diff --git a/docs/developers/roadmap.md b/docs/developers/roadmap.md index 0fb05f8ad08..125a4d36ee9 100644 --- a/docs/developers/roadmap.md +++ b/docs/developers/roadmap.md @@ -40,7 +40,7 @@ | Feedback | `V0.1.0+` | Feedback mechanism (/bug command) | Administrative Capabilities | | Stats | `V0.1.0+` | Usage statistics and quota display | Administrative Capabilities | | Memory | `V0.0.9+` | Project-level and global memory management | User Experience | -| Cache Control | `V0.0.9+` | DashScope cache control | User Experience | +| Cache Control | `V0.0.9+` | Prompt caching control (Anthropic, DashScope) | User Experience | | PlanMode | `V0.0.14` | Task planning mode | Coding Workflow | | Compress | `V0.0.11` | Chat compression mechanism | User Experience | | SubAgent | `V0.0.11` | Dedicated sub-agent system | Coding Workflow | diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 943045608cd..66caa060882 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -629,7 +629,8 @@ const SETTINGS_SCHEMA = { category: 'Generation Configuration', requiresRestart: false, default: false, - description: 'Disable cache control for DashScope providers.', + description: + 'Disable cache control for Anthropic and DashScope providers.', parentKey: 'generationConfig', showInDialog: false, }, diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index d6678763556..97f5e4d2d5f 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -75,6 +75,7 @@ export class AnthropicContentGenerator implements ContentGenerator { this.converter = new AnthropicContentConverter( contentGeneratorConfig.model, contentGeneratorConfig.schemaCompliance, + contentGeneratorConfig.disableCacheControl, ); } diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 14671b6ced6..b0fe105bcde 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -33,7 +33,13 @@ describe('AnthropicContentConverter', () => { config: { systemInstruction: 'sys' }, }); - expect(system).toBe('sys'); + expect(system).toEqual([ + { + type: 'text', + text: 'sys', + cache_control: { type: 'ephemeral' }, + }, + ]); }); it('extracts systemInstruction text from parts and joins with newlines', () => { @@ -48,7 +54,13 @@ describe('AnthropicContentConverter', () => { }, }); - expect(system).toBe('a\nb'); + expect(system).toEqual([ + { + type: 'text', + text: 'a\nb', + cache_control: { type: 'ephemeral' }, + }, + ]); }); it('converts a plain string content into a user message', () => { @@ -58,7 +70,16 @@ describe('AnthropicContentConverter', () => { }); expect(messages).toEqual([ - { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + { + role: 'user', + content: [ + { + type: 'text', + text: 'Hello', + cache_control: { type: 'ephemeral' }, + }, + ], + }, ]); }); @@ -78,7 +99,11 @@ describe('AnthropicContentConverter', () => { role: 'user', content: [ { type: 'text', text: 'Hello' }, - { type: 'text', text: 'World' }, + { + type: 'text', + text: 'World', + cache_control: { type: 'ephemeral' }, + }, ], }, ]); @@ -651,6 +676,7 @@ describe('AnthropicContentConverter', () => { properties: { location: { type: 'string' } }, required: ['location'], }, + cache_control: { type: 'ephemeral' }, }); expect(vi.mocked(convertSchema)).toHaveBeenCalledTimes(1); @@ -694,6 +720,7 @@ describe('AnthropicContentConverter', () => { name: 'no_params', description: 'no params', input_schema: { type: 'object', properties: {} }, + cache_control: { type: 'ephemeral' }, }); }); @@ -786,4 +813,78 @@ describe('AnthropicContentConverter', () => { expect(converter.mapAnthropicFinishReasonToGemini('')).toBeUndefined(); }); }); + + describe('disableCacheControl', () => { + it('does not add cache_control to system when disabled', () => { + const noCacheConverter = new AnthropicContentConverter( + 'test-model', + 'auto', + true, + ); + const { system } = noCacheConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'hi', + config: { systemInstruction: 'sys' }, + }); + + expect(system).toBe('sys'); + }); + + it('does not add cache_control to messages when disabled', () => { + const noCacheConverter = new AnthropicContentConverter( + 'test-model', + 'auto', + true, + ); + const { messages } = noCacheConverter.convertGeminiRequestToAnthropic({ + model: 'models/test', + contents: 'Hello', + }); + + expect(messages).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + ]); + }); + + it('does not add cache_control to tools when disabled', async () => { + const noCacheConverter = new AnthropicContentConverter( + 'test-model', + 'auto', + true, + ); + const tools = [ + { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather', + parametersJsonSchema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }, + ], + }, + ] as Tool[]; + + const result = + await noCacheConverter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'get_weather', + description: 'Get weather', + input_schema: { + type: 'object', + properties: { location: { type: 'string' } }, + required: ['location'], + }, + }); + expect(result[0]).not.toHaveProperty('cache_control'); + }); + }); }); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 4aade511b61..688953d4b17 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -26,32 +26,48 @@ import { } from '../../utils/schemaConverter.js'; type AnthropicMessageParam = Anthropic.MessageParam; -type AnthropicToolParam = Anthropic.Tool; +type AnthropicToolParam = Anthropic.Tool & { + cache_control?: { type: 'ephemeral' }; +}; type AnthropicContentBlockParam = Anthropic.ContentBlockParam; export class AnthropicContentConverter { private model: string; private schemaCompliance: SchemaComplianceMode; + private disableCacheControl: boolean; - constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') { + constructor( + model: string, + schemaCompliance: SchemaComplianceMode = 'auto', + disableCacheControl: boolean = false, + ) { this.model = model; this.schemaCompliance = schemaCompliance; + this.disableCacheControl = disableCacheControl; } convertGeminiRequestToAnthropic(request: GenerateContentParameters): { - system?: string; + system?: Anthropic.TextBlockParam[] | string; messages: AnthropicMessageParam[]; } { const messages: AnthropicMessageParam[] = []; - const system = this.extractTextFromContentUnion( + const systemText = this.extractTextFromContentUnion( request.config?.systemInstruction, ); this.processContents(request.contents, messages); + // Add cache_control to enable prompt caching (if not disabled) + const system = this.disableCacheControl + ? systemText + : this.buildSystemWithCacheControl(systemText); + if (!this.disableCacheControl) { + this.addCacheControlToMessages(messages); + } + return { - system: system || undefined, + system, messages, }; } @@ -103,6 +119,15 @@ export class AnthropicContentConverter { } } + // Add cache_control to the last tool for prompt caching (if not disabled) + if (!this.disableCacheControl && tools.length > 0) { + const lastToolIndex = tools.length - 1; + tools[lastToolIndex] = { + ...tools[lastToolIndex], + cache_control: { type: 'ephemeral' }, + }; + } + return tools; } @@ -497,4 +522,59 @@ export class AnthropicContentConverter { Array.isArray((content as Record)['parts']) ); } + + /** + * Build system content blocks with cache_control. + * Anthropic prompt caching requires cache_control on system content. + */ + private buildSystemWithCacheControl( + systemText: string, + ): Anthropic.TextBlockParam[] | string { + if (!systemText) { + return systemText; + } + + return [ + { + type: 'text', + text: systemText, + cache_control: { type: 'ephemeral' }, + }, + ]; + } + + /** + * Add cache_control to the last user message's content. + * This enables prompt caching for the conversation context. + */ + private addCacheControlToMessages(messages: Anthropic.MessageParam[]): void { + // Find the last user message to add cache_control + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === 'user') { + const content = Array.isArray(msg.content) + ? msg.content + : [{ type: 'text' as const, text: msg.content }]; + + if (content.length > 0) { + const lastContent = content[content.length - 1]; + // Only add cache_control if the last block is a non-empty text block + if ( + typeof lastContent === 'object' && + 'type' in lastContent && + lastContent.type === 'text' && + 'text' in lastContent && + lastContent.text + ) { + lastContent.cache_control = { + type: 'ephemeral', + }; + } + // If last block is not text or is empty, don't add cache_control + msg.content = content; + } + break; + } + } + } } diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 6ac6d9c72f3..d455c42f4d2 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -71,7 +71,7 @@ export type ContentGeneratorConfig = { openAILoggingDir?: string; timeout?: number; // Timeout configuration in milliseconds maxRetries?: number; // Maximum retries for failed requests - disableCacheControl?: boolean; // Disable cache control for DashScope providers + disableCacheControl?: boolean; // Disable prompt caching (Anthropic, DashScope) samplingParams?: { top_p?: number; top_k?: number;