mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
co-authored by
tmchow
Claude Fable 5
parent
81529ca25c
commit
2de17dd3ee
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user