diff --git a/src/agents/anthropic-payload-policy.test.ts b/src/agents/anthropic-payload-policy.test.ts index c3d26b3f6dfc..9c90aa56513e 100644 --- a/src/agents/anthropic-payload-policy.test.ts +++ b/src/agents/anthropic-payload-policy.test.ts @@ -14,6 +14,7 @@ type TestPayload = { messages: Array<{ role: string; content: unknown }>; service_tier?: string; system?: unknown; + tools?: unknown; }; function textBlock(text: string, cache_control?: { type: "ephemeral"; ttl?: "1h" }) { @@ -214,6 +215,55 @@ describe("anthropic payload policy", () => { }); }); + it("uses the latest tool result when only one message cache marker remains", () => { + const policy = resolveAnthropicPayloadPolicy({ + provider: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com/v1", + cacheRetention: "short", + enableCacheControl: true, + }); + const payload: TestPayload = { + system: [ + { type: "text", text: "Claude Code identity." }, + { type: "text", text: "Follow policy." }, + ], + tools: [{ name: "Read", cache_control: { type: "ephemeral" } }], + messages: [ + { + role: "user", + content: [{ type: "text", text: "Investigate the cache writes." }], + }, + { + role: "assistant", + content: [{ type: "text", text: "I'll inspect the logs." }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool_1", content: "log chunk" }], + }, + ], + }; + + applyAnthropicPayloadPolicyToParams(payload, policy); + + expect(payload.messages[0]).toEqual({ + role: "user", + content: [{ type: "text", text: "Investigate the cache writes." }], + }); + expect(payload.messages[2]).toEqual({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: "log chunk", + cache_control: { type: "ephemeral" }, + }, + ], + }); + }); + it("denies proxied Anthropic service tier but honors explicit long TTL for custom hosts", () => { const policy = resolveAnthropicPayloadPolicy({ provider: "anthropic", diff --git a/src/agents/anthropic-payload-policy.ts b/src/agents/anthropic-payload-policy.ts index c44c9ef1b0f3..8bf6db4715e3 100644 --- a/src/agents/anthropic-payload-policy.ts +++ b/src/agents/anthropic-payload-policy.ts @@ -27,6 +27,8 @@ type AnthropicPayloadPolicyInput = { serviceTier?: AnthropicServiceTier; }; +const ANTHROPIC_CACHE_CONTROL_LIMIT = 4; + /** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */ export type AnthropicPayloadPolicy = { allowsServiceTier: boolean; @@ -142,8 +144,9 @@ function stripAnthropicSystemPromptBoundary(system: unknown): void { function applyAnthropicCacheControlToMessages( messages: unknown, cacheControl: AnthropicEphemeralCacheControl, + markerLimit: number, ): void { - if (!Array.isArray(messages) || messages.length === 0) { + if (!Array.isArray(messages) || messages.length === 0 || markerLimit <= 0) { return; } @@ -162,6 +165,10 @@ function applyAnthropicCacheControlToMessages( const content = record.content; if (typeof content === "string") { + if (fallbackToolResult && markerLimit === 1) { + fallbackToolResult.cache_control = cacheControl; + return; + } record.content = [ { type: "text", @@ -169,6 +176,9 @@ function applyAnthropicCacheControlToMessages( cache_control: cacheControl, }, ]; + if (fallbackToolResult && markerLimit > 1) { + fallbackToolResult.cache_control = cacheControl; + } return; } @@ -184,8 +194,12 @@ function applyAnthropicCacheControlToMessages( const blockRecord = block as Record; if (blockRecord.type === "text" || blockRecord.type === "image") { + if (fallbackToolResult && markerLimit === 1) { + fallbackToolResult.cache_control = cacheControl; + return; + } blockRecord.cache_control = cacheControl; - if (fallbackToolResult) { + if (fallbackToolResult && markerLimit > 1) { fallbackToolResult.cache_control = cacheControl; } return; @@ -201,6 +215,20 @@ function applyAnthropicCacheControlToMessages( } } +function countAnthropicCacheControlMarkers(blocks: unknown): number { + if (!Array.isArray(blocks)) { + return 0; + } + + let count = 0; + for (const block of blocks) { + if (block && typeof block === "object" && "cache_control" in block) { + count += 1; + } + } + return count; +} + /** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */ export function resolveAnthropicPayloadPolicy( input: AnthropicPayloadPolicyInput, @@ -246,7 +274,14 @@ export function applyAnthropicPayloadPolicyToParams( return; } - applyAnthropicCacheControlToMessages(payloadObj.messages, policy.cacheControl); + const usedMarkers = + countAnthropicCacheControlMarkers(payloadObj.system) + + countAnthropicCacheControlMarkers(payloadObj.tools); + applyAnthropicCacheControlToMessages( + payloadObj.messages, + policy.cacheControl, + ANTHROPIC_CACHE_CONTROL_LIMIT - usedMarkers, + ); } /** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */ diff --git a/src/llm/providers/anthropic.ts b/src/llm/providers/anthropic.ts index b1a76d1452b9..11269e1dfc95 100644 --- a/src/llm/providers/anthropic.ts +++ b/src/llm/providers/anthropic.ts @@ -41,6 +41,8 @@ import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copi import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js"; import { transformMessages } from "./transform-messages.js"; +const ANTHROPIC_CACHE_CONTROL_LIMIT = 4; + /** * Resolve cache retention preference. * Defaults to "short" and uses OPENCLAW_CACHE_RETENTION for backward compatibility. @@ -939,9 +941,27 @@ function buildParams( options?: AnthropicOptions, ): MessageCreateParamsStreaming { const { cacheControl } = getCacheControl(model, options?.cacheRetention); + const systemCacheControlCount = !cacheControl + ? 0 + : isOAuthTokenResult + ? 1 + (context.systemPrompt ? 1 : 0) + : context.systemPrompt + ? 1 + : 0; + const toolCacheControlCount = cacheControl && context.tools?.length ? 1 : 0; + const messageCacheControlLimit = Math.max( + 0, + ANTHROPIC_CACHE_CONTROL_LIMIT - systemCacheControlCount - toolCacheControlCount, + ); const params: MessageCreateParamsStreaming = { model: model.id, - messages: convertMessages(context.messages, model, isOAuthTokenResult, cacheControl), + messages: convertMessages( + context.messages, + model, + isOAuthTokenResult, + cacheControl, + messageCacheControlLimit, + ), max_tokens: options?.maxTokens ?? model.maxTokens, stream: true, }; @@ -1041,6 +1061,7 @@ function convertMessages( model: Model<"anthropic-messages">, isOAuthTokenValue: boolean, cacheControl?: CacheControlEphemeral, + messageCacheControlLimit = 4, ): MessageParam[] { const params: MessageParam[] = []; @@ -1178,7 +1199,7 @@ function convertMessages( } } - if (cacheControl && params.length > 0) { + if (cacheControl && params.length > 0 && messageCacheControlLimit > 0) { let fallbackToolResult: ContentBlockParam | undefined; for (let i = params.length - 1; i >= 0; i--) { @@ -1191,14 +1212,13 @@ function convertMessages( for (let j = message.content.length - 1; j >= 0; j--) { const block = message.content[j]; if (block.type === "text" || block.type === "image") { - (block as typeof block & { cache_control?: typeof cacheControl }).cache_control = - cacheControl; - if (fallbackToolResult) { - ( - fallbackToolResult as typeof fallbackToolResult & { - cache_control?: typeof cacheControl; - } - ).cache_control = cacheControl; + if (fallbackToolResult && messageCacheControlLimit === 1) { + applyContentBlockCacheControl(fallbackToolResult, cacheControl); + return params; + } + applyContentBlockCacheControl(block, cacheControl); + if (fallbackToolResult && messageCacheControlLimit > 1) { + applyContentBlockCacheControl(fallbackToolResult, cacheControl); } return params; } @@ -1210,6 +1230,10 @@ function convertMessages( } if (typeof message.content === "string") { + if (fallbackToolResult && messageCacheControlLimit === 1) { + applyContentBlockCacheControl(fallbackToolResult, cacheControl); + return params; + } message.content = [ { type: "text", @@ -1217,16 +1241,15 @@ function convertMessages( cache_control: cacheControl, }, ] as ContentBlockParam[]; + if (fallbackToolResult && messageCacheControlLimit > 1) { + applyContentBlockCacheControl(fallbackToolResult, cacheControl); + } return params; } } if (fallbackToolResult) { - ( - fallbackToolResult as typeof fallbackToolResult & { - cache_control?: typeof cacheControl; - } - ).cache_control = cacheControl; + applyContentBlockCacheControl(fallbackToolResult, cacheControl); } } @@ -1268,6 +1291,14 @@ function buildSystemPromptBlocks( return blocks.length > 0 ? blocks : [{ type: "text", text: "" }]; } +function applyContentBlockCacheControl( + block: ContentBlockParam, + cacheControl: CacheControlEphemeral, +): void { + (block as ContentBlockParam & { cache_control?: CacheControlEphemeral }).cache_control = + cacheControl; +} + function shouldUseFineGrainedToolStreamingBeta( model: Model<"anthropic-messages">, context: Context,