diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index 60f96dc69aaa..015d0028c162 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -30,12 +30,16 @@ 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 } +// 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) + // 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 +102,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 914d7c0a0bad..9ab833d26fcd 100644 --- a/packages/llm/src/providers/openrouter.ts +++ b/packages/llm/src/providers/openrouter.ts @@ -4,10 +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) @@ -18,6 +20,7 @@ export interface OpenRouterOptions { readonly usage?: boolean | Record readonly reasoning?: Record readonly promptCacheKey?: string + readonly cacheControl?: Record } export type OpenRouterProviderOptionsInput = ProviderOptions & { @@ -35,19 +38,34 @@ 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 catalog aliases for the same models. +const isAnthropicModel = (modelID: string) => modelID.replace(/^~/, "").startsWith("anthropic/") + +// 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) + if (!policyEnabled(policy)) return undefined + return ttlBucket(policy.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M +} + export const protocol = Protocol.make({ id: "openrouter-chat", body: { schema: OpenRouterBody, from: (request) => OpenAIChat.protocol.body.from(request).pipe( - Effect.map( - (body) => - ({ - ...body, - ...bodyOptions(request.providerOptions?.openrouter), - }) as OpenRouterBody, - ), + Effect.map((body) => { + const automatic = automaticCacheControl(request) + return { + ...body, + ...(automatic ? { cache_control: automatic } : {}), + ...bodyOptions(request.providerOptions?.openrouter), + } as OpenRouterBody + }), ), }, stream: OpenAIChat.protocol.stream, @@ -63,6 +81,7 @@ const bodyOptions = (input: unknown) => { : {}), ...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}), ...(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 86d1317b3e64..7085a60ba612 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") }), ) @@ -39,6 +40,7 @@ describe("OpenRouter", () => { usage: true, reasoning: { effort: "high" }, promptCacheKey: "session_123", + cacheControl: { type: "ephemeral", ttl: "1h" }, }, }, }).model("anthropic/claude-3.7-sonnet:thinking"), @@ -50,6 +52,55 @@ describe("OpenRouter", () => { usage: { include: true }, reasoning: { effort: "high" }, prompt_cache_key: "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") + + 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" } }) + }), + ) + + 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/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 705af3d5c1b5..34cc13a6a670 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1255,6 +1255,15 @@ export function options(input: { } } + // `~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") { result["gateway"] = { caching: "auto" } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 93e166c4a8c9..be85d5b2aba7 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -288,18 +288,51 @@ 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.session_id).toBe(sessionID) + expect(result.prompt_cache_key).toBeUndefined() + }) + } + + test("should leave non-Anthropic OpenRouter models untouched", () => { 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.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", () => { 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({