Skip to content

Commit 05b2907

Browse files
fix: always use array content for system messages with block-level cache control (OpenRouterTeam#399)
* fix: convert system message to array content with block-level cache control When cache control is specified on a system message via providerOptions, the content is now converted to array format with cache_control on the text block, matching the existing behavior for user messages. This ensures consistent Anthropic prompt caching behavior across all message types. Fixes OpenRouterTeam#389 Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai> * refactor: always use array format for system messages, no cache_control duplication - System message content is always array format (no string union) - Removed message-level cache_control from ChatCompletionSystemMessageParam - cache_control only appears on content blocks, never at message level - Addresses PR review comments from louisgv Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai> * chore: update changeset to minor Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent bb58e3d commit 05b2907

File tree

5 files changed

+203
-9
lines changed

5 files changed

+203
-9
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@openrouter/ai-sdk-provider": minor
3+
---
4+
5+
Fix system message cache control to use block-level format
6+
7+
When cache control is specified on a system message via `providerOptions`, the content is now converted to array format with `cache_control` on the text block, matching the existing behavior for user messages. This ensures consistent Anthropic prompt caching behavior across all message types.
8+
9+
Before (message-level cache_control):
10+
```json
11+
{ "role": "system", "content": "...", "cache_control": { "type": "ephemeral" } }
12+
```
13+
14+
After (block-level cache_control):
15+
```json
16+
{ "role": "system", "content": [{ "type": "text", "text": "...", "cache_control": { "type": "ephemeral" } }] }
17+
```
18+
19+
Fixes #389
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Regression test for GitHub issue #389
3+
* https://github.com/OpenRouterTeam/ai-sdk-provider/issues/389
4+
*
5+
* Issue: "Anthropic prompt caching not applied when `system` is a string
6+
* in AI SDK (`ModelMessage[]`); only block content works"
7+
*
8+
* The user reported that prompt caching does not work when a system message
9+
* is provided as a plain string with cache_control at the message level via
10+
* providerOptions. Caching only worked when content was an array of text
11+
* blocks with cache_control on each block.
12+
*
13+
* The fix converts system message content to array format with block-level
14+
* cache_control when cache control is present, matching the existing behavior
15+
* for user messages.
16+
*/
17+
import { generateText } from 'ai';
18+
import { describe, expect, it, vi } from 'vitest';
19+
import { createOpenRouter } from '@/src';
20+
21+
vi.setConfig({
22+
testTimeout: 120_000,
23+
});
24+
25+
describe('Issue #389: System message cache control with string content', () => {
26+
const openrouter = createOpenRouter({
27+
apiKey: process.env.OPENROUTER_API_KEY,
28+
baseUrl: `${process.env.OPENROUTER_API_BASE}/api/v1`,
29+
});
30+
31+
const model = openrouter('anthropic/claude-sonnet-4');
32+
33+
const longSystemPrompt = `You are a helpful assistant. Here is some context that should be cached:
34+
35+
${Array(50)
36+
.fill(
37+
'This is padding text to ensure the prompt meets the minimum token threshold for Anthropic prompt caching. ' +
38+
'Prompt caching requires a minimum number of tokens in the prompt prefix. ' +
39+
'This text is repeated multiple times to reach that threshold. ',
40+
)
41+
.join('\n')}
42+
43+
Remember to be helpful and concise in your responses.`;
44+
45+
it('should trigger cache write on first request with system message cache control', async () => {
46+
const response = await generateText({
47+
model,
48+
messages: [
49+
{
50+
role: 'system',
51+
content: longSystemPrompt,
52+
providerOptions: {
53+
anthropic: {
54+
cacheControl: { type: 'ephemeral' },
55+
},
56+
},
57+
},
58+
{
59+
role: 'user',
60+
content: 'What is 2+2? Answer with just the number.',
61+
},
62+
],
63+
});
64+
65+
expect(response.text).toBeDefined();
66+
expect(response.text.length).toBeGreaterThan(0);
67+
expect(response.finishReason).toBeDefined();
68+
});
69+
70+
it('should trigger cache read on second request with system message cache control', async () => {
71+
const makeRequest = () =>
72+
generateText({
73+
model,
74+
messages: [
75+
{
76+
role: 'system',
77+
content: longSystemPrompt,
78+
providerOptions: {
79+
anthropic: {
80+
cacheControl: { type: 'ephemeral' },
81+
},
82+
},
83+
},
84+
{
85+
role: 'user',
86+
content: 'What is 2+2? Answer with just the number.',
87+
},
88+
],
89+
});
90+
91+
await makeRequest();
92+
93+
const response = await makeRequest();
94+
95+
const openrouterMetadata = response.providerMetadata?.openrouter as {
96+
usage?: {
97+
promptTokensDetails?: { cachedTokens?: number };
98+
};
99+
};
100+
101+
const cachedTokens =
102+
openrouterMetadata?.usage?.promptTokensDetails?.cachedTokens;
103+
104+
expect(cachedTokens).toBeDefined();
105+
expect(cachedTokens).toBeGreaterThan(0);
106+
});
107+
});

src/chat/convert-to-openrouter-chat-messages.test.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ describe('user messages', () => {
232232
});
233233

234234
describe('cache control', () => {
235-
it('should pass cache control from system message provider metadata', () => {
235+
it('should convert system message to array content with block-level cache control', () => {
236236
const result = convertToOpenRouterChatMessages([
237237
{
238238
role: 'system',
@@ -246,10 +246,63 @@ describe('cache control', () => {
246246
]);
247247

248248
expect(result).toEqual([
249+
{
250+
role: 'system',
251+
content: [
252+
{
253+
type: 'text',
254+
text: 'System prompt',
255+
cache_control: { type: 'ephemeral' },
256+
},
257+
],
258+
},
259+
]);
260+
});
261+
262+
it('should convert system message to array content even without cache control', () => {
263+
const result = convertToOpenRouterChatMessages([
249264
{
250265
role: 'system',
251266
content: 'System prompt',
252-
cache_control: { type: 'ephemeral' },
267+
},
268+
]);
269+
270+
expect(result).toEqual([
271+
{
272+
role: 'system',
273+
content: [
274+
{
275+
type: 'text',
276+
text: 'System prompt',
277+
},
278+
],
279+
},
280+
]);
281+
});
282+
283+
it('should convert system message to array content with openrouter namespace cache control', () => {
284+
const result = convertToOpenRouterChatMessages([
285+
{
286+
role: 'system',
287+
content: 'System prompt',
288+
providerOptions: {
289+
openrouter: {
290+
cacheControl: { type: 'ephemeral' },
291+
},
292+
},
293+
},
294+
]);
295+
296+
expect(result).toEqual([
297+
{
298+
role: 'system',
299+
content: [
300+
{
301+
type: 'text',
302+
text: 'System prompt',
303+
cache_control: { type: 'ephemeral' },
304+
},
305+
],
253306
},
254307
]);
255308
});
@@ -677,8 +730,13 @@ describe('cache control', () => {
677730
expect(result).toEqual([
678731
{
679732
role: 'system',
680-
content: 'System prompt',
681-
cache_control: { type: 'ephemeral' },
733+
content: [
734+
{
735+
type: 'text',
736+
text: 'System prompt',
737+
cache_control: { type: 'ephemeral' },
738+
},
739+
],
682740
},
683741
]);
684742
});
@@ -707,7 +765,12 @@ describe('cache control', () => {
707765
expect(result).toEqual([
708766
{
709767
role: 'system',
710-
content: 'System prompt',
768+
content: [
769+
{
770+
type: 'text',
771+
text: 'System prompt',
772+
},
773+
],
711774
},
712775
{
713776
role: 'user',

src/chat/convert-to-openrouter-chat-messages.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,16 @@ export function convertToOpenRouterChatMessages(
4646
for (const { role, content, providerOptions } of prompt) {
4747
switch (role) {
4848
case 'system': {
49+
const cacheControl = getCacheControl(providerOptions);
4950
messages.push({
5051
role: 'system',
51-
content,
52-
cache_control: getCacheControl(providerOptions),
52+
content: [
53+
{
54+
type: 'text' as const,
55+
text: content,
56+
...(cacheControl && { cache_control: cacheControl }),
57+
},
58+
],
5359
});
5460
break;
5561
}

src/types/openrouter-chat-completions-input.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ export type ChatCompletionMessageParam =
1414

1515
export interface ChatCompletionSystemMessageParam {
1616
role: 'system';
17-
content: string;
18-
cache_control?: OpenRouterCacheControl;
17+
content: Array<ChatCompletionContentPartText>;
1918
}
2019

2120
export interface ChatCompletionUserMessageParam {

0 commit comments

Comments
 (0)