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.
This commit is contained in:
Andrew Beal 2025-12-27 19:52:59 +00:00
parent 42fb73383f
commit 989fab565d
7 changed files with 376 additions and 25 deletions

View file

@ -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;
}
}

View file

@ -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;

View file

@ -182,7 +182,7 @@
{@html content}
{#if selectedTopic === 1}
<a
href={plugin.manifest.authorUrl}
href="{plugin.manifest.authorUrl}/vaultkeeper-ai"
style="text-decoration: none; display: inline-flex; align-items: center; gap: 0.5em; margin: 0 0 1em 0;">
<svg
width="1em"

View file

@ -526,7 +526,7 @@ export class VaultService {
let response = AIFunctionResponse.UserRejectionMessage;
if (result.suggestion) {
response = AIFunctionResponse.UserSuggestionMessage + result.suggestion;
response = `${AIFunctionResponse.UserSuggestionMessage}\n${result.suggestion}`;
}
return Exception.new(response);
} catch (error) {

View file

@ -826,8 +826,274 @@ describe('Claude', () => {
});
});
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

22
package-lock.json generated
View file

@ -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": {

View file

@ -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",