Skip to content

Commit 6eccb78

Browse files
committed
feat(ai-openrouter): forward system-prompt cache_control breakpoints
The text adapter collapsed `systemPrompts` to a plain joined string and dropped the object-form `metadata`, so Anthropic-family prompt caching over OpenRouter was unreachable — `cache_control` never reached the wire. Declare `OpenRouterSystemPromptMetadata` (threaded through `BaseTextAdapter`'s `TSystemPromptMetadata`, so `cache_control` is typed/autocompleted at the `chat()` call site) and, when any system prompt carries `cache_control`, emit the system message as a content-array part carrying the directive — mirroring `@tanstack/ai-anthropic`. Without `cache_control` the system message is still the same joined string, so existing callers are unaffected.
1 parent 8fa6cc5 commit 6eccb78

5 files changed

Lines changed: 179 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai-openrouter': minor
3+
---
4+
5+
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.

packages/ai-openrouter/src/adapters/text.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { extractUsageCost } from './cost'
1515
import type { SDKOptions } from '@openrouter/sdk'
1616
import type {
1717
ChatContentItems,
18+
ChatContentText,
1819
ChatMessages,
1920
ChatRequest,
2021
ChatStreamChoice,
@@ -36,7 +37,10 @@ import type {
3637
OpenRouterModelInputModalitiesByName,
3738
OpenRouterModelOptionsByName,
3839
} from '../model-meta'
39-
import type { ExternalTextProviderOptions } from '../text/text-provider-options'
40+
import type {
41+
ExternalTextProviderOptions,
42+
OpenRouterSystemPromptMetadata,
43+
} from '../text/text-provider-options'
4044
import type {
4145
OpenRouterImageMetadata,
4246
OpenRouterMessageMetadataByModality,
@@ -94,7 +98,12 @@ export class OpenRouterTextAdapter<
9498
ResolveProviderOptions<TModel>,
9599
ResolveInputModalities<TModel>,
96100
OpenRouterMessageMetadataByModality,
97-
TToolCapabilities
101+
TToolCapabilities,
102+
// TToolCallMetadata — OpenRouter has no tool-call metadata round-tripping.
103+
unknown,
104+
// TSystemPromptMetadata — narrows `systemPrompts[i].metadata` at the chat()
105+
// call site so users get `cache_control` autocomplete.
106+
OpenRouterSystemPromptMetadata
98107
> {
99108
override readonly kind = 'text' as const
100109
readonly name = 'openrouter' as const
@@ -1141,11 +1150,31 @@ export class OpenRouterTextAdapter<
11411150
const variantSuffix = variant ? `:${variant}` : ''
11421151

11431152
const messages: Array<ChatMessages> = []
1144-
const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
1153+
const systemPrompts =
1154+
normalizeSystemPrompts<OpenRouterSystemPromptMetadata>(
1155+
options.systemPrompts,
1156+
)
11451157
if (systemPrompts.length > 0) {
1158+
// When any system prompt carries a `cache_control` breakpoint, emit the
1159+
// system message as a structured content array so the directive rides on
1160+
// the wire (honoured by Anthropic-family routes). Otherwise keep the
1161+
// plain joined string — unchanged behaviour for every other caller.
1162+
const hasCacheControl = systemPrompts.some(
1163+
(p) => p.metadata?.cache_control,
1164+
)
11461165
messages.push({
11471166
role: 'system',
1148-
content: systemPrompts.map((p) => p.content).join('\n'),
1167+
content: hasCacheControl
1168+
? systemPrompts.map(
1169+
(p): ChatContentText => ({
1170+
type: 'text',
1171+
text: p.content,
1172+
...(p.metadata?.cache_control && {
1173+
cacheControl: p.metadata.cache_control,
1174+
}),
1175+
}),
1176+
)
1177+
: systemPrompts.map((p) => p.content).join('\n'),
11491178
})
11501179
}
11511180
for (const m of options.messages) {

packages/ai-openrouter/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export type {
7070
ReasoningOptions,
7171
StreamOptions,
7272
ImageConfig,
73+
OpenRouterSystemPromptMetadata,
7374
} from './text/text-provider-options'
7475

7576
// ============================================================================

packages/ai-openrouter/src/text/text-provider-options.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { ChatRequest } from '@openrouter/sdk/models'
1+
import type {
2+
ChatContentCacheControl,
3+
ChatRequest,
4+
} from '@openrouter/sdk/models'
25
import type { OPENROUTER_CHAT_MODELS } from '../model-meta'
36

47
type OpenRouterChatModel = (typeof OPENROUTER_CHAT_MODELS)[number]
@@ -88,3 +91,36 @@ export type OpenRouterBaseOptions = Pick<
8891

8992
export type ExternalTextProviderOptions = OpenRouterCommonOptions &
9093
OpenRouterBaseOptions
94+
95+
/**
96+
* Per-system-prompt metadata accepted on each `chat({ systemPrompts: [...] })`
97+
* entry (the `{ content, metadata }` object form).
98+
*
99+
* The only field is `cache_control`, OpenRouter's pass-through prompt-cache
100+
* directive. It is honoured by Anthropic-family models routed through
101+
* OpenRouter (the equivalent of calling Anthropic directly with a
102+
* `cache_control` breakpoint) and ignored by routes that don't support it —
103+
* OpenAI models, for instance, cache long prefixes automatically with no
104+
* request-side directive. The adapter forwards it onto the system message's
105+
* text content part on the wire; without it, the system prompt is sent as a
106+
* plain joined string exactly as before.
107+
*
108+
* @example
109+
* import type { OpenRouterSystemPromptMetadata } from '@tanstack/ai-openrouter'
110+
*
111+
* chat({
112+
* adapter: openRouterText('anthropic/claude-sonnet-4.5'),
113+
* systemPrompts: [
114+
* {
115+
* content: 'Large, stable instructions — cache me.',
116+
* metadata: {
117+
* cache_control: { type: 'ephemeral' },
118+
* } satisfies OpenRouterSystemPromptMetadata,
119+
* },
120+
* 'Volatile per-request instruction.',
121+
* ],
122+
* })
123+
*/
124+
export type OpenRouterSystemPromptMetadata = {
125+
cache_control?: ChatContentCacheControl
126+
}

packages/ai-openrouter/tests/openrouter-adapter.test.ts

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,11 @@ describe('OpenRouter adapter option mapping', () => {
199199
systemPrompts: [
200200
'plain',
201201
{ content: 'object-form' },
202-
// `metadata` is `never` for OpenRouter at the type level; the cast
203-
// simulates a stale JS / `as any` caller. The adapter must still
204-
// produce the joined system message and never leak the foreign
205-
// field to the wire.
202+
// A foreign metadata field (anything other than `cache_control`) must
203+
// still be dropped and the system message kept as a plain joined
204+
// string. The cast simulates a stale JS / `as any` caller.
206205
// eslint-disable-next-line @typescript-eslint/no-explicit-any
207-
{ content: 'with-meta', metadata: { cache_control: {} } } as any,
206+
{ content: 'with-meta', metadata: { foreign: 'x' } } as any,
208207
],
209208
})) {
210209
/* consume */
@@ -221,7 +220,105 @@ describe('OpenRouter adapter option mapping', () => {
221220
content: 'plain\nobject-form\nwith-meta',
222221
})
223222
expect(messages[1]).toMatchObject({ role: 'user' })
224-
expect(JSON.stringify(params)).not.toContain('cache_control')
223+
expect(JSON.stringify(params)).not.toContain('foreign')
224+
})
225+
226+
it('forwards a system-prompt cache_control breakpoint as a content-array part', async () => {
227+
setupMockSdkClient([
228+
{
229+
id: 'chatcmpl-cache',
230+
model: 'anthropic/claude-sonnet-4.5',
231+
choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }],
232+
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
233+
},
234+
])
235+
236+
const adapter = createOpenRouterText('anthropic/claude-sonnet-4.5', 'k')
237+
238+
for await (const _ of chat({
239+
adapter,
240+
messages: [{ role: 'user', content: 'hi' }],
241+
systemPrompts: [
242+
{
243+
content: 'Stable cached instructions.',
244+
metadata: { cache_control: { type: 'ephemeral' } },
245+
},
246+
],
247+
})) {
248+
/* consume */
249+
}
250+
251+
const [rawParams] = mockSend.mock.calls[0]!
252+
const params = rawParams.chatRequest
253+
254+
// The system message becomes a content array carrying the directive
255+
// (camelCase pre-serialization).
256+
expect(params.messages[0]).toEqual({
257+
role: 'system',
258+
content: [
259+
{
260+
type: 'text',
261+
text: 'Stable cached instructions.',
262+
cacheControl: { type: 'ephemeral' },
263+
},
264+
],
265+
})
266+
267+
// And it survives the SDK's outbound (snake_case wire) serialization.
268+
const serialized = ChatRequest$outboundSchema.parse(params)
269+
const wireSystem = (serialized.messages as Array<Record<string, unknown>>)[0]
270+
expect(wireSystem).toEqual({
271+
role: 'system',
272+
content: [
273+
{
274+
type: 'text',
275+
text: 'Stable cached instructions.',
276+
cache_control: { type: 'ephemeral' },
277+
},
278+
],
279+
})
280+
})
281+
282+
it('puts the cache_control breakpoint only on the prompt that declared it; others are plain text parts in the same array', async () => {
283+
setupMockSdkClient([
284+
{
285+
id: 'chatcmpl-mixed',
286+
model: 'anthropic/claude-sonnet-4.5',
287+
choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }],
288+
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
289+
},
290+
])
291+
292+
const adapter = createOpenRouterText('anthropic/claude-sonnet-4.5', 'k')
293+
294+
for await (const _ of chat({
295+
adapter,
296+
messages: [{ role: 'user', content: 'hi' }],
297+
systemPrompts: [
298+
{
299+
content: 'Cached prefix.',
300+
metadata: { cache_control: { type: 'ephemeral' } },
301+
},
302+
'Volatile suffix.',
303+
],
304+
})) {
305+
/* consume */
306+
}
307+
308+
const [rawParams] = mockSend.mock.calls[0]!
309+
const params = rawParams.chatRequest
310+
311+
expect(params.messages[0]).toEqual({
312+
role: 'system',
313+
content: [
314+
{
315+
type: 'text',
316+
text: 'Cached prefix.',
317+
cacheControl: { type: 'ephemeral' },
318+
},
319+
{ type: 'text', text: 'Volatile suffix.' },
320+
],
321+
})
225322
})
226323

227324
it('streams chat chunks with content and usage', async () => {

0 commit comments

Comments
 (0)