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
4 changes: 2 additions & 2 deletions artifacts/issue-3670-anthropic-cache-eval.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"source": {
"url": "https://platform.claude.com/docs/en/build-with-claude/prompt-caching",
"retrievedAt": "2026-07-18",
"providerSourceBlobOid": "e4dcc79e1a8c7785211e9e5749e2149a96cc2413",
"providerSourceSha256": "efd25a472ff5a6f154b55e8295513e7d159b65df5b8310b8f58d8f0b62350570",
"providerSourceBlobOid": "9e400ada3f74e4ff88652fe069aa2bd5766f9807",
"providerSourceSha256": "ebdda451e7790b31d0575c8722faa850272006f2ced496918321d919e3df5d09",
"inputFixtureSha256": "562926fba79f003eff4d45c0da2292ac66d84302e3960b2d7493441ade1f9e04"
},
"derivationCommands": [
Expand Down
2 changes: 1 addition & 1 deletion docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,7 @@ Prompt-cache modes:

Without an explicit mode, canonical Anthropic endpoints default to `automatic`, Claude-family model ids on non-canonical compatible endpoints default to `explicit`, and unknown non-Claude compatible endpoints default to `none`. Non-canonical endpoints get the default ~5m lifetime unless they opt into `supportsLongCacheRetention: true`. Set `promptCacheMode: automatic` only when a gateway is known to pass through Anthropic's top-level cache control without adding conflicting block markers.

If a gateway attaches enough cache markers of its own that our generated breakpoint becomes the fifth, Anthropic rejects the request with `A maximum of 4 blocks with cache_control may be provided.` Those extra markers are not visible in the request GJC builds, so the rejection is handled at runtime rather than predicted: the turn retries once with generated caching suppressed and keeps it suppressed for the rest of the provider session. Set `promptCacheMode: none` on such a gateway to skip the wasted first attempt.
If a gateway attaches enough cache markers of its own that ours push the request past Anthropic's four-breakpoint limit, Anthropic rejects it with `A maximum of 4 blocks with cache_control may be provided.` Those extra markers are not visible in the request GJC builds, so the limit is handled at runtime rather than predicted. Because the rejection means "too many" rather than "none allowed", recovery reduces the generated breakpoints one step at a time: `explicit` mode normally emits two markers (a conversation-prefix anchor and a current-turn refresh point), so the first retry keeps only the prefix anchor, and generated caching is disabled entirely only if that is rejected too. The reduced setting persists for the rest of the provider session, so an endpoint with one free slot keeps caching its conversation prefix instead of losing caching altogether. Set `promptCacheMode: none` on a gateway that never has a free slot to skip the wasted attempts.

```yaml
providers:
Expand Down
4 changes: 4 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Fixed

- Anthropic requests rejected with `A maximum of 4 blocks with cache_control may be provided. Found N.` now step their generated breakpoints down instead of dying on the first attempt (#3934, supersedes #3943). An Anthropic-compatible gateway may attach its own block-level cache markers before forwarding, and those never appear in the params we serialize, so the total is unpredictable locally and the rejection itself is the only usable signal. Because that rejection says "too many", not "none allowed", recovery gives up one breakpoint at a time: explicit mode normally emits two (a conversation-prefix anchor plus a current-turn refresh point), so the first retry keeps the prefix anchor — the higher-value marker — and only a second rejection disables generated caching entirely. The reduced budget persists for the provider session so later turns neither re-trigger the 400 nor lose more caching than the endpoint requires. Only a genuine breakpoint-overflow `invalid_request_error` is claimed — other `cache_control` complaints, unrelated 400s, non-400 statuses, and our own pre-flight validation failure still surface immediately. The classifier is exported as `isAnthropicCacheBreakpointOverflowError`.
## [0.12.15] - 2026-08-06

### Fixed
Expand All @@ -18,6 +21,7 @@

### Fixed


- `todo_write` raw argument rejections now carry bounded, authority-controlled correction codes for each rejected shape: unknown root keys, unknown operation-entry keys, done/drop entries missing a task or phase target, and unknown init list-entry keys. Each code maps to a fixed correction message naming the accepted shape (never echoing the offending input), so invalid calls surface specific guidance while valid payloads keep the existing passthrough/coercion path (#3916).
- Anthropic Sonnet 5 now exposes Anthropic's real `xhigh` and `max` thinking efforts on the Messages API (`minimal`/`low`/`medium`/`high`/`xhigh`/`max`), matching official support. The previous generic `kind === opus` gate excluded it from the full preset range; the capability predicate is now an explicit version-scoped list (Opus 4.7+, Sonnet 5+), so older Sonnet generations and Bedrock Converse routes stay fail-closed at their previously advertised levels (issue #3913).
- Alibaba Token Plan now exposes Qwen 3.8 Max under the provider-supported `qwen3.8-max` wire id instead of the rejected `qwen-3.8-max` spelling; catalog regeneration canonicalizes a legacy discovered alias rather than retaining a broken duplicate (#3909).
Expand Down
74 changes: 51 additions & 23 deletions packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,18 @@ const ANTHROPIC_PROVIDER_SESSION_STATE_KEY = "anthropic-messages";
type AnthropicProviderSessionState = ProviderSessionState & {
strictToolsDisabled: boolean;
fastModeDisabled: boolean;
generatedCachingDisabled: boolean;
generatedCacheBudget: GeneratedCacheBudget;
};

function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
const state: AnthropicProviderSessionState = {
strictToolsDisabled: false,
fastModeDisabled: false,
generatedCachingDisabled: false,
generatedCacheBudget: 2,
close: () => {
state.strictToolsDisabled = false;
state.fastModeDisabled = false;
state.generatedCachingDisabled = false;
state.generatedCacheBudget = 2;
},
};
return state;
Expand Down Expand Up @@ -514,16 +514,22 @@ function isClaudeFamilyModel(model: Model<"anthropic-messages">): boolean {
return shortId.toLowerCase().startsWith("claude-");
}

/**
* How many breakpoints we are still willing to generate after a gateway has
* rejected a previous attempt. `explicit` mode normally emits two (a reusable
* prefix anchor on the last assistant turn plus a refresh point on the current
* user turn), so stepping down to one still caches the prefix, and only the
* final step gives caching up entirely.
*/
type GeneratedCacheBudget = 2 | 1 | 0;

function getCacheControl(
model: Model<"anthropic-messages">,
baseUrl: string,
cacheRetention?: CacheRetention,
suppressGeneratedCaching = false,
generatedCacheBudget: GeneratedCacheBudget = 2,
): { mode: AnthropicCacheMode; cacheControl?: AnthropicCacheControl } {
// A gateway already at Anthropic's four-breakpoint limit rejected our
// generated marker on a previous attempt. The extra markers are invisible
// here, so the only safe retry is to add none of our own.
if (suppressGeneratedCaching) return { mode: "none" };
if (generatedCacheBudget === 0) return { mode: "none" };
const retention = resolveCacheRetention(cacheRetention ?? model.cacheRetention, "long");
if (retention === "none") return { mode: "none" };

Expand Down Expand Up @@ -1445,7 +1451,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
let droppedForcedToolChoice = false;
let repairLatestAssistantThinking = false;
let repairAllAssistantThinking = false;
let suppressGeneratedCaching = providerSessionState?.generatedCachingDisabled ?? false;
let generatedCacheBudget: GeneratedCacheBudget = providerSessionState?.generatedCacheBudget ?? 2;
const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
// Degradation state is cumulative: every fallback rebuild must merge all
// repairs activated so far. Rebuilding from only the immediate call lets
Expand All @@ -1460,7 +1466,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
options,
disableStrictTools,
{ repairLatestAssistantThinking, repairAllAssistantThinking },
suppressGeneratedCaching,
generatedCacheBudget,
);
if (droppedForcedToolChoice) {
delete nextParams.tool_choice;
Expand Down Expand Up @@ -1976,22 +1982,27 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
}
if (
!options?.fallbackManaged &&
!suppressGeneratedCaching &&
generatedCacheBudget > 0 &&
firstTokenTime === undefined &&
isAnthropicCacheBreakpointOverflowError(streamFailure)
) {
// The gateway's own markers already fill Anthropic's four slots, so
// our generated breakpoint is the fifth. We cannot see the others,
// which makes the rejection itself the only usable signal; retry
// once with generated caching off and keep it off for the session.
logger.debug("anthropic: cache breakpoint limit exceeded, retrying without generated caching", {
// one of ours is the fifth. We cannot see the others, which makes the
// rejection the only usable signal — and it says "too many", not
// "none allowed". So give up one breakpoint at a time instead of all
// caching at once: an endpoint that leaves a single slot free keeps
// caching the conversation prefix, which is the marker that matters.
const nextBudget: GeneratedCacheBudget = generatedCacheBudget === 2 ? 1 : 0;
logger.debug("anthropic: cache breakpoint limit exceeded, reducing generated breakpoints", {
model: model.id,
from: generatedCacheBudget,
to: nextBudget,
error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
});
if (providerSessionState) {
providerSessionState.generatedCachingDisabled = true;
providerSessionState.generatedCacheBudget = nextBudget;
}
suppressGeneratedCaching = true;
generatedCacheBudget = nextBudget;
params = await prepareParams();
providerRetryAttempt = 0;
resetOutputForRetry();
Expand Down Expand Up @@ -2388,7 +2399,12 @@ function isHumanUserMessage(message: MessageCreateParamsStreaming["messages"][nu
return message.content.some(block => block.type !== "tool_result");
}

function applyExplicitPromptCaching(params: AnthropicCacheParams, cacheControl: AnthropicCacheControl): void {
function applyExplicitPromptCaching(
params: AnthropicCacheParams,
cacheControl: AnthropicCacheControl,
budget: GeneratedCacheBudget,
): void {
if (budget === 0) return;
if (countCacheControlBreakpoints(params) >= 4) return;

const currentUserIndex = params.messages.findLastIndex(isHumanUserMessage);
Expand All @@ -2400,6 +2416,13 @@ function applyExplicitPromptCaching(params: AnthropicCacheParams, cacheControl:
// assistant tool-use turn immediately before them. Anchor the latest completed
// assistant turn so the reusable prefix advances during an agent tool loop,
// while keeping the newest tool output outside the cache boundary.
//
// This anchor is the higher-value marker of the two: it covers the whole
// conversation prefix, so a reduced budget is spent here first. It only
// consumes budget when a marker is actually placed — on a first turn there is
// no assistant message yet, and the reduced budget must still reach the
// current-turn marker below rather than emitting nothing at all.
let remaining: number = budget;
for (let index = params.messages.length - 1; index >= 0; index--) {
const message = params.messages[index];
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
Expand All @@ -2409,10 +2432,12 @@ function applyExplicitPromptCaching(params: AnthropicCacheParams, cacheControl:
cacheControl,
)
) {
remaining -= 1;
break;
}
}

if (remaining < 1) return;
if (countCacheControlBreakpoints(params) >= 4) return;
if (typeof currentUser.content === "string" && currentUser.content.trim()) {
currentUser.content = [{ type: "text", text: currentUser.content, cache_control: { ...cacheControl } }];
Expand All @@ -2428,14 +2453,17 @@ function applyPromptCaching(
params: AnthropicCacheParams,
cacheMode: AnthropicCacheMode,
cacheControl?: AnthropicCacheControl,
budget: GeneratedCacheBudget = 2,
): void {
if (!cacheControl || cacheMode === "none") return;
if (!cacheControl || cacheMode === "none" || budget === 0) return;
validateCacheControls(params);
if (cacheMode === "automatic") {
// Automatic mode only ever emits one marker, so any non-zero budget
// covers it; the zero case already returned above.
params.cache_control = { ...cacheControl };
return;
}
applyExplicitPromptCaching(params, cacheControl);
applyExplicitPromptCaching(params, cacheControl, budget);
validateCacheControls(params);
}

Expand Down Expand Up @@ -2469,13 +2497,13 @@ function buildParams(
options?: AnthropicOptions,
disableStrictTools = false,
thinkingRepair?: { repairLatestAssistantThinking?: boolean; repairAllAssistantThinking?: boolean },
suppressGeneratedCaching = false,
generatedCacheBudget: GeneratedCacheBudget = 2,
): MessageCreateParamsStreaming {
const { mode: cacheMode, cacheControl } = getCacheControl(
model,
baseUrl,
options?.cacheRetention,
suppressGeneratedCaching,
generatedCacheBudget,
);

const params: AnthropicSamplingParams = {
Expand Down Expand Up @@ -2619,7 +2647,7 @@ function buildParams(
params.system = systemBlocks;
}
ensureMaxTokensForThinking(params, model);
applyPromptCaching(params as AnthropicCacheParams, cacheMode, cacheControl);
applyPromptCaching(params as AnthropicCacheParams, cacheMode, cacheControl, generatedCacheBudget);
enforceCacheControlLimit(params, 4);
normalizeCacheControlTtlOrdering(params);

Expand Down
Loading
Loading