diff --git a/.changeset/openrouter-system-prompt-cache-control.md b/.changeset/openrouter-system-prompt-cache-control.md new file mode 100644 index 000000000..4bbc4076c --- /dev/null +++ b/.changeset/openrouter-system-prompt-cache-control.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-openrouter': minor +--- + +Forward per-system-prompt `cache_control` breakpoints to the wire. The text adapter previously collapsed `systemPrompts` to a plain joined string and dropped the object-form `metadata`, so Anthropic-family prompt caching over OpenRouter was unreachable. It now declares `OpenRouterSystemPromptMetadata` (narrowing `systemPrompts[i].metadata` so `cache_control` is typed and autocompleted at the `chat()` call site) and, when any system prompt carries `cache_control`, emits the system message as a content-array part carrying the directive — mirroring `@tanstack/ai-anthropic`. Callers without `cache_control` are unaffected: the system message is still sent as the same joined string. diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts index 2150f8cc9..09df05b35 100644 --- a/packages/ai-openrouter/src/adapters/text.ts +++ b/packages/ai-openrouter/src/adapters/text.ts @@ -15,6 +15,7 @@ import { extractUsageCost } from './cost' import type { SDKOptions } from '@openrouter/sdk' import type { ChatContentItems, + ChatContentText, ChatMessages, ChatRequest, ChatStreamChoice, @@ -36,7 +37,10 @@ import type { OpenRouterModelInputModalitiesByName, OpenRouterModelOptionsByName, } from '../model-meta' -import type { ExternalTextProviderOptions } from '../text/text-provider-options' +import type { + ExternalTextProviderOptions, + OpenRouterSystemPromptMetadata, +} from '../text/text-provider-options' import type { OpenRouterImageMetadata, OpenRouterMessageMetadataByModality, @@ -94,7 +98,12 @@ export class OpenRouterTextAdapter< ResolveProviderOptions, ResolveInputModalities, OpenRouterMessageMetadataByModality, - TToolCapabilities + TToolCapabilities, + // TToolCallMetadata — OpenRouter has no tool-call metadata round-tripping. + unknown, + // TSystemPromptMetadata — narrows `systemPrompts[i].metadata` at the chat() + // call site so users get `cache_control` autocomplete. + OpenRouterSystemPromptMetadata > { override readonly kind = 'text' as const readonly name = 'openrouter' as const @@ -1140,11 +1149,31 @@ export class OpenRouterTextAdapter< const variantSuffix = variant ? `:${variant}` : '' const messages: Array = [] - const systemPrompts = normalizeSystemPrompts(options.systemPrompts) + const systemPrompts = + normalizeSystemPrompts( + options.systemPrompts, + ) if (systemPrompts.length > 0) { + // When any system prompt carries a `cache_control` breakpoint, emit the + // system message as a structured content array so the directive rides on + // the wire (honoured by Anthropic-family routes). Otherwise keep the + // plain joined string — unchanged behaviour for every other caller. + const hasCacheControl = systemPrompts.some( + (p) => p.metadata?.cache_control, + ) messages.push({ role: 'system', - content: systemPrompts.map((p) => p.content).join('\n'), + content: hasCacheControl + ? systemPrompts.map( + (p): ChatContentText => ({ + type: 'text', + text: p.content, + ...(p.metadata?.cache_control && { + cacheControl: p.metadata.cache_control, + }), + }), + ) + : systemPrompts.map((p) => p.content).join('\n'), }) } for (const m of options.messages) { diff --git a/packages/ai-openrouter/src/index.ts b/packages/ai-openrouter/src/index.ts index aeebd5c64..e55f9a9c7 100644 --- a/packages/ai-openrouter/src/index.ts +++ b/packages/ai-openrouter/src/index.ts @@ -70,6 +70,7 @@ export type { ReasoningOptions, StreamOptions, ImageConfig, + OpenRouterSystemPromptMetadata, } from './text/text-provider-options' // ============================================================================ diff --git a/packages/ai-openrouter/src/text/text-provider-options.ts b/packages/ai-openrouter/src/text/text-provider-options.ts index c2158baad..b5989e2d8 100644 --- a/packages/ai-openrouter/src/text/text-provider-options.ts +++ b/packages/ai-openrouter/src/text/text-provider-options.ts @@ -1,4 +1,7 @@ -import type { ChatRequest } from '@openrouter/sdk/models' +import type { + ChatContentCacheControl, + ChatRequest, +} from '@openrouter/sdk/models' import type { OPENROUTER_CHAT_MODELS } from '../model-meta' type OpenRouterChatModel = (typeof OPENROUTER_CHAT_MODELS)[number] @@ -88,3 +91,36 @@ export type OpenRouterBaseOptions = Pick< export type ExternalTextProviderOptions = OpenRouterCommonOptions & OpenRouterBaseOptions + +/** + * Per-system-prompt metadata accepted on each `chat({ systemPrompts: [...] })` + * entry (the `{ content, metadata }` object form). + * + * The only field is `cache_control`, OpenRouter's pass-through prompt-cache + * directive. It is honoured by Anthropic-family models routed through + * OpenRouter (the equivalent of calling Anthropic directly with a + * `cache_control` breakpoint) and ignored by routes that don't support it — + * OpenAI models, for instance, cache long prefixes automatically with no + * request-side directive. The adapter forwards it onto the system message's + * text content part on the wire; without it, the system prompt is sent as a + * plain joined string exactly as before. + * + * @example + * import type { OpenRouterSystemPromptMetadata } from '@tanstack/ai-openrouter' + * + * chat({ + * adapter: openRouterText('anthropic/claude-sonnet-4.5'), + * systemPrompts: [ + * { + * content: 'Large, stable instructions — cache me.', + * metadata: { + * cache_control: { type: 'ephemeral' }, + * } satisfies OpenRouterSystemPromptMetadata, + * }, + * 'Volatile per-request instruction.', + * ], + * }) + */ +export type OpenRouterSystemPromptMetadata = { + cache_control?: ChatContentCacheControl +} diff --git a/packages/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/ai-openrouter/tests/openrouter-adapter.test.ts index bb43e0ada..558e39518 100644 --- a/packages/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/ai-openrouter/tests/openrouter-adapter.test.ts @@ -199,12 +199,11 @@ describe('OpenRouter adapter option mapping', () => { systemPrompts: [ 'plain', { content: 'object-form' }, - // `metadata` is `never` for OpenRouter at the type level; the cast - // simulates a stale JS / `as any` caller. The adapter must still - // produce the joined system message and never leak the foreign - // field to the wire. + // A foreign metadata field (anything other than `cache_control`) must + // still be dropped and the system message kept as a plain joined + // string. The cast simulates a stale JS / `as any` caller. // eslint-disable-next-line @typescript-eslint/no-explicit-any - { content: 'with-meta', metadata: { cache_control: {} } } as any, + { content: 'with-meta', metadata: { foreign: 'x' } } as any, ], })) { /* consume */ @@ -221,7 +220,107 @@ describe('OpenRouter adapter option mapping', () => { content: 'plain\nobject-form\nwith-meta', }) expect(messages[1]).toMatchObject({ role: 'user' }) - expect(JSON.stringify(params)).not.toContain('cache_control') + expect(JSON.stringify(params)).not.toContain('foreign') + }) + + it('forwards a system-prompt cache_control breakpoint as a content-array part', async () => { + setupMockSdkClient([ + { + id: 'chatcmpl-cache', + model: 'anthropic/claude-sonnet-4.5', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }, + ]) + + const adapter = createOpenRouterText('anthropic/claude-sonnet-4.5', 'k') + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + systemPrompts: [ + { + content: 'Stable cached instructions.', + metadata: { cache_control: { type: 'ephemeral' } }, + }, + ], + })) { + /* consume */ + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + + // The system message becomes a content array carrying the directive + // (camelCase pre-serialization). + expect(params.messages[0]).toEqual({ + role: 'system', + content: [ + { + type: 'text', + text: 'Stable cached instructions.', + cacheControl: { type: 'ephemeral' }, + }, + ], + }) + + // And it survives the SDK's outbound (snake_case wire) serialization. + const serialized = ChatRequest$outboundSchema.parse(params) + const wireSystem = ( + serialized.messages as Array> + )[0] + expect(wireSystem).toEqual({ + role: 'system', + content: [ + { + type: 'text', + text: 'Stable cached instructions.', + cache_control: { type: 'ephemeral' }, + }, + ], + }) + }) + + it('puts the cache_control breakpoint only on the prompt that declared it; others are plain text parts in the same array', async () => { + setupMockSdkClient([ + { + id: 'chatcmpl-mixed', + model: 'anthropic/claude-sonnet-4.5', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }, + ]) + + const adapter = createOpenRouterText('anthropic/claude-sonnet-4.5', 'k') + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + systemPrompts: [ + { + content: 'Cached prefix.', + metadata: { cache_control: { type: 'ephemeral' } }, + }, + 'Volatile suffix.', + ], + })) { + /* consume */ + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + + expect(params.messages[0]).toEqual({ + role: 'system', + content: [ + { + type: 'text', + text: 'Cached prefix.', + cacheControl: { type: 'ephemeral' }, + }, + { type: 'text', text: 'Volatile suffix.' }, + ], + }) }) it('streams chat chunks with content and usage', async () => {