From 81c264245fe7f090be926471a82c7be9c88f631e Mon Sep 17 00:00:00 2001 From: Code_G Date: Sat, 22 Aug 2026 17:07:58 +0900 Subject: [PATCH 1/5] fix(ai): retain Anthropic cache checkpoints --- packages/ai/src/api/anthropic-messages.ts | 36 +++++---- ...thropic-cache-checkpoint-stability.test.ts | 80 +++++++++++++++++++ 2 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 packages/ai/test/anthropic-cache-checkpoint-stability.test.ts diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index d05640a16f..8331505fd0 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -1042,6 +1042,21 @@ function isCacheableUserContentBlock( return block?.type === "text" || block?.type === "image" || block?.type === "tool_result"; } +function markUserMessageCacheCheckpoint(message: MessageParam, cacheControl: CacheControlEphemeral): boolean { + if (message.role !== "user") return false; + if (Array.isArray(message.content)) { + const lastBlock = message.content[message.content.length - 1]; + if (!isCacheableUserContentBlock(lastBlock)) return false; + lastBlock.cache_control = cacheControl; + return true; + } + if (typeof message.content === "string") { + message.content = [{ type: "text", text: message.content, cache_control: cacheControl }]; + return true; + } + return false; +} + const ANTHROPIC_MESSAGE_EVENTS: ReadonlySet = new Set([ "message_start", "message_delta", @@ -2382,23 +2397,10 @@ function convertMessages( } } - // Add cache_control to the last user message to cache conversation history - if (cacheControl && params.length > 0) { - const lastMessage = params[params.length - 1]; - if (lastMessage.role === "user") { - if (Array.isArray(lastMessage.content)) { - const lastBlock = lastMessage.content[lastMessage.content.length - 1]; - if (isCacheableUserContentBlock(lastBlock)) { - lastBlock.cache_control = cacheControl; - } - } else if (typeof lastMessage.content === "string") { - lastMessage.content = [ - { - type: "text", - text: lastMessage.content, - cache_control: cacheControl, - }, - ]; + if (cacheControl && params.length > 0 && markUserMessageCacheCheckpoint(params[params.length - 1], cacheControl)) { + if (!isOAuthToken) { + for (let index = params.length - 2; index >= 0; index--) { + if (markUserMessageCacheCheckpoint(params[index], cacheControl)) break; } } } diff --git a/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts b/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts new file mode 100644 index 0000000000..c01ad193e1 --- /dev/null +++ b/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts @@ -0,0 +1,80 @@ +import { Type } from "typebox"; +import { describe, expect, it } from "vitest"; +import { buildAnthropicWarmPromptCacheParams } from "../src/api/anthropic-messages.ts"; +import { getBuiltinModel as getModel } from "../src/providers/all.ts"; +import { fauxAssistantMessage, fauxToolCall } from "../src/providers/faux.ts"; +import type { Context, ToolResultMessage } from "../src/types.ts"; + +const FIRST_TOOL_USE_ID = "toolu_first"; +const SECOND_TOOL_USE_ID = "toolu_second"; +const THIRD_TOOL_USE_ID = "toolu_third"; + +function toolResultMessage(toolCallId: string): ToolResultMessage { + return { + role: "toolResult", + toolCallId, + toolName: "read", + content: [{ type: "text", text: `${toolCallId} result` }], + isError: false, + timestamp: 1, + }; +} + +function markedToolResultIds(params: ReturnType): string[] { + const markedIds: string[] = []; + for (const message of params.messages) { + if (!Array.isArray(message.content)) continue; + for (const block of message.content) { + if (block.type === "tool_result" && block.cache_control !== undefined) { + markedIds.push(block.tool_use_id); + } + } + } + return markedIds; +} + +function cacheBreakpointCount(params: ReturnType): number { + return JSON.stringify(params).match(/"cache_control"/g)?.length ?? 0; +} + +describe("Anthropic cache checkpoints", () => { + it("retains the preceding tool-result checkpoint while marking a new tool-result tail", () => { + const model = getModel("anthropic", "claude-sonnet-4-5"); + const firstToolLoop: Context = { + systemPrompt: "You are concise.", + messages: [ + { role: "user", content: "Inspect the repository", timestamp: 1 }, + fauxAssistantMessage(fauxToolCall("read", {}, { id: FIRST_TOOL_USE_ID }), { stopReason: "toolUse" }), + toolResultMessage(FIRST_TOOL_USE_ID), + ], + tools: [{ name: "read", description: "Read a file", parameters: Type.Object({}) }], + }; + const secondToolLoop: Context = { + ...firstToolLoop, + messages: [ + ...firstToolLoop.messages, + fauxAssistantMessage(fauxToolCall("read", {}, { id: SECOND_TOOL_USE_ID }), { stopReason: "toolUse" }), + toolResultMessage(SECOND_TOOL_USE_ID), + ], + }; + const thirdToolLoop: Context = { + ...secondToolLoop, + messages: [ + ...secondToolLoop.messages, + fauxAssistantMessage(fauxToolCall("read", {}, { id: THIRD_TOOL_USE_ID }), { stopReason: "toolUse" }), + toolResultMessage(THIRD_TOOL_USE_ID), + ], + }; + + expect(markedToolResultIds(buildAnthropicWarmPromptCacheParams(model, firstToolLoop))).toEqual([FIRST_TOOL_USE_ID]); + expect(markedToolResultIds(buildAnthropicWarmPromptCacheParams(model, secondToolLoop))).toEqual([ + FIRST_TOOL_USE_ID, + SECOND_TOOL_USE_ID, + ]); + expect(markedToolResultIds(buildAnthropicWarmPromptCacheParams(model, thirdToolLoop))).toEqual([ + SECOND_TOOL_USE_ID, + THIRD_TOOL_USE_ID, + ]); + expect(cacheBreakpointCount(buildAnthropicWarmPromptCacheParams(model, thirdToolLoop))).toBe(4); + }); +}); From 32ee10114a534231b4eb31708fd8106ae87c1207 Mon Sep 17 00:00:00 2001 From: Code_G Date: Sat, 22 Aug 2026 17:14:07 +0900 Subject: [PATCH 2/5] fix(ai): preserve OAuth cache checkpoints --- packages/ai/src/api/anthropic-messages.ts | 8 +- ...thropic-cache-checkpoint-stability.test.ts | 138 +++++++++++++++++- 2 files changed, 139 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 8331505fd0..7970db61a7 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -2014,7 +2014,7 @@ function buildParams( { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude.", - ...(cacheControl ? { cache_control: cacheControl } : {}), + ...(!context.systemPrompt && cacheControl ? { cache_control: cacheControl } : {}), }, ]; if (context.systemPrompt) { @@ -2398,10 +2398,8 @@ function convertMessages( } if (cacheControl && params.length > 0 && markUserMessageCacheCheckpoint(params[params.length - 1], cacheControl)) { - if (!isOAuthToken) { - for (let index = params.length - 2; index >= 0; index--) { - if (markUserMessageCacheCheckpoint(params[index], cacheControl)) break; - } + for (let index = params.length - 2; index >= 0; index--) { + if (markUserMessageCacheCheckpoint(params[index], cacheControl)) break; } } diff --git a/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts b/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts index c01ad193e1..227447e54f 100644 --- a/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts +++ b/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts @@ -1,9 +1,9 @@ import { Type } from "typebox"; import { describe, expect, it } from "vitest"; -import { buildAnthropicWarmPromptCacheParams } from "../src/api/anthropic-messages.ts"; +import { buildAnthropicWarmPromptCacheParams, stream as streamAnthropic } from "../src/api/anthropic-messages.ts"; import { getBuiltinModel as getModel } from "../src/providers/all.ts"; import { fauxAssistantMessage, fauxToolCall } from "../src/providers/faux.ts"; -import type { Context, ToolResultMessage } from "../src/types.ts"; +import type { Context, Model, ToolResultMessage } from "../src/types.ts"; const FIRST_TOOL_USE_ID = "toolu_first"; const SECOND_TOOL_USE_ID = "toolu_second"; @@ -37,6 +37,98 @@ function cacheBreakpointCount(params: ReturnType { + return typeof value === "object" && value !== null; +} + +function cacheMarkerSnapshot(payload: unknown): CacheMarkerSnapshot { + const toolResultIds: string[] = []; + const messages = isRecord(payload) && Array.isArray(payload.messages) ? payload.messages : []; + for (const message of messages) { + if (!isRecord(message) || !Array.isArray(message.content)) continue; + for (const block of message.content) { + if ( + isRecord(block) && + block.type === "tool_result" && + "cache_control" in block && + typeof block.tool_use_id === "string" + ) { + toolResultIds.push(block.tool_use_id); + } + } + } + return { + toolResultIds, + breakpointCount: (JSON.stringify(payload) ?? "").match(/"cache_control"/g)?.length ?? 0, + }; +} + +function oauthSseResponse(): Response { + const events = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_test","usage":{"input_tokens":1,"output_tokens":0}}}\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}\n', + 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n', + ]; + return new Response(events.join("\n"), { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +async function captureOAuthCacheMarkers( + model: Model<"anthropic-messages">, + context: Context, + cacheRetention?: "none", +): Promise { + let payload: unknown; + const fetch: typeof globalThis.fetch = async (_input, init) => { + payload = JSON.parse(String(init?.body)); + return oauthSseResponse(); + }; + const response = streamAnthropic(model, context, { + apiKey: "sk-ant-oat-test", + fetch, + ...(cacheRetention ? { cacheRetention } : {}), + }); + await response.result(); + if (payload === undefined) throw new Error("Expected the OAuth request payload"); + return cacheMarkerSnapshot(payload); +} + +function oauthToolLoopContexts(systemPrompt?: string): readonly [Context, Context, Context] { + const firstToolLoop: Context = { + ...(systemPrompt ? { systemPrompt } : {}), + messages: [ + { role: "user", content: "Inspect the repository", timestamp: 1 }, + fauxAssistantMessage(fauxToolCall("read", {}, { id: FIRST_TOOL_USE_ID }), { stopReason: "toolUse" }), + toolResultMessage(FIRST_TOOL_USE_ID), + ], + tools: [{ name: "read", description: "Read a file", parameters: Type.Object({}) }], + }; + const secondToolLoop: Context = { + ...firstToolLoop, + messages: [ + ...firstToolLoop.messages, + fauxAssistantMessage(fauxToolCall("read", {}, { id: SECOND_TOOL_USE_ID }), { stopReason: "toolUse" }), + toolResultMessage(SECOND_TOOL_USE_ID), + ], + }; + const thirdToolLoop: Context = { + ...secondToolLoop, + messages: [ + ...secondToolLoop.messages, + fauxAssistantMessage(fauxToolCall("read", {}, { id: THIRD_TOOL_USE_ID }), { stopReason: "toolUse" }), + toolResultMessage(THIRD_TOOL_USE_ID), + ], + }; + return [firstToolLoop, secondToolLoop, thirdToolLoop]; +} + describe("Anthropic cache checkpoints", () => { it("retains the preceding tool-result checkpoint while marking a new tool-result tail", () => { const model = getModel("anthropic", "claude-sonnet-4-5"); @@ -77,4 +169,46 @@ describe("Anthropic cache checkpoints", () => { ]); expect(cacheBreakpointCount(buildAnthropicWarmPromptCacheParams(model, thirdToolLoop))).toBe(4); }); + + it.each([ + ["without", undefined], + ["with", "Keep the response concise."], + ])("retains two rolling OAuth checkpoints %s a context system prompt", async (_label, systemPrompt) => { + const model = getModel("anthropic", "claude-sonnet-4-5"); + const [firstToolLoop, secondToolLoop, thirdToolLoop] = oauthToolLoopContexts(systemPrompt); + + expect((await captureOAuthCacheMarkers(model, firstToolLoop)).toolResultIds).toEqual([FIRST_TOOL_USE_ID]); + expect((await captureOAuthCacheMarkers(model, secondToolLoop)).toolResultIds).toEqual([ + FIRST_TOOL_USE_ID, + SECOND_TOOL_USE_ID, + ]); + const thirdSnapshot = await captureOAuthCacheMarkers(model, thirdToolLoop); + expect(thirdSnapshot.toolResultIds).toEqual([SECOND_TOOL_USE_ID, THIRD_TOOL_USE_ID]); + expect(thirdSnapshot.breakpointCount).toBe(4); + }); + + it("marks only the OAuth tail when there is no preceding tool-result checkpoint", async () => { + const model = getModel("anthropic", "claude-sonnet-4-5"); + const [firstToolLoop] = oauthToolLoopContexts(); + + expect((await captureOAuthCacheMarkers(model, firstToolLoop)).toolResultIds).toEqual([FIRST_TOOL_USE_ID]); + }); + + it("omits every OAuth cache marker when cache retention is none", async () => { + const model = getModel("anthropic", "claude-sonnet-4-5"); + const [, , thirdToolLoop] = oauthToolLoopContexts("Keep the response concise."); + + expect((await captureOAuthCacheMarkers(model, thirdToolLoop, "none")).breakpointCount).toBe(0); + }); + + it("does not mark an unpaired OAuth tool result", async () => { + const model = getModel("anthropic", "claude-sonnet-4-5"); + const [firstToolLoop] = oauthToolLoopContexts(); + const unpairedResultContext: Context = { + ...firstToolLoop, + messages: [...firstToolLoop.messages, toolResultMessage("toolu_unpaired")], + }; + + expect((await captureOAuthCacheMarkers(model, unpairedResultContext)).toolResultIds).toEqual([]); + }); }); From 79213e328b67171cbee565148b95de6a98c6f360 Mon Sep 17 00:00:00 2001 From: Code_G <288527233+codeg-dev@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:27:23 +0900 Subject: [PATCH 3/5] docs(ai): track Anthropic cache checkpoint fix --- packages/ai/CHANGELOG.md | 2 ++ packages/ai/src/changes.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e707d7c5cf..9aa16b4784 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixed +- Anthropic prompt caching now retains the previous checkpoint while tool loops append a new result, avoiding repeated prefix reprocessing for API-key and OAuth requests. + ### Removed ## [2026.8.22] - 2026-08-22 diff --git a/packages/ai/src/changes.md b/packages/ai/src/changes.md index b626fe3aff..5f543becfe 100644 --- a/packages/ai/src/changes.md +++ b/packages/ai/src/changes.md @@ -2766,3 +2766,17 @@ Detection has to happen inside the Anthropic SSE loop while the stream is still - MEDIUM: `api/anthropic-messages.ts` streaming event loop and request-option construction. - LOW: `types.ts` `StreamOptions`, `api/simple-options.ts` `buildBaseOptions` field list, `index.ts` export list. + +## 2026-08-22 - Stable Anthropic cache checkpoints across tool loops + +### What changed +- `api/anthropic-messages.ts` now marks the newest and immediately preceding cacheable user-message boundaries, retaining a stable Anthropic prompt-cache checkpoint while tool loops append new results. OAuth requests with a context system prompt keep the checkpoint budget available for message history. + +### Why +- Replacing the sole tail marker on every tool turn invalidated the previous cache boundary and caused repeated prefix reprocessing instead of preserving a reusable checkpoint across adjacent loops. + +### Why an extension could not handle it +- Cache markers are attached while the Anthropic wire payload is built inside `pi-ai`; extensions cannot safely rewrite provider-native message blocks after conversion. + +### Expected merge conflict zones +- MEDIUM: `api/anthropic-messages.ts` cache-control placement in `buildParams()` and the final checkpoint pass in `convertMessages()`. From 5437b99d432965f3b7ad92fec5243ea3101fbe6c Mon Sep 17 00:00:00 2001 From: Code_G <288527233+codeg-dev@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:32:06 +0900 Subject: [PATCH 4/5] test(ai): format cache checkpoint regression --- packages/ai/test/anthropic-cache-checkpoint-stability.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts b/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts index 227447e54f..77c7c92678 100644 --- a/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts +++ b/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts @@ -158,7 +158,9 @@ describe("Anthropic cache checkpoints", () => { ], }; - expect(markedToolResultIds(buildAnthropicWarmPromptCacheParams(model, firstToolLoop))).toEqual([FIRST_TOOL_USE_ID]); + expect(markedToolResultIds(buildAnthropicWarmPromptCacheParams(model, firstToolLoop))).toEqual([ + FIRST_TOOL_USE_ID, + ]); expect(markedToolResultIds(buildAnthropicWarmPromptCacheParams(model, secondToolLoop))).toEqual([ FIRST_TOOL_USE_ID, SECOND_TOOL_USE_ID, From 93e4e73001c3cbdb10a661bbc385d80e40ed1c77 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 29 Aug 2026 21:36:01 +0900 Subject: [PATCH 5/5] fix(ai): preserve standalone Anthropic user strings --- packages/ai/src/api/anthropic-messages.ts | 7 ++++++- .../test/anthropic-cache-checkpoint-stability.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index 5ee6ce0a56..a5b8679fff 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -2266,7 +2266,12 @@ function convertMessages( if (msg.role === "user") { if (typeof msg.content === "string") { if (msg.content.trim().length > 0) { - appendUserBlocks(params, [{ type: "text", text: sanitizeSurrogates(msg.content) }]); + const content = sanitizeSurrogates(msg.content); + if (params[params.length - 1]?.role === "user") { + appendUserBlocks(params, [{ type: "text", text: content }]); + } else { + params.push({ role: "user", content }); + } } } else { const blocks: ContentBlockParam[] = msg.content.map((item) => { diff --git a/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts b/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts index 7b024a6e25..137ce02688 100644 --- a/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts +++ b/packages/ai/test/anthropic-cache-checkpoint-stability.test.ts @@ -227,6 +227,17 @@ describe("Anthropic cache checkpoints", () => { expect(cacheBreakpointCount(params)).toBe(1); }); + it("preserves standalone string user content when cache retention is none", () => { + const model = getModel("anthropic", "claude-sonnet-4-5"); + const params = buildAnthropicWarmPromptCacheParams( + model, + { messages: [{ role: "user", content: "standalone", timestamp: 1 }] }, + { cacheRetention: "none" }, + ); + + expect(params.messages).toEqual([{ role: "user", content: "standalone" }]); + }); + it("handles empty history without creating checkpoints", () => { const model = getModel("anthropic", "claude-sonnet-4-5"); expect(cacheBreakpointCount(buildAnthropicWarmPromptCacheParams(model, { messages: [] }))).toBe(0);