Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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": "ca40efcc01da0ebbc78f018563a8a4474d77ba36",
"providerSourceSha256": "b3173948f5982c97b41790f53fbc396d514e5ce36cfa316453447527aa0236e8",
"providerSourceBlobOid": "d1ff41e0c353e3984f9c33bed9b8df17bcc063a1",
"providerSourceSha256": "f39a2c034cf4128c4eb6b37f46d0aa5a4f0d1fc83c5d03ea6a7aa591f75c8da0",
"inputFixtureSha256": "b1ca8d3183ca168140774f848ed414252178f7c5788d61243069862ae59c129b"
},
"derivationCommands": [
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,10 @@

## [Unreleased]

### Changed

- Anthropic prompt caching now defaults to top-level automatic caching (`cache_control: { type: "ephemeral" }`) for every Claude-family model, including through non-canonical Anthropic-compatible gateways (Cloudflare AI Gateway, GitHub Copilot, GitLab Duo, Vercel AI Gateway, zenmux, etc.), instead of only `api.anthropic.com`. Non-Claude models on unknown compatible endpoints keep the previous no-cache default; `compat.promptCacheMode: "none"`, `compat.promptCacheMode: "explicit"`, and per-request `cacheRetention: "none"` still opt out. Non-canonical Claude models get the default ~5m cache lifetime unless the endpoint sets `compat.supportsLongCacheRetention: true`.

## [0.12.12] - 2026-08-05

### Fixed
Expand Down
11 changes: 10 additions & 1 deletion packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,15 @@ function dropAnthropicStrictTools(params: MessageCreateParamsStreaming): void {
}
}

function isClaudeFamilyModel(model: Model<"anthropic-messages">): boolean {
// Classify the same identifier the request body serializes (`params.model =
// model.id` in buildParams); a differing `wireModelId` is not dispatched by
// this transport, so it must not drive the cache decision either.
const id = model.id;
const shortId = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
return shortId.toLowerCase().startsWith("claude-");
}

