diff --git a/src/core/ai/capabilities.ts b/src/core/ai/capabilities.ts index 5d860a0ab..a701cf860 100644 --- a/src/core/ai/capabilities.ts +++ b/src/core/ai/capabilities.ts @@ -90,7 +90,10 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti return { supportsToolCalling: chat.supports_tools === true, - supportsPromptCaching: chat.supports_prompt_cache === true, + // #1987: may be per-model-id (OpenRouter caches anthropic/claude-* routes). + supportsPromptCaching: typeof chat.supports_prompt_cache === 'function' + ? chat.supports_prompt_cache(parsed.modelId) + : chat.supports_prompt_cache === true, // No recipe exposes parallel-tools-specifically yet; gate on supports_tools. // Subsequent waves can split this into its own recipe field if a provider // ever supports tools without parallel dispatch. diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 3b308e1dc..59c3d3d53 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -422,6 +422,20 @@ export function recipeSupportsStructuredOutputs(recipe: Recipe): boolean { return recipe.touchpoints.chat?.supports_structured_outputs === true; } +/** + * #1987: `supports_prompt_cache` may be a per-model-id function on + * openai-compatible aggregators (OpenRouter caches `anthropic/claude-*` + * routes but not every routed family). Fail-closed: anything not strictly + * `true` (or a function returning true) means no caching. + * + * @internal exported for tests. + */ +export function chatSupportsPromptCache(recipe: Recipe, modelId: string): boolean { + const support = recipe.touchpoints.chat?.supports_prompt_cache; + if (typeof support === 'function') return support(modelId); + return support === true; +} + /** * #1250: native providers (anthropic/openai) are instantiated as * `create({ apiKey })` with NO explicit baseURL, so the AI SDK reads @@ -2274,10 +2288,13 @@ function instantiateExpansion(recipe: Recipe, modelId: string, cfg: AIGatewayCon const auth = applyResolveAuth(recipe, cfg, 'expansion'); // v0.32: env-templated base URL + optional fetch wrapper. const compat = applyOpenAICompatConfig(recipe, cfg); + // #1987: chat/expansion-scoped fetch wrapper (does not displace the + // embedding path's asymmetric shim). Recipe-wide fetch wins when both set. + const chatFetch = compat.fetch ?? recipe.compat?.chatFetch; return createOpenAICompatible({ name: recipe.id, baseURL: compat.baseURL, - ...(compat.fetch ? { fetch: compat.fetch } : {}), + ...(chatFetch ? { fetch: chatFetch } : {}), ...auth, supportsStructuredOutputs: recipeSupportsStructuredOutputs(recipe), }).languageModel(modelId); @@ -2818,10 +2835,13 @@ function instantiateChat(recipe: Recipe, modelId: string, cfg: AIGatewayConfig): const auth = applyResolveAuth(recipe, cfg, 'chat'); // v0.32: env-templated base URL + optional fetch wrapper. const compat = applyOpenAICompatConfig(recipe, cfg); + // #1987: chat/expansion-scoped fetch wrapper (does not displace the + // embedding path's asymmetric shim). Recipe-wide fetch wins when both set. + const chatFetch = compat.fetch ?? recipe.compat?.chatFetch; return createOpenAICompatible({ name: recipe.id, baseURL: compat.baseURL, - ...(compat.fetch ? { fetch: compat.fetch } : {}), + ...(chatFetch ? { fetch: chatFetch } : {}), ...auth, supportsStructuredOutputs: recipeSupportsStructuredOutputs(recipe), }).languageModel(modelId); @@ -3102,7 +3122,7 @@ export async function chat(opts: ChatOpts): Promise { const { model, recipe, modelId } = await resolveChatProvider(modelStr); const cfg = requireConfig(); - const supportsCache = recipe.touchpoints.chat?.supports_prompt_cache === true; + const supportsCache = chatSupportsPromptCache(recipe, modelId); const useCache = !!opts.cacheSystem && supportsCache; const tools = toAISDKTools(opts.tools); @@ -3201,7 +3221,21 @@ export async function chat(opts: ChatOpts): Promise { ? { role: 'system' as const, content: opts.system, - providerOptions: { anthropic: { cacheControl: cacheControlValue } }, + providerOptions: { + anthropic: { cacheControl: cacheControlValue }, + // #1987: OpenRouter Anthropic routes ride the openai-compatible + // wire, where `providerOptions.anthropic` never leaves the process. + // The openai-compatible adapter spreads message-level + // `openaiCompatible` metadata onto the outgoing system message; the + // recipe's chatFetch shim (liftMessageCacheControl) then lifts the + // marker into OpenRouter's documented content-part cache_control + // shape. Only reachable when chatSupportsPromptCache passed (i.e. + // anthropic/claude-* via openrouter), so no other compat recipe + // ever sees the marker. + ...(recipe.implementation === 'openai-compatible' + ? { openaiCompatible: { cache_control: cacheControlValue } } + : {}), + }, } : opts.system; diff --git a/src/core/ai/recipes/openrouter.ts b/src/core/ai/recipes/openrouter.ts index 055848ac8..8152dc819 100644 --- a/src/core/ai/recipes/openrouter.ts +++ b/src/core/ai/recipes/openrouter.ts @@ -1,5 +1,72 @@ import type { Recipe } from '../types.ts'; +/** + * OpenRouter prompt caching (#1987): OpenRouter forwards Anthropic + * `cache_control` breakpoints on Claude routes. Family-scoped (not "every + * anthropic/* model forever") so capability classification stays honest for + * routed models with no documented cache support. + * + * @internal exported for tests. + */ +export function openrouterSupportsPromptCache(modelId: string): boolean { + return modelId.trim().toLowerCase().startsWith('anthropic/claude-'); +} + +/** + * Lift message-level `cache_control` markers into OpenRouter's documented + * shape: a multipart content array whose text part carries the marker + * (OpenRouter only reads cache_control inside content parts). The gateway + * plants the message-level marker via the system block's + * `providerOptions.openaiCompatible` (the openai-compatible adapter spreads + * that metadata onto the outgoing message); this rewrite runs just before + * the request leaves the process. Mutates `body`; returns true when + * something changed. + * + * @internal exported for tests. + */ +export function liftMessageCacheControl(body: unknown): boolean { + if (!body || typeof body !== 'object') return false; + const messages = (body as Record).messages; + if (!Array.isArray(messages)) return false; + let modified = false; + for (const msg of messages) { + if (!msg || typeof msg !== 'object') continue; + const m = msg as Record; + if (!m.cache_control || typeof m.cache_control !== 'object') continue; + if (typeof m.content !== 'string') continue; + m.content = [{ type: 'text', text: m.content, cache_control: m.cache_control }]; + delete m.cache_control; + modified = true; + } + return modified; +} + +/** + * Chat-path fetch shim: rewrites outbound chat/completions bodies via + * `liftMessageCacheControl`. Fail-open — any parse error passes the original + * request through untouched. Installed as `compat.chatFetch` so the embedding + * path keeps the gateway's asymmetric input_type shim. + */ +export const openrouterCacheControlFetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, +): Promise => { + if (init?.body && typeof init.body === 'string') { + try { + const body = JSON.parse(init.body); + if (liftMessageCacheControl(body)) { + // Drop Content-Length so fetch recomputes it from the new body. + const headers = new Headers(init.headers ?? {}); + headers.delete('content-length'); + init = { ...init, body: JSON.stringify(body), headers }; + } + } catch { + // Non-JSON body: pass through untouched. + } + } + return fetch(input as any, init as any); +}) as unknown as typeof fetch; + /** * OpenRouter — single-key fan-out to OpenAI, Anthropic, Google, DeepSeek, and * dozens of other providers via a single OpenAI-compatible endpoint at @@ -93,13 +160,18 @@ export const openrouter: Recipe = { supports_tools: true, // Informational only — real gate is isAnthropicProvider() upstream. supports_subagent_loop: false, - supports_prompt_cache: false, + // #1987: per-model-family — OpenRouter forwards Anthropic cache_control + // on Claude routes; other routed families stay uncached. + supports_prompt_cache: openrouterSupportsPromptCache, // No max_context_tokens: catalog spans 128K to 1M+; a single recipe-wide // value is either unsafe for smaller models or wasteful for larger ones. // Let upstream errors surface per-model. price_last_verified: '2026-05-20', }, }, + // #1987: chat-path-only shim (see chatFetch doc in types.ts) that lifts the + // gateway's message-level cache marker into OpenRouter's content-part shape. + compat: { chatFetch: openrouterCacheControlFetch }, setup_hint: 'Get an API key at https://openrouter.ai/settings/keys, then `export OPENROUTER_API_KEY=...` and use `openrouter:/`. Optional overrides: OPENROUTER_BASE_URL (proxy), OPENROUTER_REFERER (attribution URL), OPENROUTER_TITLE (attribution name).', }; diff --git a/src/core/ai/types.ts b/src/core/ai/types.ts index 3b1ba844b..53d0413ac 100644 --- a/src/core/ai/types.ts +++ b/src/core/ai/types.ts @@ -222,8 +222,13 @@ export interface ChatTouchpoint { * Strictly stronger than supports_tools. */ supports_subagent_loop: boolean; - /** Anthropic-style ephemeral prompt cache markers honored. */ - supports_prompt_cache?: boolean; + /** + * Anthropic-style ephemeral prompt cache markers honored. Static booleans + * cover native providers; openai-compatible aggregators may decide per + * model id (OpenRouter forwards Anthropic cache_control on + * `anthropic/claude-*` routes but not for every routed model family). + */ + supports_prompt_cache?: boolean | ((modelId: string) => boolean); /** * Backend honors OpenAI structured outputs (a strict `json_schema` * response_format). Threaded into `createOpenAICompatible`'s @@ -348,6 +353,15 @@ export interface Recipe { */ compat?: { fetch?: typeof fetch; + /** + * Chat/expansion-only fetch wrapper. Unlike `fetch`, this does NOT + * displace the embedding path's asymmetric input_type shim + * (`openAICompatAsymmetricFetch`) — use it when only the chat wire + * shape needs rewriting (OpenRouter's cache_control lift). A recipe + * `fetch` (or `resolveOpenAICompatConfig` fetch) takes precedence on + * the chat path when both are present. + */ + chatFetch?: typeof fetch; }; /** * v0.32 (D13=A): optional runtime readiness check for local-server diff --git a/test/ai/capabilities.test.ts b/test/ai/capabilities.test.ts index 3f6ed4e0f..03ca71892 100644 --- a/test/ai/capabilities.test.ts +++ b/test/ai/capabilities.test.ts @@ -24,6 +24,17 @@ describe('getProviderCapabilities (v0.38 Slice 1 — D6/D7 recipe-driven capabil expect(caps.maxContext).toBe(1000000); // Gemini 1.5 Pro }); + it('marks OpenRouter Anthropic routes as cache-capable (#1987 function-valued capability)', () => { + const caps = getProviderCapabilities('openrouter:anthropic/claude-sonnet-4.6'); + expect(caps.supportsToolCalling).toBe(true); + expect(caps.supportsPromptCaching).toBe(true); + }); + + it('does not mark every OpenRouter route as cache-capable', () => { + const caps = getProviderCapabilities('openrouter:deepseek/deepseek-chat'); + expect(caps.supportsPromptCaching).toBe(false); + }); + it('honors Anthropic alias (undated → dated)', () => { const caps = getProviderCapabilities('anthropic:claude-haiku-4-5'); expect(caps.supportsToolCalling).toBe(true); @@ -54,6 +65,11 @@ describe('classifyCapabilities (D6 — three-tier capability verdict)', () => { expect(classifyCapabilities('openai:gpt-5.2')).toBe('degraded:no_caching'); }); + it('returns ok for OpenRouter Anthropic routes; other routes stay degraded:no_caching (#1987)', () => { + expect(classifyCapabilities('openrouter:anthropic/claude-sonnet-4.6')).toBe('ok'); + expect(classifyCapabilities('openrouter:deepseek/deepseek-chat')).toBe('degraded:no_caching'); + }); + it('returns degraded:no_caching for Google Gemini', () => { expect(classifyCapabilities('google:gemini-1.5-pro')).toBe('degraded:no_caching'); }); diff --git a/test/ai/gateway-cache-breakpoint.test.ts b/test/ai/gateway-cache-breakpoint.test.ts index 8a9b65ca9..1d607b2a2 100644 --- a/test/ai/gateway-cache-breakpoint.test.ts +++ b/test/ai/gateway-cache-breakpoint.test.ts @@ -29,7 +29,7 @@ * the bug made you believe was sufficient. */ -import { describe, test, expect, beforeEach } from 'bun:test'; +import { describe, test, expect, beforeEach, afterAll } from 'bun:test'; import { chat, configureGateway, @@ -43,6 +43,11 @@ describe('gbrain#2490 — Anthropic cache breakpoint placement', () => { __setGenerateTextTransportForTests(null); }); + afterAll(() => { + resetGateway(); + __setGenerateTextTransportForTests(null); + }); + async function captureTransportArgs( opts: Partial[0]> = {}, ): Promise { @@ -192,4 +197,51 @@ describe('gbrain#2490 — Anthropic cache breakpoint placement', () => { expect((captured.system as any)?.providerOptions?.anthropic?.cacheControl).toEqual(expected); expect(captured.tools?.search?.providerOptions?.anthropic?.cacheControl).toEqual(expected); }); + + // #1987 — OpenRouter Anthropic routes support prompt caching. The + // capability is per-model-family (function-valued supports_prompt_cache), + // and the system-block marker must ALSO ride `openaiCompatible` metadata: + // `providerOptions.anthropic` never leaves the process on the + // openai-compatible wire, so without the extra key no cache_control could + // ever reach OpenRouter. + async function captureOpenRouterArgs(model: string): Promise { + let captured: any; + __setGenerateTextTransportForTests(async (args: any) => { + captured = args; + return { + content: [{ type: 'text', text: 'ok' }], + finishReason: 'stop', + usage: { inputTokens: 1, outputTokens: 1 }, + } as any; + }); + configureGateway({ + chat_model: model, + env: { OPENROUTER_API_KEY: 'fake' }, + }); + await chat({ + model, + system: 'SYS', + cacheSystem: true, + messages: [{ role: 'user', content: 'hello' }], + }); + return captured; + } + + test('cacheSystem:true on openrouter:anthropic/claude-* plants anthropic AND openaiCompatible markers on the system block (#1987)', async () => { + const args = await captureOpenRouterArgs('openrouter:anthropic/claude-sonnet-4.6'); + expect(args.system).toEqual({ + role: 'system', + content: 'SYS', + providerOptions: { + anthropic: { cacheControl: { type: 'ephemeral' } }, + openaiCompatible: { cache_control: { type: 'ephemeral' } }, + }, + }); + }); + + test('cacheSystem:true on a non-Claude OpenRouter route stays uncached (per-model-family gate)', async () => { + const args = await captureOpenRouterArgs('openrouter:deepseek/deepseek-chat'); + expect(args.system).toBe('SYS'); + expect(args.providerOptions?.anthropic).toBeUndefined(); + }); }); diff --git a/test/ai/gateway-chat.test.ts b/test/ai/gateway-chat.test.ts index cd73ff4e5..2273d6971 100644 --- a/test/ai/gateway-chat.test.ts +++ b/test/ai/gateway-chat.test.ts @@ -45,11 +45,14 @@ describe('chat touchpoint — recipe registry', () => { } }); - test('only Anthropic claims supports_prompt_cache=true', () => { + test('only Anthropic and model-family-gated OpenRouter claim supports_prompt_cache', () => { for (const r of listRecipes()) { if (!r.touchpoints.chat) continue; if (r.id === 'anthropic') { expect(r.touchpoints.chat.supports_prompt_cache).toBe(true); + } else if (r.id === 'openrouter') { + // #1987: per-model-family (anthropic/claude-* routes only). + expect(typeof r.touchpoints.chat.supports_prompt_cache).toBe('function'); } else { expect(r.touchpoints.chat.supports_prompt_cache ?? false).toBe(false); } diff --git a/test/ai/recipe-openrouter.test.ts b/test/ai/recipe-openrouter.test.ts index b2e40faff..a127a5d6f 100644 --- a/test/ai/recipe-openrouter.test.ts +++ b/test/ai/recipe-openrouter.test.ts @@ -11,6 +11,11 @@ import { describe, expect, test } from 'bun:test'; import { getRecipe } from '../../src/core/ai/recipes/index.ts'; +import { + openrouterSupportsPromptCache, + liftMessageCacheControl, + openrouterCacheControlFetch, +} from '../../src/core/ai/recipes/openrouter.ts'; import { defaultResolveAuth } from '../../src/core/ai/gateway.ts'; import { assertTouchpoint } from '../../src/core/ai/model-resolver.ts'; import { AIConfigError } from '../../src/core/ai/errors.ts'; @@ -135,4 +140,87 @@ describe('recipe: openrouter', () => { expect(r.setup_hint).toContain('OPENROUTER_REFERER'); expect(r.setup_hint).toContain('OPENROUTER_TITLE'); }); + + test('12. prompt cache capability is scoped to anthropic/claude-* routes (#1987)', () => { + expect(openrouterSupportsPromptCache('anthropic/claude-sonnet-4.6')).toBe(true); + expect(openrouterSupportsPromptCache('anthropic/claude-opus-4.7')).toBe(true); + expect(openrouterSupportsPromptCache('ANTHROPIC/Claude-Haiku-4.5')).toBe(true); // case-insensitive + expect(openrouterSupportsPromptCache('openai/gpt-5.2')).toBe(false); + expect(openrouterSupportsPromptCache('deepseek/deepseek-chat')).toBe(false); + expect(openrouterSupportsPromptCache('google/gemini-3-flash-preview')).toBe(false); + }); + + test('13. liftMessageCacheControl moves a message-level marker into the content part', () => { + const body: any = { + model: 'anthropic/claude-sonnet-4.6', + messages: [ + { role: 'system', content: 'SYS', cache_control: { type: 'ephemeral' } }, + { role: 'user', content: 'hello' }, + ], + }; + expect(liftMessageCacheControl(body)).toBe(true); + expect(body.messages[0]).toEqual({ + role: 'system', + content: [{ type: 'text', text: 'SYS', cache_control: { type: 'ephemeral' } }], + }); + // The user message (no marker) is untouched. + expect(body.messages[1]).toEqual({ role: 'user', content: 'hello' }); + }); + + test('14. liftMessageCacheControl is a no-op when no marker rides the body', () => { + const body = { + model: 'openai/gpt-5.2', + messages: [ + { role: 'system', content: 'SYS' }, + { role: 'user', content: 'hello' }, + ], + }; + expect(liftMessageCacheControl(body)).toBe(false); + expect(body.messages[0]).toEqual({ role: 'system', content: 'SYS' }); + expect(liftMessageCacheControl(undefined)).toBe(false); + expect(liftMessageCacheControl({ input: 'embedding body, no messages' })).toBe(false); + }); + + test('15. cache fetch shim rewrites the outbound body and recomputes content-length', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ init?: RequestInit }> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ init }); + return new Response('{}', { status: 200 }); + }) as typeof fetch; + try { + await openrouterCacheControlFetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + headers: { 'content-length': '999', 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'anthropic/claude-sonnet-4.6', + messages: [{ role: 'system', content: 'SYS', cache_control: { type: 'ephemeral' } }], + }), + }); + const rewritten = JSON.parse(calls[0].init!.body as string); + expect(rewritten.messages[0].content).toEqual([ + { type: 'text', text: 'SYS', cache_control: { type: 'ephemeral' } }, + ]); + expect(rewritten.messages[0].cache_control).toBeUndefined(); + expect(new Headers(calls[0].init!.headers).get('content-length')).toBeNull(); + + // Marker-free body passes through byte-identical (fail-open contract). + const plain = JSON.stringify({ model: 'openai/gpt-5.2', messages: [{ role: 'user', content: 'hi' }] }); + await openrouterCacheControlFetch('https://openrouter.ai/api/v1/chat/completions', { + method: 'POST', + body: plain, + }); + expect(calls[1].init!.body).toBe(plain); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test('16. cache shim is installed as compat.chatFetch (chat path only, embedding shim preserved)', () => { + const r = getRecipe('openrouter')!; + expect(r.compat?.chatFetch).toBe(openrouterCacheControlFetch); + // Deliberately NOT compat.fetch: that would displace the gateway's + // asymmetric input_type shim on the embedding path. + expect(r.compat?.fetch).toBeUndefined(); + }); });