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: 2 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.29] - 2026-08-29
Expand Down
18 changes: 18 additions & 0 deletions packages/ai/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# changes.md — ai

## Anthropic cache checkpoints across tool loops (2026-08-29)

### What changed

- Anthropic message serialization now retains the preceding prompt-cache checkpoint only for a genuine tool-loop continuation, including interrupted turns whose tool result and following user text are coalesced into one user message.

### Why

- Ordinary multi-turn histories must not create additional premium cache writes, while tool loops need a stable rolling checkpoint across appended results.

### Why an extension could not handle it

- Cache markers and Anthropic role coalescing are applied inside the provider wire serializer below extension-visible message handling.

### Expected merge conflict zones

- MEDIUM: `src/api/anthropic-messages.ts` message coalescing and final cache-marker pass.

## Credential pool export wildcard (2026-08-27)

### What changed
Expand Down
86 changes: 56 additions & 30 deletions packages/ai/src/api/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,48 @@ function isCacheableUserContentBlock(
return block?.type === "text" || block?.type === "image" || block?.type === "tool_result";
}

function appendUserBlocks(params: MessageParam[], newBlocks: ContentBlockParam[]): void {
if (newBlocks.length === 0) return;
const lastParam = params[params.length - 1];
if (lastParam?.role === "user") {
if (typeof lastParam.content === "string") {
lastParam.content = [{ type: "text", text: lastParam.content }, ...newBlocks];
} else if (Array.isArray(lastParam.content)) {
(lastParam.content as ContentBlockParam[]).push(...newBlocks);
}
} else {
params.push({ role: "user", content: newBlocks });
}
}

function isToolLoopContinuation(messages: MessageParam[]): boolean {
const tail = messages[messages.length - 1];
const preceding = messages[messages.length - 2];
return (
tail?.role === "user" &&
Array.isArray(tail.content) &&
tail.content.some((block) => block.type === "tool_result") &&
preceding?.role === "assistant" &&
Array.isArray(preceding.content) &&
preceding.content.some((block) => block.type === "tool_use")
);
}

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<string> = new Set([
"message_start",
"message_delta",
Expand Down Expand Up @@ -2012,7 +2054,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 } : {}),
Comment thread
codeg-dev marked this conversation as resolved.
},
];
if (context.systemPrompt) {
Expand Down Expand Up @@ -2224,10 +2266,12 @@ function convertMessages(
if (msg.role === "user") {
if (typeof msg.content === "string") {
if (msg.content.trim().length > 0) {
params.push({
role: "user",
content: 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) => {
Expand Down Expand Up @@ -2263,10 +2307,7 @@ function convertMessages(
return true;
});
if (filteredBlocks.length === 0) continue;
params.push({
role: "user",
content: filteredBlocks,
});
appendUserBlocks(params, filteredBlocks);
}
} else if (msg.role === "assistant") {
const blocks: ContentBlockParam[] = [];
Expand Down Expand Up @@ -2388,30 +2429,15 @@ function convertMessages(
if (toolResults.length === 0) continue;

// Displaced reference-bearing results must follow every tool_result block.
params.push({
role: "user",
content: [...toolResults, ...siblingContent],
});
appendUserBlocks(params, [...toolResults, ...siblingContent]);
}
}

// 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,
},
];
const retainPrecedingCheckpoint = isToolLoopContinuation(params);
if (cacheControl && params.length > 0 && markUserMessageCacheCheckpoint(params[params.length - 1], cacheControl)) {
Comment thread
codeg-dev marked this conversation as resolved.
Comment thread
codeg-dev marked this conversation as resolved.
if (retainPrecedingCheckpoint) {
for (let index = params.length - 2; index >= 0; index--) {
if (markUserMessageCacheCheckpoint(params[index], cacheControl)) break;
}
}
}
Expand Down
14 changes: 14 additions & 0 deletions packages/ai/src/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3061,3 +3061,17 @@ Detection has to happen inside the Anthropic SSE loop while the stream is still
### Expected merge conflict zones

- OpenAI Completions reasoning conversion and Cloudflare provider generic declarations.

## 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 Anthropic-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()`.
Loading