function getCacheControl(
model: Model<"anthropic-messages">,
baseUrl: string,
Expand All @@ -462,7 +471,7 @@ function getCacheControl(
? "none"
: promptCacheMode === "explicit"
? "explicit"
: isCanonicalApi
: isCanonicalApi || isClaudeFamilyModel(model)
? "automatic"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate automatic caching on endpoint capability

When a user-defined anthropic-messages endpoint exposes a claude-* model but rejects top-level cache_control and requires block-level markers, this identifier-only default now makes every request fail upstream. The updated AnthropicCompat documentation explicitly recognizes such endpoints and recommends promptCacheMode: "explicit", but the coding-agent models configuration still validates compat with OpenAICompatSchema, which does not admit promptCacheMode, so CLI users cannot select that advertised mode (short of disabling caching entirely with cacheRetention: "none"). Restrict this default to known-capable endpoints or expose the Anthropic compatibility setting through the configuration schema.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9dc8a8d. The shared models compatibility schema now admits compat.promptCacheMode (none, explicit, automatic) and compat.supportsLongCacheRetention at provider, model, and model-override levels; the generated schemas/models.schema.json is updated. Focused schema validation and ModelRegistry propagation/precedence tests cover all three configuration levels. The branch is also merged with current dev, and Anthropic/config/package checks are clean.

: "none";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the explicit automatic cache mode

When a non-Claude model on a noncanonical Anthropic-compatible endpoint sets the newly accepted compat.promptCacheMode: "automatic", this ternary ignores that value and falls through to the model-name classifier, selecting "none" and emitting no cache marker. This makes the only meaningful opt-in use of automatic ineffective for custom identifiers; handle promptCacheMode === "automatic" before applying the endpoint/model defaults.

Useful? React with 👍 / 👎.

if (mode === "none") return { mode };
Expand Down
4 changes: 3 additions & 1 deletion packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,9 @@ export interface AnthropicCompat extends ToolChoiceCompat {
/**
* Prompt-cache transport accepted by this Anthropic-compatible endpoint.
* Canonical Anthropic defaults to `"automatic"`; noncanonical endpoints default
* to `"none"` and must explicitly opt into generated `"explicit"` markers.
* to `"automatic"` for Claude-family models and `"none"` otherwise. Set
* `"none"` to opt out of generated markers, or `"explicit"` for block-level
* breakpoints on endpoints that reject top-level `cache_control`.
*/
promptCacheMode?: "none" | "explicit" | "automatic";
}
Expand Down
87 changes: 84 additions & 3 deletions packages/ai/test/anthropic-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ function capturePayload(
model: Model<"anthropic-messages">,
input: Context,
onPayload?: (payload: Payload) => Payload | undefined,
cacheRetention?: "none" | "short" | "long",
): Promise<Payload> {
const { promise, resolve } = Promise.withResolvers<Payload>();
streamAnthropic(model, input, {
apiKey: "sk-ant-api-test",
isOAuth: false,
signal: abortedSignal(),
cacheRetention,
onPayload: payload => {
const replacement = onPayload?.(payload as Payload);
resolve((replacement ?? payload) as Payload);
Expand Down Expand Up @@ -98,21 +100,100 @@ describe("Anthropic prompt caching", () => {
compat: { promptCacheMode: "explicit" },
};

it("defaults canonical Anthropic to automatic and requires compatible endpoints to opt into explicit caching", async () => {
const [canonical, compatible, explicit] = await Promise.all([
it("defaults canonical Anthropic and Claude-family models to automatic caching", async () => {
const nonClaudeModel: Model<"anthropic-messages"> = {
...canonicalModel,
id: "custom-compatible-model",
name: "Custom compatible model",
baseUrl: "https://proxy.example.test/anthropic",
};
const [canonical, proxiedClaude, explicit, nonClaude] = await Promise.all([
capturePayload(canonicalModel, context()),
capturePayload({ ...canonicalModel, baseUrl: "https://proxy.example.test/anthropic" }, context()),
capturePayload(explicitCompatibleModel, context()),
capturePayload(nonClaudeModel, context()),
]);

expect(canonical.cache_control).toEqual({ type: "ephemeral", ttl: "1h" });
expect(compatible.cache_control).toBeUndefined();
// Claude-family models behind non-canonical Anthropic-compatible gateways
// get top-level automatic caching; without explicit long-retention support
// they fall back to the default ~5m breakpoint (no ttl).
expect(proxiedClaude.cache_control).toEqual({ type: "ephemeral" });
expect(explicit.cache_control).toBeUndefined();
expect(explicit.tools?.every(tool => !(tool as { cache_control?: CacheControl }).cache_control)).toBe(true);
expect(!Array.isArray(explicit.system) || explicit.system.every(block => !block.cache_control)).toBe(true);
expect((explicit.messages.at(-1)?.content as Array<{ cache_control?: CacheControl }>)[0]?.cache_control).toEqual({
type: "ephemeral",
});
// Non-Claude models on unknown compatible endpoints still get no generated caching.
expect(nonClaude.cache_control).toBeUndefined();
});
it("classifies the dispatched model id and honors every cache opt-out", async () => {
const proxyUrl = "https://proxy.example.test/anthropic";
const cases: Array<{
name: string;
model: Model<"anthropic-messages">;
options?: { cacheRetention?: "none" | "short" | "long" };
expected: CacheControl | undefined;
}> = [
{
name: "prefixed claude id on non-canonical gateway",
model: { ...canonicalModel, id: "anthropic/claude-sonnet-4-5", baseUrl: proxyUrl },
expected: { type: "ephemeral" },
},
{
name: "uppercase claude id on non-canonical gateway",
model: { ...canonicalModel, id: "CLAUDE-OPUS-5", baseUrl: proxyUrl },
expected: { type: "ephemeral" },
},
{
name: "non-canonical claude with explicit long retention opt-in",
model: { ...canonicalModel, baseUrl: proxyUrl, compat: { supportsLongCacheRetention: true } },
expected: { type: "ephemeral", ttl: "1h" },
},
{
name: "promptCacheMode none disables automatic caching",
model: { ...canonicalModel, baseUrl: proxyUrl, compat: { promptCacheMode: "none" } },
expected: undefined,
},
{
name: "per-request cacheRetention none disables caching on canonical",
model: canonicalModel,
options: { cacheRetention: "none" },
expected: undefined,
},
{
name: "id containing -claude- but not starting claude- is not cached",
model: { ...canonicalModel, id: "my-claude-helper", baseUrl: proxyUrl },
expected: undefined,
},
{
name: "wireModelId override does not drive the decision; dispatched id governs",
model: {
...canonicalModel,
id: "local-alias",
wireModelId: "claude-sonnet-4-5",
baseUrl: proxyUrl,
},
expected: undefined,
},
{
name: "wireModelId override to a non-claude wire id still follows dispatched id",
model: {
...canonicalModel,
id: "claude-sonnet-4-5",
wireModelId: "local-alias",
baseUrl: proxyUrl,
},
expected: { type: "ephemeral" },
},
];

for (const { name, model, options, expected } of cases) {
const payload = await capturePayload(model, context(), undefined, options?.cacheRetention);
expect(payload.model, name).toBe(model.id);
expect(cacheControls(payload), name).toEqual(expected ? [expected] : []);
}
});

it("counts top-level automatic and caller controls together without mutating a callback replacement", async () => {
Expand Down
25 changes: 22 additions & 3 deletions packages/ai/test/anthropic-stream-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,12 @@ describe("anthropic stream envelope handling", () => {
model,
{ ...model, compat: { supportsLongCacheRetention: false } },
{ ...model, baseUrl: "https://proxy.example.com/anthropic" },
{
...model,
id: "custom-compatible-model",
name: "Custom compatible model",
baseUrl: "https://proxy.example.com/anthropic",
},
]) {
const stream = streamAnthropic(testModel, context, {
apiKey: "sk-ant-test",
Expand All @@ -1143,7 +1149,11 @@ describe("anthropic stream envelope handling", () => {
);
expect(cacheControls[0]).toEqual({ type: "ephemeral", ttl: "1h" });
expect(cacheControls[1]).toEqual({ type: "ephemeral" });
expect(cacheControls[2]).toBeUndefined();
// Claude-family models through Anthropic-compatible gateways get automatic
// caching; without explicit long-retention support the default ~5m breakpoint applies.
expect(cacheControls[2]).toEqual({ type: "ephemeral" });
// Non-Claude models on unknown compatible endpoints receive no generated caching.
expect(cacheControls[3]).toBeUndefined();
});

it("defaults to 1h cache TTL when the request omits cacheRetention, with safe fallback", async () => {
Expand All @@ -1163,6 +1173,12 @@ describe("anthropic stream envelope handling", () => {
model,
{ ...model, compat: { supportsLongCacheRetention: false } },
{ ...model, baseUrl: "https://proxy.example.com/anthropic" },
{
...model,
id: "custom-compatible-model",
name: "Custom compatible model",
baseUrl: "https://proxy.example.com/anthropic",
},
]) {
// No cacheRetention passed: the provider default should drive the TTL.
const stream = streamAnthropic(testModel, context, { apiKey: "sk-ant-test" });
Expand All @@ -1185,7 +1201,10 @@ describe("anthropic stream envelope handling", () => {
expect(cacheControls[0]).toEqual({ type: "ephemeral", ttl: "1h" });
// Models without long-cache support fall back to the default ~5m breakpoint.
expect(cacheControls[1]).toEqual({ type: "ephemeral" });
// Unknown compatible endpoints do not receive generated cache controls.
expect(cacheControls[2]).toBeUndefined();
// Claude-family models through Anthropic-compatible gateways get automatic
// caching; without explicit long-retention support the default ~5m breakpoint applies.
expect(cacheControls[2]).toEqual({ type: "ephemeral" });
// Non-Claude models on unknown compatible endpoints receive no generated caching.
expect(cacheControls[3]).toBeUndefined();
});
});