From 989fab565d7316b6a192872f7eb0f60ca67be1b3 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Sat, 27 Dec 2025 19:52:59 +0000 Subject: [PATCH] feat: implement prompt caching for Claude API requests Add cache control to system prompt, tools, and conversation messages to optimize API performance and reduce costs by caching frequently reused content. Update GitHub link to point directly to project page. Improve prompt for user suggestion during actions that require confirmation. Update unit tests. Bump dependency versions. --- AIClasses/Claude/Claude.ts | 79 ++++- .../FunctionDefinitions/AIFunctionResponse.ts | 16 +- Modals/HelpModalSvelte.svelte | 2 +- Services/VaultService.ts | 2 +- __tests__/AIClasses/Claude.test.ts | 276 +++++++++++++++++- package-lock.json | 22 +- package.json | 4 +- 7 files changed, 376 insertions(+), 25 deletions(-) diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index 0d687c3..81f7721 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -8,7 +8,7 @@ import { fromString as aiFunctionFromString } from "Enums/AIFunction"; import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFunctionDefinition"; import type { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; -import type { RawMessageStreamEvent, ContentBlockParam, Tool } from '@anthropic-ai/sdk/resources/messages'; +import type { RawMessageStreamEvent, ContentBlockParam, Tool, TextBlockParam, ToolUnion, WebSearchTool20250305 } from '@anthropic-ai/sdk/resources/messages'; import { Exception } from "Helpers/Exception"; import { MimeType, toMimeType } from "Enums/MimeType"; import { isTextFile } from "Enums/FileType"; @@ -47,17 +47,33 @@ export class Claude extends BaseAIClass { await this.aiFileService.refreshCache(); } - const systemPrompt = await this.buildSystemPrompt(); + // Build system prompt and convert to array with cache control + const systemPromptText = await this.buildSystemPrompt(); + const systemPrompt: TextBlockParam[] = [ + { + type: "text", + text: systemPromptText, + cache_control: { type: "ephemeral" } + } + ]; - const messages = await this.extractContents(conversation.contents); + const messages = this.addCacheControlToMessages( + await this.extractContents(conversation.contents)); - const tools = [{ + const webSearchTool: WebSearchTool20250305 = { type: "web_search_20250305", name: "web_search", max_uses: 5 - }, ...this.mapFunctionDefinitions( - this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions) - )]; + }; + + let tools: ToolUnion[] = [ + webSearchTool, + ...this.mapFunctionDefinitions( + this.aiFunctionDefinitions.getQueryActions(allowDestructiveActions) + ) + ]; + + tools = this.addCacheControlToTools(tools); const requestBody = { model: this.settingsService.settings.model, @@ -303,4 +319,53 @@ export class Claude extends BaseAIClass { private isSupportedMimeType(mimeType: MimeType): boolean { return this.SUPPORTED_MIMETYPES.includes(mimeType); } + + // Adds cache control to the last tool in the tools array. + private addCacheControlToTools(tools: ToolUnion[]): ToolUnion[] { + if (tools.length === 0) { + return tools; + } + + const cachedTools = [...tools]; + const lastIndex = cachedTools.length - 1; + + cachedTools[lastIndex] = { + ...cachedTools[lastIndex], + cache_control: { type: "ephemeral" } + }; + + return cachedTools; + } + + // Adds cache control to the last content block of the second-to-last message. + private addCacheControlToMessages( + messages: { role: Role; content: ContentBlockParam[]; }[] + ): { role: Role; content: ContentBlockParam[]; }[] { + if (messages.length < 2) { + return messages; + } + + const cachedMessages = messages.map(msg => ({ + ...msg, + content: [...msg.content] + })); + + const secondToLastIndex = cachedMessages.length - 2; + const secondToLastMessage = cachedMessages[secondToLastIndex]; + + const contentLength = secondToLastMessage.content.length; + if (contentLength === 0) { + return messages; + } + + const lastContentIndex = contentLength - 1; + const lastContentBlock = secondToLastMessage.content[lastContentIndex]; + + secondToLastMessage.content[lastContentIndex] = { + ...lastContentBlock, + cache_control: { type: "ephemeral" } + } as ContentBlockParam; + + return cachedMessages; + } } \ No newline at end of file diff --git a/AIClasses/FunctionDefinitions/AIFunctionResponse.ts b/AIClasses/FunctionDefinitions/AIFunctionResponse.ts index 112ecc6..467c1ea 100644 --- a/AIClasses/FunctionDefinitions/AIFunctionResponse.ts +++ b/AIClasses/FunctionDefinitions/AIFunctionResponse.ts @@ -13,7 +13,21 @@ export class AIFunctionResponse { **CRITICAL:** Immediately stop all further actions and consult with the user`; - public static readonly UserSuggestionMessage: string = "The user has rejected the change with the following suggestion: "; + public static readonly UserSuggestionMessage: string = `**USER MODIFICATION REQUEST:** + + The user has reviewed your proposed action and provided a modification or alternative direction. + + **Critical Instructions:** + 1. This is NOT an error or failure - this is valuable user guidance that should be taken seriously + 2. The user may want to: + - Adjust the SAME action with different parameters (e.g., write to a different file) + - Change to a DIFFERENT action entirely (e.g., delete instead of write) + - Add context or constraints you didn't initially consider + 3. Carefully analyze the user's suggestion below to understand their true intent + 4. Acknowledge their feedback and explain how you'll adjust your approach + 5. Then proceed with the modified action that aligns with their guidance + + **User's Suggestion:**`; constructor(name: AIFunction, response: object, toolId?: string) { this.name = name; diff --git a/Modals/HelpModalSvelte.svelte b/Modals/HelpModalSvelte.svelte index c7fc980..5d5b6de 100644 --- a/Modals/HelpModalSvelte.svelte +++ b/Modals/HelpModalSvelte.svelte @@ -182,7 +182,7 @@ {@html content} {#if selectedTopic === 1} { }); }); + describe('addCacheControlToTools', () => { + it('should add cache control to the last tool in the array', () => { + const tools = [ + { + name: 'search_vault_files', + description: 'Search for files', + input_schema: { + type: 'object' as const, + properties: { query: { type: 'string' } } + } + }, + { + name: 'read_file', + description: 'Read a file', + input_schema: { + type: 'object' as const, + properties: { path: { type: 'string' } } + } + } + ]; + + const result = (claude as any).addCacheControlToTools(tools); + + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('cache_control'); + expect(result[1]).toHaveProperty('cache_control'); + expect(result[1].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should handle single tool array', () => { + const tools = [ + { + name: 'search_vault_files', + description: 'Search for files', + input_schema: { + type: 'object' as const, + properties: { query: { type: 'string' } } + } + } + ]; + + const result = (claude as any).addCacheControlToTools(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('cache_control'); + expect(result[0].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should handle empty tools array', () => { + const tools: any[] = []; + + const result = (claude as any).addCacheControlToTools(tools); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('should not mutate the original tools array', () => { + const tools = [ + { + name: 'search_vault_files', + description: 'Search for files', + input_schema: { + type: 'object' as const, + properties: { query: { type: 'string' } } + } + } + ]; + + const result = (claude as any).addCacheControlToTools(tools); + + // Original array should not have cache_control + expect(tools[0]).not.toHaveProperty('cache_control'); + // Result should have cache_control + expect(result[0]).toHaveProperty('cache_control'); + }); + + it('should add cache control to web_search tool', () => { + const tools = [ + { + type: 'web_search_20250305', + name: 'web_search', + max_uses: 5 + } + ]; + + const result = (claude as any).addCacheControlToTools(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('cache_control'); + expect(result[0].cache_control).toEqual({ type: 'ephemeral' }); + expect(result[0].type).toBe('web_search_20250305'); + expect(result[0].name).toBe('web_search'); + }); + }); + + describe('addCacheControlToMessages', () => { + it('should add cache control to the last content block of the second-to-last message', () => { + const messages = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'First message' }] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Second message' }] + }, + { + role: Role.User, + content: [{ type: 'text' as const, text: 'Third message' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(3); + // First message should not have cache control + expect(result[0].content[0]).not.toHaveProperty('cache_control'); + // Second message (second-to-last) should have cache control + expect(result[1].content[0]).toHaveProperty('cache_control'); + expect(result[1].content[0].cache_control).toEqual({ type: 'ephemeral' }); + // Third message (last) should not have cache control + expect(result[2].content[0]).not.toHaveProperty('cache_control'); + }); + + it('should add cache control to the last content block when message has multiple blocks', () => { + const messages = [ + { + role: Role.User, + content: [ + { type: 'text' as const, text: 'First block' }, + { type: 'text' as const, text: 'Second block' }, + { type: 'text' as const, text: 'Third block' } + ] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Response' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(2); + // First message, last block should have cache control + expect(result[0].content[0]).not.toHaveProperty('cache_control'); + expect(result[0].content[1]).not.toHaveProperty('cache_control'); + expect(result[0].content[2]).toHaveProperty('cache_control'); + expect(result[0].content[2].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should return messages unchanged when there are fewer than 2 messages', () => { + const singleMessage = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'Only message' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(singleMessage); + + expect(result).toHaveLength(1); + expect(result[0].content[0]).not.toHaveProperty('cache_control'); + }); + + it('should return empty array when messages array is empty', () => { + const messages: any[] = []; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('should handle messages with empty content blocks', () => { + const messages = [ + { + role: Role.User, + content: [] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Response' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(2); + // Should return original messages since second-to-last has no content + expect(result).toEqual(messages); + }); + + it('should not mutate the original messages array', () => { + const messages = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'First' }] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Second' }] + } + ]; + + const originalFirstContent = messages[0].content[0]; + const result = (claude as any).addCacheControlToMessages(messages); + + // Original should not have cache_control + expect(originalFirstContent).not.toHaveProperty('cache_control'); + // Result should have cache_control on second-to-last message + expect(result[0].content[0]).toHaveProperty('cache_control'); + }); + + it('should handle messages with tool_use and tool_result blocks', () => { + const messages = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'Search for files' }] + }, + { + role: Role.Assistant, + content: [ + { type: 'text' as const, text: 'Let me search' }, + { type: 'tool_use' as const, id: 'call_1', name: 'search', input: {} } + ] + }, + { + role: Role.User, + content: [ + { type: 'tool_result' as const, tool_use_id: 'call_1', content: '["file1.txt"]' } + ] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(3); + // Second-to-last message's last content block should have cache control + expect(result[1].content[1]).toHaveProperty('cache_control'); + expect(result[1].content[1].cache_control).toEqual({ type: 'ephemeral' }); + }); + + it('should work with exactly 2 messages', () => { + const messages = [ + { + role: Role.User, + content: [{ type: 'text' as const, text: 'Hello' }] + }, + { + role: Role.Assistant, + content: [{ type: 'text' as const, text: 'Hi' }] + } + ]; + + const result = (claude as any).addCacheControlToMessages(messages); + + expect(result).toHaveLength(2); + // First message (second-to-last when length=2) should have cache control + expect(result[0].content[0]).toHaveProperty('cache_control'); + expect(result[0].content[0].cache_control).toEqual({ type: 'ephemeral' }); + // Second message should not have cache control + expect(result[1].content[0]).not.toHaveProperty('cache_control'); + }); + }); + describe('streamRequest', () => { - it('should call streamingService with correct parameters', async () => { + it('should call streamingService with correct parameters including cache control', async () => { const conversation = new Conversation(); conversation.contents.push(new ConversationContent({ role: Role.User, content: 'Test message' })); @@ -847,7 +1113,13 @@ describe('Claude', () => { expect.objectContaining({ model: 'claude-opus-4-20250514', max_tokens: 16384, - system: 'System instruction\n\nUser instruction', + system: [ + { + type: 'text', + text: 'System instruction\n\nUser instruction', + cache_control: { type: 'ephemeral' } + } + ], messages: expect.any(Array), tools: expect.any(Array), stream: true diff --git a/package-lock.json b/package-lock.json index 1fcdc11..37e7bb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.2", - "@testing-library/svelte": "^5.3.0", + "@testing-library/svelte": "^5.3.1", "@types/express": "^5.0.6", "@types/node": "^25.0.3", "@types/path-browserify": "^1.0.3", @@ -54,7 +54,7 @@ "eslint-plugin-obsidianmd": "^0.1.9", "happy-dom": "^20.0.11", "obsidian": "latest", - "svelte": "^5.46.0", + "svelte": "^5.46.1", "svelte-check": "^4.3.5", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1", @@ -1462,9 +1462,9 @@ } }, "node_modules/@testing-library/svelte": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.0.tgz", - "integrity": "sha512-N1b+OAZNF34iMmtTbj5TyE0Ff+NJ/Mp6E483qOIeyzRUKOTcj2N5jEEl56RJeNq5jeQ4kZp/oMA0hCybXydrtQ==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", "dev": true, "license": "MIT", "dependencies": { @@ -8872,9 +8872,9 @@ } }, "node_modules/svelte": { - "version": "5.46.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz", - "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", + "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "dev": true, "license": "MIT", "dependencies": { @@ -9125,9 +9125,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.2.0.tgz", + "integrity": "sha512-L6f5oQRAoLU1RwXz0Ab9mxsE7LtxeVB6AIR1lpkZMsOyg/JXeaxBaXa/FVCBZyNr9S9I4wkHrlZTklX+im+WMw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 038b1ab..48e52bd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.2", - "@testing-library/svelte": "^5.3.0", + "@testing-library/svelte": "^5.3.1", "@types/express": "^5.0.6", "@types/node": "^25.0.3", "@types/path-browserify": "^1.0.3", @@ -36,7 +36,7 @@ "eslint-plugin-obsidianmd": "^0.1.9", "happy-dom": "^20.0.11", "obsidian": "latest", - "svelte": "^5.46.0", + "svelte": "^5.46.1", "svelte-check": "^4.3.5", "svelte-preprocess": "^6.0.3", "tslib": "2.8.1",