From e74ca4e49e95f7a04dcf938e4ad2f4e57e8ee804 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 26 Jul 2026 18:43:23 -0400 Subject: [PATCH 1/5] fix(llm): enable Anthropic prompt caching on the OpenRouter route The V2 native OpenRouter route never set cache_control, so Anthropic models proxied through OpenRouter were billed at full input price on every turn. Set OpenRouter's top-level cache_control for anthropic/* models (including ~anthropic/* aliases) when the cache policy is on, and let an explicit providerOptions.openrouter.cacheControl override it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/llm/src/providers/openrouter.ts | 52 ++++++++++++++++--- packages/llm/test/provider/openrouter.test.ts | 46 +++++++++++++++- .../opencode/test/session/llm-native.test.ts | 26 ++++++++++ 3 files changed, 114 insertions(+), 10 deletions(-) diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts index 914d7c0a0bad..83f27067946e 100644 --- a/packages/llm/src/providers/openrouter.ts +++ b/packages/llm/src/providers/openrouter.ts @@ -8,6 +8,7 @@ import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAIChat from "../protocols/openai-chat" import { isRecord } from "../protocols/shared" +import { ttlBucket } from "../protocols/utils/cache" export const profile = OpenAICompatibleProfiles.profiles.openrouter export const id = ProviderID.make(profile.provider) @@ -18,6 +19,11 @@ export interface OpenRouterOptions { readonly usage?: boolean | Record readonly reasoning?: Record readonly promptCacheKey?: string + readonly prompt_cache_key?: string + readonly sessionID?: string + readonly session_id?: string + readonly cacheControl?: Record + readonly cache_control?: Record } export type OpenRouterProviderOptionsInput = ProviderOptions & { @@ -41,13 +47,28 @@ export const protocol = Protocol.make({ schema: OpenRouterBody, from: (request) => OpenAIChat.protocol.body.from(request).pipe( - Effect.map( - (body) => - ({ - ...body, - ...bodyOptions(request.providerOptions?.openrouter), - }) as OpenRouterBody, - ), + Effect.map((body) => { + const options = bodyOptions(request.providerOptions?.openrouter) + const policy = request.cache + const cacheEnabled = + policy === undefined || + policy === "auto" || + (typeof policy === "object" && Boolean(policy.tools || policy.system || policy.messages)) + const automaticCacheControl = + request.model.id.replace(/^~/, "").startsWith("anthropic/") && cacheEnabled + ? { + type: "ephemeral", + ...(ttlBucket(typeof policy === "object" ? policy.ttlSeconds : undefined) === "1h" + ? { ttl: "1h" } + : {}), + } + : undefined + return { + ...body, + ...(automaticCacheControl ? { cache_control: automaticCacheControl } : {}), + ...options, + } as OpenRouterBody + }), ), }, stream: OpenAIChat.protocol.stream, @@ -55,6 +76,11 @@ export const protocol = Protocol.make({ const bodyOptions = (input: unknown) => { const openrouter = isRecord(input) ? input : {} + const cacheControl = isRecord(openrouter.cacheControl) + ? openrouter.cacheControl + : isRecord(openrouter.cache_control) + ? openrouter.cache_control + : undefined return { ...(openrouter.usage === true ? { usage: { include: true } } @@ -62,7 +88,17 @@ const bodyOptions = (input: unknown) => { ? { usage: openrouter.usage } : {}), ...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}), - ...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}), + ...(typeof openrouter.promptCacheKey === "string" + ? { prompt_cache_key: openrouter.promptCacheKey } + : typeof openrouter.prompt_cache_key === "string" + ? { prompt_cache_key: openrouter.prompt_cache_key } + : {}), + ...(typeof openrouter.sessionID === "string" + ? { session_id: openrouter.sessionID } + : typeof openrouter.session_id === "string" + ? { session_id: openrouter.session_id } + : {}), + ...(cacheControl ? { cache_control: cacheControl } : {}), } } diff --git a/packages/llm/test/provider/openrouter.test.ts b/packages/llm/test/provider/openrouter.test.ts index 86d1317b3e64..691b25d83c1b 100644 --- a/packages/llm/test/provider/openrouter.test.ts +++ b/packages/llm/test/provider/openrouter.test.ts @@ -25,6 +25,7 @@ describe("OpenRouter", () => { messages: [{ role: "user", content: "Say hello." }], stream: true, }) + expect(prepared.body).not.toHaveProperty("cache_control") }), ) @@ -38,7 +39,8 @@ describe("OpenRouter", () => { openrouter: { usage: true, reasoning: { effort: "high" }, - promptCacheKey: "session_123", + session_id: "session_123", + cacheControl: { type: "ephemeral", ttl: "1h" }, }, }, }).model("anthropic/claude-3.7-sonnet:thinking"), @@ -49,7 +51,47 @@ describe("OpenRouter", () => { expect(prepared.body).toMatchObject({ usage: { include: true }, reasoning: { effort: "high" }, - prompt_cache_key: "session_123", + session_id: "session_123", + cache_control: { type: "ephemeral", ttl: "1h" }, + }) + }), + ) + + it.effect("enables automatic prompt caching for Anthropic models", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-opus-4.8"), + prompt: "Say hello.", + }), + ) + + expect(prepared.body).toMatchObject({ + cache_control: { type: "ephemeral" }, + }) + + const disabled = yield* LLMClient.prepare( + LLM.request({ + model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-opus-4.8"), + prompt: "Say hello.", + cache: "none", + }), + ) + expect(disabled.body).not.toHaveProperty("cache_control") + }), + ) + + it.effect("enables automatic prompt caching for tilde-prefixed Anthropic aliases", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenRouter.configure({ apiKey: "test-key" }).model("~anthropic/claude-opus-latest"), + prompt: "Say hello.", + }), + ) + + expect(prepared.body).toMatchObject({ + cache_control: { type: "ephemeral" }, }) }), ) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index dd4d9cc17481..0c24801378a2 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -378,6 +378,32 @@ describe("session.llm-native.request", () => { expect(openrouter.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1") }) + it.effect("enables OpenRouter prompt caching for Anthropic models", () => + Effect.gen(function* () { + const prepared = yield* prepareNativeRequest({ + model: { + ...baseModel, + id: ModelV2.ID.make("anthropic/claude-opus-4.8"), + providerID: ProviderV2.ID.openrouter, + api: { + id: "anthropic/claude-opus-4.8", + url: "https://openrouter.ai/api/v1", + npm: "@openrouter/ai-sdk-provider", + }, + }, + apiKey: "test-key", + messages: [storedSession.user("Say hello.")], + }) + + expect(prepared).toMatchObject({ + route: "openrouter", + body: { + cache_control: { type: "ephemeral" }, + }, + }) + }), + ) + test("fails fast for unsupported provider packages", () => { expect(() => LLMNative.request({ From 485cda2b534c3851b6d13c15e588fe9b34cfd255 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 26 Jul 2026 19:15:55 -0400 Subject: [PATCH 2/5] fix(provider): enable OpenRouter Anthropic prompt caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic models proxied through OpenRouter run on the AI SDK path, not the V2 native route, so the OpenRouter protocol fix never reached them. Set the documented top-level `cache_control` in ProviderTransform.options for `anthropic/*` (and `~anthropic/*` aliases) on `@openrouter/ai-sdk-provider`, which is the namespace the AI SDK provider spreads onto the request body. Config `options.cache_control` still wins — model options merge over these defaults — and non-Anthropic OpenRouter models are untouched. Verified against openrouter/anthropic/claude-haiku-4.5, two turns per run: without the fix both turns report cache read 0 / write 0; with it turn one writes 15840 and turn two reads 15840, taking turn two from $0.01610 to $0.00165. Co-Authored-By: Claude Opus 5 (1M context) --- packages/opencode/src/provider/transform.ts | 9 ++++++++ .../opencode/test/provider/transform.test.ts | 21 +++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 705af3d5c1b5..1a5630eea7a2 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1255,6 +1255,15 @@ export function options(input: { } } + // Anthropic models proxied through OpenRouter cache nothing without an explicit + // breakpoint. `~anthropic/*` are catalog aliases for the same models. + if ( + input.model.api.npm === "@openrouter/ai-sdk-provider" && + input.model.api.id.replace(/^~/, "").startsWith("anthropic/") + ) { + result["cache_control"] = { type: "ephemeral" } + } + if (input.model.api.npm === "@ai-sdk/gateway") { result["gateway"] = { caching: "auto" } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 93e166c4a8c9..220b1d089e9d 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -288,16 +288,33 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.promptCacheKey).toBe(sessionID) }) - test("should not send an undocumented OpenRouter prompt_cache_key", () => { + for (const id of ["anthropic/claude-opus-4.8", "~anthropic/claude-opus-latest"]) { + test(`should enable OpenRouter automatic caching for ${id}`, () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openrouter", + api: { ...mockModel.api, id, npm: "@openrouter/ai-sdk-provider" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.cache_control).toEqual({ type: "ephemeral" }) + expect(result.prompt_cache_key).toBeUndefined() + }) + } + + test("should not enable OpenRouter automatic caching for non-Anthropic models", () => { const result = ProviderTransform.options({ model: { ...mockModel, providerID: "openrouter", - api: { ...mockModel.api, npm: "@openrouter/ai-sdk-provider" }, + api: { ...mockModel.api, id: "google/gemini-3.6-flash", npm: "@openrouter/ai-sdk-provider" }, }, sessionID, providerOptions: {}, }) + expect(result.cache_control).toBeUndefined() expect(result.prompt_cache_key).toBeUndefined() }) }) From 4b15d04d70a0cd8b1fe270f9d9572297637b376f Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 26 Jul 2026 19:21:33 -0400 Subject: [PATCH 3/5] refactor(llm): name the OpenRouter cache-policy quirk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request-body construction inlined the provider policy and TTL projection, which packages/llm/AGENTS.md asks to keep behind named helpers so protocols stay comparable side by side. Move it to `automaticCacheControl`, and share the policy semantics with `applyCachePolicy` via exported `resolvePolicy` / `policyEnabled` rather than re-deriving "is caching on" by hand. Drop the `session_id` / `sessionID` options. OpenRouter does not document a session-affinity field, and #38424 removed the equally undocumented `prompt_cache_key` for this provider four days ago. Drop the snake_case input spellings with them — AGENTS.md wants one way to construct a thing. Co-Authored-By: Claude Opus 5 (1M context) --- packages/llm/src/cache-policy.ts | 11 +++- packages/llm/src/providers/openrouter.ts | 60 +++++++------------ packages/llm/test/provider/openrouter.test.ts | 13 +++- 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index 60f96dc69aaa..5cb9340facb5 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -30,12 +30,17 @@ const NONE: CachePolicyObject = {} // - "auto" → tools + system + latest user msg. // - "none" → no auto placement; manual `CacheHint`s still flow. // - object form → exactly what the caller asked for. -const resolve = (policy: CachePolicy | undefined): CachePolicyObject => { +export const resolvePolicy = (policy: CachePolicy | undefined): CachePolicyObject => { if (policy === undefined || policy === "auto") return AUTO if (policy === "none") return NONE return policy } +// Protocols that cache a whole request in one directive, rather than at the +// breakpoints `applyCachePolicy` places, have no placements to read — only this. +export const policyEnabled = (policy: CachePolicyObject): boolean => + Boolean(policy.tools || policy.system || policy.messages) + // Protocols whose wire format ignores inline cache markers (OpenAI's implicit // prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the // whole policy pass for these — emitting hints would be harmless but pointless. @@ -98,8 +103,8 @@ const markMessages = ( export const applyCachePolicy = (request: LLMRequest): LLMRequest => { if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request - const policy = resolve(request.cache) - if (!policy.tools && !policy.system && !policy.messages) return request + const policy = resolvePolicy(request.cache) + if (!policyEnabled(policy)) return request const hint = makeHint(policy.ttlSeconds) const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts index 83f27067946e..35204dd147fa 100644 --- a/packages/llm/src/providers/openrouter.ts +++ b/packages/llm/src/providers/openrouter.ts @@ -4,11 +4,12 @@ import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" -import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import { ProviderID, type LLMRequest, type ModelID, type ProviderOptions } from "../schema" import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAIChat from "../protocols/openai-chat" import { isRecord } from "../protocols/shared" import { ttlBucket } from "../protocols/utils/cache" +import { policyEnabled, resolvePolicy } from "../cache-policy" export const profile = OpenAICompatibleProfiles.profiles.openrouter export const id = ProviderID.make(profile.provider) @@ -19,11 +20,7 @@ export interface OpenRouterOptions { readonly usage?: boolean | Record readonly reasoning?: Record readonly promptCacheKey?: string - readonly prompt_cache_key?: string - readonly sessionID?: string - readonly session_id?: string readonly cacheControl?: Record - readonly cache_control?: Record } export type OpenRouterProviderOptionsInput = ProviderOptions & { @@ -41,6 +38,21 @@ const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields ]) export type OpenRouterBody = Schema.Schema.Type +const EPHEMERAL_5M = { type: "ephemeral" as const } +const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const } + +// `~anthropic/*` are OpenRouter catalog aliases for the same upstream models. +const isAnthropicModel = (modelID: string) => modelID.replace(/^~/, "").startsWith("anthropic/") + +// "Automatic" caching is a whole-request directive, so the policy's breakpoint +// placements do not apply here — only whether it is on, and its TTL. +const automaticCacheControl = (request: LLMRequest) => { + if (!isAnthropicModel(request.model.id)) return undefined + const policy = resolvePolicy(request.cache) + if (!policyEnabled(policy)) return undefined + return ttlBucket(policy.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M +} + export const protocol = Protocol.make({ id: "openrouter-chat", body: { @@ -48,25 +60,11 @@ export const protocol = Protocol.make({ from: (request) => OpenAIChat.protocol.body.from(request).pipe( Effect.map((body) => { - const options = bodyOptions(request.providerOptions?.openrouter) - const policy = request.cache - const cacheEnabled = - policy === undefined || - policy === "auto" || - (typeof policy === "object" && Boolean(policy.tools || policy.system || policy.messages)) - const automaticCacheControl = - request.model.id.replace(/^~/, "").startsWith("anthropic/") && cacheEnabled - ? { - type: "ephemeral", - ...(ttlBucket(typeof policy === "object" ? policy.ttlSeconds : undefined) === "1h" - ? { ttl: "1h" } - : {}), - } - : undefined + const automatic = automaticCacheControl(request) return { ...body, - ...(automaticCacheControl ? { cache_control: automaticCacheControl } : {}), - ...options, + ...(automatic ? { cache_control: automatic } : {}), + ...bodyOptions(request.providerOptions?.openrouter), } as OpenRouterBody }), ), @@ -76,11 +74,6 @@ export const protocol = Protocol.make({ const bodyOptions = (input: unknown) => { const openrouter = isRecord(input) ? input : {} - const cacheControl = isRecord(openrouter.cacheControl) - ? openrouter.cacheControl - : isRecord(openrouter.cache_control) - ? openrouter.cache_control - : undefined return { ...(openrouter.usage === true ? { usage: { include: true } } @@ -88,17 +81,8 @@ const bodyOptions = (input: unknown) => { ? { usage: openrouter.usage } : {}), ...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}), - ...(typeof openrouter.promptCacheKey === "string" - ? { prompt_cache_key: openrouter.promptCacheKey } - : typeof openrouter.prompt_cache_key === "string" - ? { prompt_cache_key: openrouter.prompt_cache_key } - : {}), - ...(typeof openrouter.sessionID === "string" - ? { session_id: openrouter.sessionID } - : typeof openrouter.session_id === "string" - ? { session_id: openrouter.session_id } - : {}), - ...(cacheControl ? { cache_control: cacheControl } : {}), + ...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}), + ...(isRecord(openrouter.cacheControl) ? { cache_control: openrouter.cacheControl } : {}), } } diff --git a/packages/llm/test/provider/openrouter.test.ts b/packages/llm/test/provider/openrouter.test.ts index 691b25d83c1b..7085a60ba612 100644 --- a/packages/llm/test/provider/openrouter.test.ts +++ b/packages/llm/test/provider/openrouter.test.ts @@ -39,7 +39,7 @@ describe("OpenRouter", () => { openrouter: { usage: true, reasoning: { effort: "high" }, - session_id: "session_123", + promptCacheKey: "session_123", cacheControl: { type: "ephemeral", ttl: "1h" }, }, }, @@ -51,7 +51,7 @@ describe("OpenRouter", () => { expect(prepared.body).toMatchObject({ usage: { include: true }, reasoning: { effort: "high" }, - session_id: "session_123", + prompt_cache_key: "session_123", cache_control: { type: "ephemeral", ttl: "1h" }, }) }), @@ -78,6 +78,15 @@ describe("OpenRouter", () => { }), ) expect(disabled.body).not.toHaveProperty("cache_control") + + const hourly = yield* LLMClient.prepare( + LLM.request({ + model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-opus-4.8"), + prompt: "Say hello.", + cache: { system: true, ttlSeconds: 3600 }, + }), + ) + expect(hourly.body).toMatchObject({ cache_control: { type: "ephemeral", ttl: "1h" } }) }), ) From be1476d64484b4c3171c928d3efb130efb3ee7b1 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Sun, 26 Jul 2026 19:41:57 -0400 Subject: [PATCH 4/5] fix(provider): send OpenRouter session_id for sticky routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenRouter derives its sticky-routing key by hashing the opening messages when no session key is given, and those drift as a conversation grows. A cache written on one turn is then unreachable when the next turn routes to a different upstream provider, so the documented top-level `session_id` is what makes the cache_control write actually pay off across turns. https://openrouter.ai/docs/guides/best-practices/prompt-caching#using-session_id-for-sticky-sessions Sent for every OpenRouter model, not just Anthropic — it is a routing field, and gating it on a model family would be arbitrary. Co-Authored-By: Claude Opus 5 (1M context) --- packages/llm/src/cache-policy.ts | 3 +-- packages/llm/src/providers/openrouter.ts | 5 ++--- packages/opencode/src/provider/transform.ts | 13 ++++++------- packages/opencode/test/provider/transform.test.ts | 4 +++- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index 5cb9340facb5..015d0028c162 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -36,8 +36,7 @@ export const resolvePolicy = (policy: CachePolicy | undefined): CachePolicyObjec return policy } -// Protocols that cache a whole request in one directive, rather than at the -// breakpoints `applyCachePolicy` places, have no placements to read — only this. +// For protocols that cache a whole request in one directive and have no placements to read. export const policyEnabled = (policy: CachePolicyObject): boolean => Boolean(policy.tools || policy.system || policy.messages) diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts index 35204dd147fa..9ab833d26fcd 100644 --- a/packages/llm/src/providers/openrouter.ts +++ b/packages/llm/src/providers/openrouter.ts @@ -41,11 +41,10 @@ export type OpenRouterBody = Schema.Schema.Type const EPHEMERAL_5M = { type: "ephemeral" as const } const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const } -// `~anthropic/*` are OpenRouter catalog aliases for the same upstream models. +// `~anthropic/*` are catalog aliases for the same models. const isAnthropicModel = (modelID: string) => modelID.replace(/^~/, "").startsWith("anthropic/") -// "Automatic" caching is a whole-request directive, so the policy's breakpoint -// placements do not apply here — only whether it is on, and its TTL. +// Whole-request directive, so only the policy's on/off and TTL apply, not its placements. const automaticCacheControl = (request: LLMRequest) => { if (!isAnthropicModel(request.model.id)) return undefined const policy = resolvePolicy(request.cache) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 1a5630eea7a2..b461c2632c80 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1255,13 +1255,12 @@ export function options(input: { } } - // Anthropic models proxied through OpenRouter cache nothing without an explicit - // breakpoint. `~anthropic/*` are catalog aliases for the same models. - if ( - input.model.api.npm === "@openrouter/ai-sdk-provider" && - input.model.api.id.replace(/^~/, "").startsWith("anthropic/") - ) { - result["cache_control"] = { type: "ephemeral" } + if (input.model.api.npm === "@openrouter/ai-sdk-provider") { + result["session_id"] = input.sessionID + // `~anthropic/*` are catalog aliases for the same models. + if (input.model.api.id.replace(/^~/, "").startsWith("anthropic/")) { + result["cache_control"] = { type: "ephemeral" } + } } if (input.model.api.npm === "@ai-sdk/gateway") { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 220b1d089e9d..34c17fad10de 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -300,11 +300,12 @@ describe("ProviderTransform.options - setCacheKey", () => { providerOptions: {}, }) expect(result.cache_control).toEqual({ type: "ephemeral" }) + expect(result.session_id).toBe(sessionID) expect(result.prompt_cache_key).toBeUndefined() }) } - test("should not enable OpenRouter automatic caching for non-Anthropic models", () => { + test("should keep OpenRouter sticky routing but not caching for non-Anthropic models", () => { const result = ProviderTransform.options({ model: { ...mockModel, @@ -314,6 +315,7 @@ describe("ProviderTransform.options - setCacheKey", () => { sessionID, providerOptions: {}, }) + expect(result.session_id).toBe(sessionID) expect(result.cache_control).toBeUndefined() expect(result.prompt_cache_key).toBeUndefined() }) From 016694a855ab987e8e885f982d4494a56e3811e2 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 27 Jul 2026 09:28:36 -0400 Subject: [PATCH 5/5] fix(provider): scope OpenRouter session_id to Anthropic and honor setCacheKey session_id also pins the resolved model for OpenRouter router models such as Auto Router and Pareto Router, so sending it for every model changed routing semantics well outside the Anthropic caching bug in #39009. Restrict it to the branch that needs it. setCacheKey: false is the established opt-out for provider cache and session keys, and the new assignment sat outside that guard. Caching itself stays on when opted out, since cache_control carries no session identity. Co-Authored-By: Claude Opus 5 (1M context) --- packages/opencode/src/provider/transform.ts | 13 +++++++------ .../opencode/test/provider/transform.test.ts | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index b461c2632c80..34cc13a6a670 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1255,12 +1255,13 @@ export function options(input: { } } - if (input.model.api.npm === "@openrouter/ai-sdk-provider") { - result["session_id"] = input.sessionID - // `~anthropic/*` are catalog aliases for the same models. - if (input.model.api.id.replace(/^~/, "").startsWith("anthropic/")) { - result["cache_control"] = { type: "ephemeral" } - } + // `~anthropic/*` are catalog aliases for the same models. + if ( + input.model.api.npm === "@openrouter/ai-sdk-provider" && + input.model.api.id.replace(/^~/, "").startsWith("anthropic/") + ) { + result["cache_control"] = { type: "ephemeral" } + if (input.providerOptions?.setCacheKey !== false) result["session_id"] = input.sessionID } if (input.model.api.npm === "@ai-sdk/gateway") { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 34c17fad10de..be85d5b2aba7 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -305,7 +305,7 @@ describe("ProviderTransform.options - setCacheKey", () => { }) } - test("should keep OpenRouter sticky routing but not caching for non-Anthropic models", () => { + test("should leave non-Anthropic OpenRouter models untouched", () => { const result = ProviderTransform.options({ model: { ...mockModel, @@ -315,10 +315,24 @@ describe("ProviderTransform.options - setCacheKey", () => { sessionID, providerOptions: {}, }) - expect(result.session_id).toBe(sessionID) expect(result.cache_control).toBeUndefined() + expect(result.session_id).toBeUndefined() expect(result.prompt_cache_key).toBeUndefined() }) + + test("should disable the OpenRouter session key but keep caching when opted out", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openrouter", + api: { ...mockModel.api, id: "anthropic/claude-opus-4.8", npm: "@openrouter/ai-sdk-provider" }, + }, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.cache_control).toEqual({ type: "ephemeral" }) + expect(result.session_id).toBeUndefined() + }) }) describe("ProviderTransform.options - zai/zhipuai thinking", () => {