mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
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:
parent
42fb73383f
commit
989fab565d
7 changed files with 376 additions and 25 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
22
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue