feat(ai): prompt caching for OpenRouter Anthropic routes (#1987)

Rework of #1988 against the post-#2981 cache-breakpoint placement.

- ChatTouchpoint.supports_prompt_cache may now be function-valued
  (per-model-id); OpenRouter scopes it to anthropic/claude-* routes, so
  those stop classifying as degraded:no_caching. capabilities.ts +
  gateway's supportsCache gate handle both forms (fail-closed).
- The system-block cache breakpoint additionally rides
  providerOptions.openaiCompatible on openai-compatible recipes (the
  anthropic key never reaches the compat wire); a new chat-scoped
  recipe fetch shim (compat.chatFetch) lifts the marker into
  OpenRouter's documented content-part cache_control shape before the
  request leaves the process. chatFetch deliberately does NOT displace
  the embedding path's asymmetric input_type shim the way compat.fetch
  would.

Co-authored-by: tmchow <tmchow@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-21 14:53:41 -07:00
co-authored by tmchow Claude Fable 5
parent 81529ca25c
commit 2de17dd3ee
8 changed files with 292 additions and 10 deletions
+4 -1
View File
@@ -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.
+38 -4
View File
@@ -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<Provider>({ 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<ChatResult> {
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<ChatResult> {
? {
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;
+73 -1
View File
@@ -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<string, unknown>).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<string, unknown>;
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<Response> => {
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:<provider>/<model>`. Optional overrides: OPENROUTER_BASE_URL (proxy), OPENROUTER_REFERER (attribution URL), OPENROUTER_TITLE (attribution name).',
};
+16 -2
View File
@@ -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
+16
View File
@@ -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');
});
+53 -1
View File
@@ -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<Parameters<typeof chat>[0]> = {},
): Promise<any> {
@@ -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<any> {
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();
});
});
+4 -1
View File
@@ -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);
}
+88
View File
@@ -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();
});
});