Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/developers/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
this.converter = new AnthropicContentConverter(
contentGeneratorConfig.model,
contentGeneratorConfig.schemaCompliance,
contentGeneratorConfig.disableCacheControl,
);
}

Expand Down
109 changes: 105 additions & 4 deletions packages/core/src/core/anthropicContentGenerator/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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' },
},
],
},
]);
});

Expand All @@ -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' },
},
],
},
]);
Expand Down Expand Up @@ -651,6 +676,7 @@ describe('AnthropicContentConverter', () => {
properties: { location: { type: 'string' } },
required: ['location'],
},
cache_control: { type: 'ephemeral' },
});

expect(vi.mocked(convertSchema)).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -694,6 +720,7 @@ describe('AnthropicContentConverter', () => {
name: 'no_params',
description: 'no params',
input_schema: { type: 'object', properties: {} },
cache_control: { type: 'ephemeral' },
});
});

Expand Down Expand Up @@ -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');
});
});
});
90 changes: 85 additions & 5 deletions packages/core/src/core/anthropicContentGenerator/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -497,4 +522,59 @@ export class AnthropicContentConverter {
Array.isArray((content as Record<string, unknown>)['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;
}
}
}
}
2 changes: 1 addition & 1 deletion packages/core/src/core/contentGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down