Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/openrouter-system-prompt-cache-control.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 33 additions & 4 deletions packages/ai-openrouter/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { extractUsageCost } from './cost'
import type { SDKOptions } from '@openrouter/sdk'
import type {
ChatContentItems,
ChatContentText,
ChatMessages,
ChatRequest,
ChatStreamChoice,
Expand All @@ -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,
Expand Down Expand Up @@ -94,7 +98,12 @@ export class OpenRouterTextAdapter<
ResolveProviderOptions<TModel>,
ResolveInputModalities<TModel>,
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
Expand Down Expand Up @@ -1140,11 +1149,31 @@ export class OpenRouterTextAdapter<
const variantSuffix = variant ? `:${variant}` : ''

const messages: Array<ChatMessages> = []
const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
const systemPrompts =
normalizeSystemPrompts<OpenRouterSystemPromptMetadata>(
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) {
Expand Down
1 change: 1 addition & 0 deletions packages/ai-openrouter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export type {
ReasoningOptions,
StreamOptions,
ImageConfig,
OpenRouterSystemPromptMetadata,
} from './text/text-provider-options'

// ============================================================================
Expand Down
38 changes: 37 additions & 1 deletion packages/ai-openrouter/src/text/text-provider-options.ts
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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
}
111 changes: 105 additions & 6 deletions packages/ai-openrouter/tests/openrouter-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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<Record<string, unknown>>
)[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 () => {
Expand Down