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
5 changes: 5 additions & 0 deletions .changeset/friendly-otters-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ai-sdk/anthropic': patch
---

feat(anthropic): add prompt caching validation
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
Expand Down
43 changes: 40 additions & 3 deletions packages/anthropic/src/anthropic-messages-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage;

export type AnthropicCacheControl = {
type: 'ephemeral';
ttl?: '5m' | '1h';
};

export interface AnthropicUserMessage {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -252,6 +282,7 @@ export type AnthropicTool =
| {
type: 'code_execution_20250522';
name: string;
cache_control: AnthropicCacheControl | undefined;
}
| {
type: 'code_execution_20250825';
Expand All @@ -263,22 +294,26 @@ export type AnthropicTool =
display_width_px: number;
display_height_px: number;
display_number: number;
cache_control: AnthropicCacheControl | undefined;
}
| {
name: string;
type:
| '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;
Expand All @@ -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';
Expand All @@ -306,6 +342,7 @@ export type AnthropicTool =
country?: string;
timezone?: string;
};
cache_control: AnthropicCacheControl | undefined;
};

export type AnthropicToolChoice =
Expand Down
12 changes: 11 additions & 1 deletion packages/anthropic/src/anthropic-messages-language-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};
Expand Down
86 changes: 86 additions & 0 deletions packages/anthropic/src/anthropic-prepare-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -71,6 +72,7 @@ describe('prepareTools', () => {
"toolWarnings": [],
"tools": [
{
"cache_control": undefined,
"display_height_px": 600,
"display_number": 1,
"display_width_px": 800,
Expand Down Expand Up @@ -104,6 +106,7 @@ describe('prepareTools', () => {
"toolWarnings": [],
"tools": [
{
"cache_control": undefined,
"name": "str_replace_editor",
"type": "text_editor_20241022",
},
Expand Down Expand Up @@ -134,6 +137,7 @@ describe('prepareTools', () => {
"toolWarnings": [],
"tools": [
{
"cache_control": undefined,
"name": "bash",
"type": "bash_20241022",
},
Expand All @@ -160,6 +164,7 @@ describe('prepareTools', () => {
"toolWarnings": [],
"tools": [
{
"cache_control": undefined,
"max_characters": 10000,
"name": "str_replace_based_edit_tool",
"type": "text_editor_20250728",
Expand Down Expand Up @@ -187,6 +192,7 @@ describe('prepareTools', () => {
"toolWarnings": [],
"tools": [
{
"cache_control": undefined,
"max_characters": undefined,
"name": "str_replace_based_edit_tool",
"type": "text_editor_20250728",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -265,6 +272,7 @@ describe('prepareTools', () => {
"https://www.google.com",
],
"blocked_domains": undefined,
"cache_control": undefined,
"citations": {
"enabled": true,
},
Expand Down Expand Up @@ -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'),
});
});
});
Loading
Loading