diff --git a/.changeset/friendly-otters-sin.md b/.changeset/friendly-otters-sin.md new file mode 100644 index 000000000000..55ca672b12cf --- /dev/null +++ b/.changeset/friendly-otters-sin.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/anthropic': patch +--- + +feat(anthropic): add prompt caching validation diff --git a/packages/amazon-bedrock/src/bedrock-chat-language-model.test.ts b/packages/amazon-bedrock/src/bedrock-chat-language-model.test.ts index cab1c38ab007..5ede18a53a87 100644 --- a/packages/amazon-bedrock/src/bedrock-chat-language-model.test.ts +++ b/packages/amazon-bedrock/src/bedrock-chat-language-model.test.ts @@ -1827,7 +1827,9 @@ describe('doGenerate', () => { it('should handle Anthropic provider-defined tools', async () => { mockPrepareAnthropicTools.mockReturnValue( Promise.resolve({ - tools: [{ name: 'bash', type: 'bash_20241022' }], + tools: [ + { name: 'bash', type: 'bash_20241022', cache_control: undefined }, + ], toolChoice: { type: 'auto' }, toolWarnings: [], betas: new Set(['computer-use-2024-10-22']), diff --git a/packages/anthropic/src/anthropic-messages-api.ts b/packages/anthropic/src/anthropic-messages-api.ts index 1d2197e65b8a..764a8c6a3a3c 100644 --- a/packages/anthropic/src/anthropic-messages-api.ts +++ b/packages/anthropic/src/anthropic-messages-api.ts @@ -11,6 +11,7 @@ export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage; export type AnthropicCacheControl = { type: 'ephemeral'; + ttl?: '5m' | '1h'; }; export interface AnthropicUserMessage { @@ -51,13 +52,17 @@ export interface AnthropicThinkingContent { type: 'thinking'; thinking: string; signature: string; - cache_control: AnthropicCacheControl | undefined; + // Note: thinking blocks cannot be directly cached with cache_control. + // They are cached implicitly when appearing in previous assistant turns. + cache_control?: never; } export interface AnthropicRedactedThinkingContent { type: 'redacted_thinking'; data: string; - cache_control: AnthropicCacheControl | undefined; + // Note: redacted thinking blocks cannot be directly cached with cache_control. + // They are cached implicitly when appearing in previous assistant turns. + cache_control?: never; } type AnthropicContentSource = @@ -114,13 +119,38 @@ export interface AnthropicServerToolUseContent { cache_control: AnthropicCacheControl | undefined; } +// Nested content types for tool results (without cache_control) +// Sub-content blocks cannot be cached directly according to Anthropic docs +type AnthropicNestedTextContent = Omit< + AnthropicTextContent, + 'cache_control' +> & { + cache_control?: never; +}; + +type AnthropicNestedImageContent = Omit< + AnthropicImageContent, + 'cache_control' +> & { + cache_control?: never; +}; + +type AnthropicNestedDocumentContent = Omit< + AnthropicDocumentContent, + 'cache_control' +> & { + cache_control?: never; +}; + export interface AnthropicToolResultContent { type: 'tool_result'; tool_use_id: string; content: | string | Array< - AnthropicTextContent | AnthropicImageContent | AnthropicDocumentContent + | AnthropicNestedTextContent + | AnthropicNestedImageContent + | AnthropicNestedDocumentContent >; is_error: boolean | undefined; cache_control: AnthropicCacheControl | undefined; @@ -252,6 +282,7 @@ export type AnthropicTool = | { type: 'code_execution_20250522'; name: string; + cache_control: AnthropicCacheControl | undefined; } | { type: 'code_execution_20250825'; @@ -263,6 +294,7 @@ export type AnthropicTool = display_width_px: number; display_height_px: number; display_number: number; + cache_control: AnthropicCacheControl | undefined; } | { name: string; @@ -270,15 +302,18 @@ export type AnthropicTool = | 'text_editor_20250124' | 'text_editor_20241022' | 'text_editor_20250429'; + cache_control: AnthropicCacheControl | undefined; } | { name: string; type: 'text_editor_20250728'; max_characters?: number; + cache_control: AnthropicCacheControl | undefined; } | { name: string; type: 'bash_20250124' | 'bash_20241022'; + cache_control: AnthropicCacheControl | undefined; } | { name: string; @@ -292,6 +327,7 @@ export type AnthropicTool = blocked_domains?: string[]; citations?: { enabled: boolean }; max_content_tokens?: number; + cache_control: AnthropicCacheControl | undefined; } | { type: 'web_search_20250305'; @@ -306,6 +342,7 @@ export type AnthropicTool = country?: string; timezone?: string; }; + cache_control: AnthropicCacheControl | undefined; }; export type AnthropicToolChoice = diff --git a/packages/anthropic/src/anthropic-messages-language-model.ts b/packages/anthropic/src/anthropic-messages-language-model.ts index b945d2793b0f..489efad96e12 100644 --- a/packages/anthropic/src/anthropic-messages-language-model.ts +++ b/packages/anthropic/src/anthropic-messages-language-model.ts @@ -40,6 +40,7 @@ import { } from './anthropic-messages-options'; import { prepareTools } from './anthropic-prepare-tools'; import { convertToAnthropicMessagesPrompt } from './convert-to-anthropic-messages-prompt'; +import { CacheControlValidator } from './get-cache-control'; import { mapAnthropicStopReason } from './map-anthropic-stop-reason'; function createCitationSource( @@ -199,11 +200,15 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 { schema: anthropicProviderOptions, }); + // Create a shared cache control validator to track breakpoints across tools and messages + const cacheControlValidator = new CacheControlValidator(); + const { prompt: messagesPrompt, betas } = await convertToAnthropicMessagesPrompt({ prompt, sendReasoning: anthropicOptions?.sendReasoning ?? true, warnings, + cacheControlValidator, }); const isThinking = anthropicOptions?.thinking?.type === 'enabled'; @@ -356,21 +361,26 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 { tools: [jsonResponseTool], toolChoice: { type: 'tool', toolName: jsonResponseTool.name }, disableParallelToolUse: true, + cacheControlValidator, } : { tools: tools ?? [], toolChoice, disableParallelToolUse: anthropicOptions?.disableParallelToolUse, + cacheControlValidator, }, ); + // Extract cache control warnings once at the end + const cacheWarnings = cacheControlValidator.getWarnings(); + return { args: { ...baseArgs, tools: anthropicTools, tool_choice: anthropicToolChoice, }, - warnings: [...warnings, ...toolWarnings], + warnings: [...warnings, ...toolWarnings, ...cacheWarnings], betas: new Set([...betas, ...toolsBetas]), usesJsonResponseTool: jsonResponseTool != null, }; diff --git a/packages/anthropic/src/anthropic-prepare-tools.test.ts b/packages/anthropic/src/anthropic-prepare-tools.test.ts index 4f66991ebd65..eb5fb0f22d93 100644 --- a/packages/anthropic/src/anthropic-prepare-tools.test.ts +++ b/packages/anthropic/src/anthropic-prepare-tools.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { prepareTools } from './anthropic-prepare-tools'; +import { CacheControlValidator } from './get-cache-control'; describe('prepareTools', () => { it('should return undefined tools and tool_choice when tools are null', async () => { @@ -71,6 +72,7 @@ describe('prepareTools', () => { "toolWarnings": [], "tools": [ { + "cache_control": undefined, "display_height_px": 600, "display_number": 1, "display_width_px": 800, @@ -104,6 +106,7 @@ describe('prepareTools', () => { "toolWarnings": [], "tools": [ { + "cache_control": undefined, "name": "str_replace_editor", "type": "text_editor_20241022", }, @@ -134,6 +137,7 @@ describe('prepareTools', () => { "toolWarnings": [], "tools": [ { + "cache_control": undefined, "name": "bash", "type": "bash_20241022", }, @@ -160,6 +164,7 @@ describe('prepareTools', () => { "toolWarnings": [], "tools": [ { + "cache_control": undefined, "max_characters": 10000, "name": "str_replace_based_edit_tool", "type": "text_editor_20250728", @@ -187,6 +192,7 @@ describe('prepareTools', () => { "toolWarnings": [], "tools": [ { + "cache_control": undefined, "max_characters": undefined, "name": "str_replace_based_edit_tool", "type": "text_editor_20250728", @@ -222,6 +228,7 @@ describe('prepareTools', () => { "https://www.google.com", ], "blocked_domains": undefined, + "cache_control": undefined, "max_uses": 10, "name": "web_search", "type": "web_search_20250305", @@ -265,6 +272,7 @@ describe('prepareTools', () => { "https://www.google.com", ], "blocked_domains": undefined, + "cache_control": undefined, "citations": { "enabled": true, }, @@ -398,4 +406,82 @@ describe('prepareTools', () => { ] `); }); + + it('should limit cache breakpoints to 4', async () => { + const cacheControlValidator = new CacheControlValidator(); + const result = await prepareTools({ + tools: [ + { + type: 'function', + name: 'tool1', + description: 'Test 1', + inputSchema: {}, + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + { + type: 'function', + name: 'tool2', + description: 'Test 2', + inputSchema: {}, + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + { + type: 'function', + name: 'tool3', + description: 'Test 3', + inputSchema: {}, + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + { + type: 'function', + name: 'tool4', + description: 'Test 4', + inputSchema: {}, + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + { + type: 'function', + name: 'tool5', + description: 'Test 5 (should be rejected)', + inputSchema: {}, + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + ], + cacheControlValidator, + }); + + // First 4 should have cache_control + expect(result.tools?.[0]).toHaveProperty('cache_control', { + type: 'ephemeral', + }); + expect(result.tools?.[1]).toHaveProperty('cache_control', { + type: 'ephemeral', + }); + expect(result.tools?.[2]).toHaveProperty('cache_control', { + type: 'ephemeral', + }); + expect(result.tools?.[3]).toHaveProperty('cache_control', { + type: 'ephemeral', + }); + + // 5th should be rejected (cache_control should be undefined) + expect(result.tools?.[4]).toHaveProperty('cache_control', undefined); + + // Should have warning + expect(cacheControlValidator.getWarnings()).toContainEqual({ + type: 'unsupported-setting', + setting: 'cacheControl', + details: expect.stringContaining('Maximum 4 cache breakpoints exceeded'), + }); + }); }); diff --git a/packages/anthropic/src/anthropic-prepare-tools.ts b/packages/anthropic/src/anthropic-prepare-tools.ts index 509e2275e9d7..03e680bf0b95 100644 --- a/packages/anthropic/src/anthropic-prepare-tools.ts +++ b/packages/anthropic/src/anthropic-prepare-tools.ts @@ -4,7 +4,7 @@ import { UnsupportedFunctionalityError, } from '@ai-sdk/provider'; import { AnthropicTool, AnthropicToolChoice } from './anthropic-messages-api'; -import { getCacheControl } from './get-cache-control'; +import { CacheControlValidator } from './get-cache-control'; import { textEditor_20250728ArgsSchema } from './tool/text-editor_20250728'; import { webSearch_20250305ArgsSchema } from './tool/web-search_20250305'; import { webFetch_20250910ArgsSchema } from './tool/web-fetch-20250910'; @@ -14,10 +14,12 @@ export async function prepareTools({ tools, toolChoice, disableParallelToolUse, + cacheControlValidator, }: { tools: LanguageModelV3CallOptions['tools']; toolChoice?: LanguageModelV3CallOptions['toolChoice']; disableParallelToolUse?: boolean; + cacheControlValidator?: CacheControlValidator; }): Promise<{ tools: Array | undefined; toolChoice: AnthropicToolChoice | undefined; @@ -29,6 +31,7 @@ export async function prepareTools({ const toolWarnings: LanguageModelV3CallWarning[] = []; const betas = new Set(); + const validator = cacheControlValidator || new CacheControlValidator(); if (tools == null) { return { tools: undefined, toolChoice: undefined, toolWarnings, betas }; @@ -39,7 +42,10 @@ export async function prepareTools({ for (const tool of tools) { switch (tool.type) { case 'function': { - const cacheControl = getCacheControl(tool.providerOptions); + const cacheControl = validator.getCacheControl(tool.providerOptions, { + type: 'tool definition', + canCache: true, + }); anthropicTools.push({ name: tool.name, @@ -51,12 +57,16 @@ export async function prepareTools({ } case 'provider-defined': { + // Note: Provider-defined tools don't currently support providerOptions in the SDK, + // so cache_control cannot be set on them. The Anthropic API supports caching all tools, + // but the SDK would need to be updated to expose providerOptions on provider-defined tools. switch (tool.id) { case 'anthropic.code_execution_20250522': { betas.add('code-execution-2025-05-22'); anthropicTools.push({ type: 'code_execution_20250522', name: 'code_execution', + cache_control: undefined, }); break; } @@ -76,6 +86,7 @@ export async function prepareTools({ display_width_px: tool.args.displayWidthPx as number, display_height_px: tool.args.displayHeightPx as number, display_number: tool.args.displayNumber as number, + cache_control: undefined, }); break; } @@ -87,6 +98,7 @@ export async function prepareTools({ display_width_px: tool.args.displayWidthPx as number, display_height_px: tool.args.displayHeightPx as number, display_number: tool.args.displayNumber as number, + cache_control: undefined, }); break; } @@ -95,6 +107,7 @@ export async function prepareTools({ anthropicTools.push({ name: 'str_replace_editor', type: 'text_editor_20250124', + cache_control: undefined, }); break; } @@ -103,6 +116,7 @@ export async function prepareTools({ anthropicTools.push({ name: 'str_replace_editor', type: 'text_editor_20241022', + cache_control: undefined, }); break; } @@ -111,6 +125,7 @@ export async function prepareTools({ anthropicTools.push({ name: 'str_replace_based_edit_tool', type: 'text_editor_20250429', + cache_control: undefined, }); break; } @@ -123,6 +138,7 @@ export async function prepareTools({ name: 'str_replace_based_edit_tool', type: 'text_editor_20250728', max_characters: args.maxCharacters, + cache_control: undefined, }); break; } @@ -131,6 +147,7 @@ export async function prepareTools({ anthropicTools.push({ name: 'bash', type: 'bash_20250124', + cache_control: undefined, }); break; } @@ -139,6 +156,7 @@ export async function prepareTools({ anthropicTools.push({ name: 'bash', type: 'bash_20241022', + cache_control: undefined, }); break; } @@ -164,6 +182,7 @@ export async function prepareTools({ blocked_domains: args.blockedDomains, citations: args.citations, max_content_tokens: args.maxContentTokens, + cache_control: undefined, }); break; } @@ -179,6 +198,7 @@ export async function prepareTools({ allowed_domains: args.allowedDomains, blocked_domains: args.blockedDomains, user_location: args.userLocation, + cache_control: undefined, }); break; } diff --git a/packages/anthropic/src/convert-to-anthropic-messages-prompt.test.ts b/packages/anthropic/src/convert-to-anthropic-messages-prompt.test.ts index 1e155b86d443..17488777cef9 100644 --- a/packages/anthropic/src/convert-to-anthropic-messages-prompt.test.ts +++ b/packages/anthropic/src/convert-to-anthropic-messages-prompt.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { LanguageModelV3CallWarning } from '@ai-sdk/provider'; import { convertToAnthropicMessagesPrompt } from './convert-to-anthropic-messages-prompt'; +import { CacheControlValidator } from './get-cache-control'; describe('system messages', () => { it('should convert a single system message into an anthropic system message', async () => { @@ -463,12 +464,10 @@ describe('tool messages', () => { "cache_control": undefined, "content": [ { - "cache_control": undefined, "text": "Image generated successfully", "type": "text", }, { - "cache_control": undefined, "source": { "data": "AAECAw==", "media_type": "image/png", @@ -536,12 +535,10 @@ describe('tool messages', () => { "cache_control": undefined, "content": [ { - "cache_control": undefined, "text": "PDF generated successfully", "type": "text", }, { - "cache_control": undefined, "source": { "data": "JVBERi0xLjQKJeLjz9MKNCAwIG9iago=", "media_type": "application/pdf", @@ -1745,6 +1742,184 @@ describe('cache control', () => { }); }); }); + + describe('cache control validation', () => { + it('should reject cache_control on thinking blocks', async () => { + const warnings: LanguageModelV3CallWarning[] = []; + const cacheControlValidator = new CacheControlValidator(); + const result = await convertToAnthropicMessagesPrompt({ + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'reasoning', + text: 'thinking content', + providerOptions: { + anthropic: { + signature: 'test-sig', + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + ], + }, + ], + sendReasoning: true, + warnings, + cacheControlValidator, + }); + + expect(result).toEqual({ + prompt: { + messages: [ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'thinking content', + signature: 'test-sig', + }, + ], + }, + ], + }, + betas: new Set(), + }); + + expect(cacheControlValidator.getWarnings()).toContainEqual({ + type: 'unsupported-setting', + setting: 'cacheControl', + details: + 'cache_control cannot be set on thinking block. It will be ignored.', + }); + }); + + it('should reject cache_control on redacted thinking blocks', async () => { + const warnings: LanguageModelV3CallWarning[] = []; + const cacheControlValidator = new CacheControlValidator(); + const result = await convertToAnthropicMessagesPrompt({ + prompt: [ + { + role: 'assistant', + content: [ + { + type: 'reasoning', + text: 'redacted', + providerOptions: { + anthropic: { + redactedData: 'abc123', + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + ], + }, + ], + sendReasoning: true, + warnings, + cacheControlValidator, + }); + + expect(result.prompt.messages[0].content[0]).not.toHaveProperty( + 'cache_control', + ); + + expect(cacheControlValidator.getWarnings()).toContainEqual({ + type: 'unsupported-setting', + setting: 'cacheControl', + details: + 'cache_control cannot be set on redacted thinking block. It will be ignored.', + }); + }); + }); + + it('should limit cache breakpoints to 4', async () => { + const warnings: LanguageModelV3CallWarning[] = []; + const cacheControlValidator = new CacheControlValidator(); + const result = await convertToAnthropicMessagesPrompt({ + prompt: [ + { + role: 'system', + content: 'system 1', + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + { + role: 'system', + content: 'system 2', + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + { + role: 'user', + content: [ + { + type: 'text', + text: 'user 1', + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + ], + }, + { + role: 'assistant', + content: [ + { + type: 'text', + text: 'assistant 1', + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'text', + text: 'user 2 (should be rejected)', + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + }, + }, + ], + }, + ], + sendReasoning: true, + warnings, + cacheControlValidator, + }); + + // First 4 should have cache_control + expect(result.prompt.system?.[0].cache_control).toEqual({ + type: 'ephemeral', + }); + expect(result.prompt.system?.[1].cache_control).toEqual({ + type: 'ephemeral', + }); + expect(result.prompt.messages[0].content[0].cache_control).toEqual({ + type: 'ephemeral', + }); + expect(result.prompt.messages[1].content[0].cache_control).toEqual({ + type: 'ephemeral', + }); + + // 5th should be rejected + expect(result.prompt.messages[2].content[0].cache_control).toBeUndefined(); + + // Should have warning about exceeding limit + expect(cacheControlValidator.getWarnings()).toContainEqual({ + type: 'unsupported-setting', + setting: 'cacheControl', + details: expect.stringContaining('Maximum 4 cache breakpoints exceeded'), + }); + }); }); describe('citations', () => { diff --git a/packages/anthropic/src/convert-to-anthropic-messages-prompt.ts b/packages/anthropic/src/convert-to-anthropic-messages-prompt.ts index c518f3113ae5..55553e44fadd 100644 --- a/packages/anthropic/src/convert-to-anthropic-messages-prompt.ts +++ b/packages/anthropic/src/convert-to-anthropic-messages-prompt.ts @@ -21,7 +21,7 @@ import { AnthropicWebFetchToolResultContent, } from './anthropic-messages-api'; import { anthropicFilePartProviderOptions } from './anthropic-messages-options'; -import { getCacheControl } from './get-cache-control'; +import { CacheControlValidator } from './get-cache-control'; import { codeExecution_20250522OutputSchema } from './tool/code-execution_20250522'; import { codeExecution_20250825OutputSchema } from './tool/code-execution_20250825'; import { webFetch_20250910OutputSchema } from './tool/web-fetch-20250910'; @@ -51,16 +51,19 @@ export async function convertToAnthropicMessagesPrompt({ prompt, sendReasoning, warnings, + cacheControlValidator, }: { prompt: LanguageModelV3Prompt; sendReasoning: boolean; warnings: LanguageModelV3CallWarning[]; + cacheControlValidator?: CacheControlValidator; }): Promise<{ prompt: AnthropicMessagesPrompt; betas: Set; }> { const betas = new Set(); const blocks = groupIntoBlocks(prompt); + const validator = cacheControlValidator || new CacheControlValidator(); let system: AnthropicMessagesPrompt['system'] = undefined; const messages: AnthropicMessagesPrompt['messages'] = []; @@ -109,7 +112,10 @@ export async function convertToAnthropicMessagesPrompt({ system = block.messages.map(({ content, providerOptions }) => ({ type: 'text', text: content, - cache_control: getCacheControl(providerOptions), + cache_control: validator.getCacheControl(providerOptions, { + type: 'system message', + canCache: true, + }), })); break; @@ -132,9 +138,15 @@ export async function convertToAnthropicMessagesPrompt({ const isLastPart = j === content.length - 1; const cacheControl = - getCacheControl(part.providerOptions) ?? + validator.getCacheControl(part.providerOptions, { + type: 'user message part', + canCache: true, + }) ?? (isLastPart - ? getCacheControl(message.providerOptions) + ? validator.getCacheControl(message.providerOptions, { + type: 'user message', + canCache: true, + }) : undefined); switch (part.type) { @@ -250,9 +262,15 @@ export async function convertToAnthropicMessagesPrompt({ const isLastPart = i === content.length - 1; const cacheControl = - getCacheControl(part.providerOptions) ?? + validator.getCacheControl(part.providerOptions, { + type: 'tool result part', + canCache: true, + }) ?? (isLastPart - ? getCacheControl(message.providerOptions) + ? validator.getCacheControl(message.providerOptions, { + type: 'tool result message', + canCache: true, + }) : undefined); const output = part.output; @@ -266,7 +284,6 @@ export async function convertToAnthropicMessagesPrompt({ return { type: 'text' as const, text: contentPart.text, - cache_control: cacheControl, }; case 'image-data': { return { @@ -276,13 +293,11 @@ export async function convertToAnthropicMessagesPrompt({ media_type: contentPart.mediaType, data: contentPart.data, }, - cache_control: cacheControl, }; } case 'file-data': { if (contentPart.mediaType === 'application/pdf') { betas.add('pdfs-2024-09-25'); - return { type: 'document' as const, source: { @@ -290,7 +305,6 @@ export async function convertToAnthropicMessagesPrompt({ media_type: contentPart.mediaType, data: contentPart.data, }, - cache_control: cacheControl, }; } @@ -372,9 +386,15 @@ export async function convertToAnthropicMessagesPrompt({ // for the last part of a message, // check also if the message has cache control. const cacheControl = - getCacheControl(part.providerOptions) ?? + validator.getCacheControl(part.providerOptions, { + type: 'assistant message part', + canCache: true, + }) ?? (isLastContentPart - ? getCacheControl(message.providerOptions) + ? validator.getCacheControl(message.providerOptions, { + type: 'assistant message', + canCache: true, + }) : undefined); switch (part.type) { @@ -404,17 +424,29 @@ export async function convertToAnthropicMessagesPrompt({ if (reasoningMetadata != null) { if (reasoningMetadata.signature != null) { + // Note: thinking blocks cannot have cache_control directly + // They are cached implicitly when in previous assistant turns + // Validate to provide helpful error message + validator.getCacheControl(part.providerOptions, { + type: 'thinking block', + canCache: false, + }); anthropicContent.push({ type: 'thinking', thinking: part.text, signature: reasoningMetadata.signature, - cache_control: cacheControl, }); } else if (reasoningMetadata.redactedData != null) { + // Note: redacted thinking blocks cannot have cache_control directly + // They are cached implicitly when in previous assistant turns + // Validate to provide helpful error message + validator.getCacheControl(part.providerOptions, { + type: 'redacted thinking block', + canCache: false, + }); anthropicContent.push({ type: 'redacted_thinking', data: reasoningMetadata.redactedData, - cache_control: cacheControl, }); } else { warnings.push({ diff --git a/packages/anthropic/src/get-cache-control.ts b/packages/anthropic/src/get-cache-control.ts index a180ce948370..5e4657252851 100644 --- a/packages/anthropic/src/get-cache-control.ts +++ b/packages/anthropic/src/get-cache-control.ts @@ -1,7 +1,15 @@ -import { SharedV3ProviderMetadata } from '@ai-sdk/provider'; +import { + LanguageModelV3CallWarning, + SharedV3ProviderMetadata, +} from '@ai-sdk/provider'; import { AnthropicCacheControl } from './anthropic-messages-api'; -export function getCacheControl( +// Anthropic allows a maximum of 4 cache breakpoints per request +const MAX_CACHE_BREAKPOINTS = 4; + +// Helper function to extract cache_control from provider metadata +// Allows both cacheControl and cache_control for flexibility +function getCacheControl( providerMetadata: SharedV3ProviderMetadata | undefined, ): AnthropicCacheControl | undefined { const anthropic = providerMetadata?.anthropic; @@ -13,3 +21,46 @@ export function getCacheControl( // The Anthropic API will validate the value. return cacheControlValue as AnthropicCacheControl | undefined; } + +export class CacheControlValidator { + private breakpointCount = 0; + private warnings: LanguageModelV3CallWarning[] = []; + + getCacheControl( + providerMetadata: SharedV3ProviderMetadata | undefined, + context: { type: string; canCache: boolean }, + ): AnthropicCacheControl | undefined { + const cacheControlValue = getCacheControl(providerMetadata); + + if (!cacheControlValue) { + return undefined; + } + + // Validate that cache_control is allowed in this context + if (!context.canCache) { + this.warnings.push({ + type: 'unsupported-setting', + setting: 'cacheControl', + details: `cache_control cannot be set on ${context.type}. It will be ignored.`, + }); + return undefined; + } + + // Validate cache breakpoint limit + this.breakpointCount++; + if (this.breakpointCount > MAX_CACHE_BREAKPOINTS) { + this.warnings.push({ + type: 'unsupported-setting', + setting: 'cacheControl', + details: `Maximum ${MAX_CACHE_BREAKPOINTS} cache breakpoints exceeded (found ${this.breakpointCount}). This breakpoint will be ignored.`, + }); + return undefined; + } + + return cacheControlValue; + } + + getWarnings(): LanguageModelV3CallWarning[] { + return this.warnings; + } +}