@librechat/agents 3.1.81 → 3.1.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +102 -35
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +13 -0
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/llm/openai/index.cjs +50 -13
  6. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  7. package/dist/cjs/llm/openrouter/index.cjs +17 -7
  8. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  9. package/dist/cjs/llm/openrouter/toolCache.cjs +55 -0
  10. package/dist/cjs/llm/openrouter/toolCache.cjs.map +1 -0
  11. package/dist/cjs/tools/ToolNode.cjs +70 -12
  12. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  13. package/dist/esm/agents/AgentContext.mjs +101 -34
  14. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  15. package/dist/esm/graphs/Graph.mjs +13 -0
  16. package/dist/esm/graphs/Graph.mjs.map +1 -1
  17. package/dist/esm/llm/openai/index.mjs +50 -14
  18. package/dist/esm/llm/openai/index.mjs.map +1 -1
  19. package/dist/esm/llm/openrouter/index.mjs +17 -7
  20. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  21. package/dist/esm/llm/openrouter/toolCache.mjs +53 -0
  22. package/dist/esm/llm/openrouter/toolCache.mjs.map +1 -0
  23. package/dist/esm/tools/ToolNode.mjs +70 -12
  24. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  25. package/dist/types/agents/AgentContext.d.ts +6 -1
  26. package/dist/types/llm/openrouter/index.d.ts +1 -0
  27. package/dist/types/llm/openrouter/toolCache.d.ts +2 -0
  28. package/dist/types/tools/ToolNode.d.ts +5 -0
  29. package/dist/types/types/run.d.ts +2 -0
  30. package/package.json +2 -1
  31. package/src/agents/AgentContext.ts +146 -38
  32. package/src/agents/__tests__/AgentContext.test.ts +198 -0
  33. package/src/graphs/Graph.ts +24 -0
  34. package/src/llm/custom-chat-models.smoke.test.ts +76 -0
  35. package/src/llm/openai/deepseek.test.ts +14 -1
  36. package/src/llm/openai/index.ts +38 -12
  37. package/src/llm/openrouter/index.ts +22 -7
  38. package/src/llm/openrouter/reasoning.test.ts +33 -0
  39. package/src/llm/openrouter/toolCache.test.ts +83 -0
  40. package/src/llm/openrouter/toolCache.ts +89 -0
  41. package/src/messages/cache.test.ts +127 -0
  42. package/src/scripts/openrouter_prompt_cache_live.ts +310 -0
  43. package/src/specs/agent-handoffs.live.test.ts +140 -0
  44. package/src/specs/agent-handoffs.test.ts +266 -2
  45. package/src/specs/openrouter.simple.test.ts +15 -8
  46. package/src/tools/ToolNode.ts +92 -13
  47. package/src/types/run.ts +2 -0
@@ -120,6 +120,17 @@ type OpenRouterReasoningStreamChoice = Omit<
120
120
  > & {
121
121
  delta: OpenRouterReasoningStreamDelta;
122
122
  };
123
+ type PromptTokensDetailsWithCacheWrite = NonNullable<
124
+ OpenAIClient.Completions.CompletionUsage['prompt_tokens_details']
125
+ > & {
126
+ cache_write_tokens?: number;
127
+ };
128
+ type CompletionUsageWithCacheWrite = Omit<
129
+ OpenAIClient.Completions.CompletionUsage,
130
+ 'prompt_tokens_details'
131
+ > & {
132
+ prompt_tokens_details?: PromptTokensDetailsWithCacheWrite;
133
+ };
123
134
  type OpenAIStreamModel = ChatOpenAI | AzureChatOpenAI;
124
135
 
125
136
  const baseAzureFields = {
@@ -654,6 +665,71 @@ describe('custom chat model class smoke tests', () => {
654
665
  ]);
655
666
  });
656
667
 
668
+ it('maps OpenRouter cache write usage to cache_creation in streaming responses', async () => {
669
+ const model = new ChatOpenRouter({
670
+ model: 'anthropic/claude-sonnet-test',
671
+ apiKey: 'test-key',
672
+ streamUsage: true,
673
+ });
674
+ const completions = (model as unknown as StreamingCompletionBackedModel)
675
+ .completions;
676
+ const usage: CompletionUsageWithCacheWrite = {
677
+ prompt_tokens: 11,
678
+ completion_tokens: 7,
679
+ total_tokens: 18,
680
+ prompt_tokens_details: {
681
+ audio_tokens: 2,
682
+ cached_tokens: 3,
683
+ cache_write_tokens: 5,
684
+ },
685
+ completion_tokens_details: {
686
+ audio_tokens: 4,
687
+ reasoning_tokens: 6,
688
+ },
689
+ };
690
+
691
+ async function* streamChunks(): AsyncGenerator<OpenAIClient.Chat.Completions.ChatCompletionChunk> {
692
+ yield createOpenAIStreamChunk('answer', 'stop');
693
+ yield {
694
+ id: 'chatcmpl-openrouter-usage',
695
+ object: 'chat.completion.chunk',
696
+ created: 0,
697
+ model: 'anthropic/claude-sonnet-test',
698
+ choices: [],
699
+ usage,
700
+ } as OpenAIClient.Chat.Completions.ChatCompletionChunk;
701
+ }
702
+
703
+ completions.completionWithRetry = async (): Promise<
704
+ AsyncIterable<OpenAIClient.Chat.Completions.ChatCompletionChunk>
705
+ > => streamChunks();
706
+
707
+ const chunks: AIMessageChunk[] = [];
708
+ const stream = await model.stream([new HumanMessage('hi')]);
709
+ for await (const chunk of stream) {
710
+ chunks.push(chunk);
711
+ }
712
+
713
+ const usageChunk = chunks.find(
714
+ (chunk) =>
715
+ chunk.usage_metadata?.input_token_details?.cache_creation === 5
716
+ );
717
+ expect(usageChunk?.usage_metadata).toEqual({
718
+ input_tokens: 11,
719
+ output_tokens: 7,
720
+ total_tokens: 18,
721
+ input_token_details: {
722
+ audio: 2,
723
+ cache_read: 3,
724
+ cache_creation: 5,
725
+ },
726
+ output_token_details: {
727
+ audio: 4,
728
+ reasoning: 6,
729
+ },
730
+ });
731
+ });
732
+
657
733
  it('keeps Anthropic output, residency, compaction, and stream-delay options', () => {
658
734
  const contextManagement = {
659
735
  edits: [
@@ -11,6 +11,17 @@ type DeepSeekRequest =
11
11
  type OpenAIChatCompletion = OpenAIClient.Chat.Completions.ChatCompletion;
12
12
  type OpenAIChatCompletionChunk =
13
13
  OpenAIClient.Chat.Completions.ChatCompletionChunk;
14
+ type PromptTokensDetailsWithCacheWrite = NonNullable<
15
+ OpenAIClient.Completions.CompletionUsage['prompt_tokens_details']
16
+ > & {
17
+ cache_write_tokens?: number;
18
+ };
19
+ type CompletionUsageWithCacheWrite = Omit<
20
+ OpenAIClient.Completions.CompletionUsage,
21
+ 'prompt_tokens_details'
22
+ > & {
23
+ prompt_tokens_details?: PromptTokensDetailsWithCacheWrite;
24
+ };
14
25
  type ReasoningAssistantMessageParam =
15
26
  OpenAIClient.Chat.Completions.ChatCompletionAssistantMessageParam & {
16
27
  reasoning_content?: string;
@@ -129,7 +140,7 @@ async function* createCompletionStream(
129
140
  }
130
141
 
131
142
  function createCompletion(
132
- usage: OpenAIClient.Completions.CompletionUsage = {
143
+ usage: CompletionUsageWithCacheWrite = {
133
144
  prompt_tokens: 1,
134
145
  completion_tokens: 1,
135
146
  total_tokens: 2,
@@ -392,6 +403,7 @@ describe('ChatDeepSeek', () => {
392
403
  prompt_tokens_details: {
393
404
  audio_tokens: 2,
394
405
  cached_tokens: 3,
406
+ cache_write_tokens: 6,
395
407
  },
396
408
  completion_tokens_details: {
397
409
  audio_tokens: 4,
@@ -409,6 +421,7 @@ describe('ChatDeepSeek', () => {
409
421
  input_token_details: {
410
422
  audio: 2,
411
423
  cache_read: 3,
424
+ cache_creation: 6,
412
425
  },
413
426
  output_token_details: {
414
427
  audio: 4,
@@ -134,6 +134,11 @@ type OpenAIChatCompletionRequest =
134
134
  type OpenAIChatCompletionResult =
135
135
  | AsyncIterable<OpenAIChatCompletionChunk>
136
136
  | OpenAIChatCompletion;
137
+ type PromptTokensDetailsWithCacheWrite = NonNullable<
138
+ OpenAIClient.Completions.CompletionUsage['prompt_tokens_details']
139
+ > & {
140
+ cache_write_tokens?: number;
141
+ };
137
142
  type OpenAIChatCompletionRetry = (
138
143
  request: OpenAIChatCompletionRequest,
139
144
  requestOptions?: OpenAICoreRequestOptions
@@ -158,8 +163,12 @@ function createUsageMetadata(
158
163
  const outputTokenDetails: UsageMetadata['output_token_details'] = {};
159
164
  let hasInputTokenDetails = false;
160
165
  let hasOutputTokenDetails = false;
161
- const audioInputTokens = usage.prompt_tokens_details?.audio_tokens;
162
- const cachedInputTokens = usage.prompt_tokens_details?.cached_tokens;
166
+ const promptTokenDetails = usage.prompt_tokens_details as
167
+ | PromptTokensDetailsWithCacheWrite
168
+ | undefined;
169
+ const audioInputTokens = promptTokenDetails?.audio_tokens;
170
+ const cachedInputTokens = promptTokenDetails?.cached_tokens;
171
+ const cacheWriteInputTokens = promptTokenDetails?.cache_write_tokens;
163
172
  const audioOutputTokens = usage.completion_tokens_details?.audio_tokens;
164
173
  const reasoningOutputTokens =
165
174
  usage.completion_tokens_details?.reasoning_tokens;
@@ -172,6 +181,10 @@ function createUsageMetadata(
172
181
  inputTokenDetails.cache_read = cachedInputTokens;
173
182
  hasInputTokenDetails = true;
174
183
  }
184
+ if (cacheWriteInputTokens != null) {
185
+ inputTokenDetails.cache_creation = cacheWriteInputTokens;
186
+ hasInputTokenDetails = true;
187
+ }
175
188
  if (audioOutputTokens != null) {
176
189
  outputTokenDetails.audio = audioOutputTokens;
177
190
  hasOutputTokenDetails = true;
@@ -685,16 +698,23 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
685
698
  usageMetadata.total_tokens =
686
699
  (usageMetadata.total_tokens ?? 0) + totalTokens;
687
700
  }
701
+ const promptTokensDetailsWithCacheWrite = promptTokensDetails as
702
+ | PromptTokensDetailsWithCacheWrite
703
+ | undefined;
688
704
  if (
689
- promptTokensDetails?.audio_tokens != null ||
690
- promptTokensDetails?.cached_tokens != null
705
+ promptTokensDetailsWithCacheWrite?.audio_tokens != null ||
706
+ promptTokensDetailsWithCacheWrite?.cached_tokens != null ||
707
+ promptTokensDetailsWithCacheWrite?.cache_write_tokens != null
691
708
  ) {
692
709
  usageMetadata.input_token_details = {
693
- ...(promptTokensDetails.audio_tokens != null && {
694
- audio: promptTokensDetails.audio_tokens,
710
+ ...(promptTokensDetailsWithCacheWrite.audio_tokens != null && {
711
+ audio: promptTokensDetailsWithCacheWrite.audio_tokens,
712
+ }),
713
+ ...(promptTokensDetailsWithCacheWrite.cached_tokens != null && {
714
+ cache_read: promptTokensDetailsWithCacheWrite.cached_tokens,
695
715
  }),
696
- ...(promptTokensDetails.cached_tokens != null && {
697
- cache_read: promptTokensDetails.cached_tokens,
716
+ ...(promptTokensDetailsWithCacheWrite.cache_write_tokens != null && {
717
+ cache_creation: promptTokensDetailsWithCacheWrite.cache_write_tokens,
698
718
  }),
699
719
  };
700
720
  }
@@ -846,12 +866,18 @@ class LibreChatOpenAICompletions extends OriginalChatOpenAICompletions {
846
866
  );
847
867
  }
848
868
  if (usage) {
869
+ const promptTokenDetails = usage.prompt_tokens_details as
870
+ | PromptTokensDetailsWithCacheWrite
871
+ | undefined;
849
872
  const inputTokenDetails = {
850
- ...(usage.prompt_tokens_details?.audio_tokens != null && {
851
- audio: usage.prompt_tokens_details.audio_tokens,
873
+ ...(promptTokenDetails?.audio_tokens != null && {
874
+ audio: promptTokenDetails.audio_tokens,
875
+ }),
876
+ ...(promptTokenDetails?.cached_tokens != null && {
877
+ cache_read: promptTokenDetails.cached_tokens,
852
878
  }),
853
- ...(usage.prompt_tokens_details?.cached_tokens != null && {
854
- cache_read: usage.prompt_tokens_details.cached_tokens,
879
+ ...(promptTokenDetails?.cache_write_tokens != null && {
880
+ cache_creation: promptTokenDetails.cache_write_tokens,
855
881
  }),
856
882
  };
857
883
  const outputTokenDetails = {
@@ -29,6 +29,7 @@ export interface ChatOpenRouterCallOptions
29
29
  include_reasoning?: boolean;
30
30
  reasoning?: OpenRouterReasoning;
31
31
  modelKwargs?: OpenAIChatInput['modelKwargs'];
32
+ promptCache?: boolean;
32
33
  }
33
34
 
34
35
  export type ChatOpenRouterInput = Partial<
@@ -104,31 +105,45 @@ export class ChatOpenRouter extends ChatOpenAI {
104
105
  private includeReasoning?: boolean;
105
106
 
106
107
  constructor(_fields: ChatOpenRouterInput) {
108
+ const fieldsWithoutPromptCache: ChatOpenRouterInput = { ..._fields };
109
+ delete fieldsWithoutPromptCache.promptCache;
110
+
107
111
  const {
108
112
  include_reasoning,
109
113
  reasoning: openRouterReasoning,
110
114
  modelKwargs = {},
111
115
  ...fields
112
- } = _fields;
116
+ } = fieldsWithoutPromptCache;
113
117
 
114
118
  // Extract reasoning from modelKwargs if provided there (e.g., from LLMConfig)
115
119
  const { reasoning: mkReasoning, ...restModelKwargs } = modelKwargs as {
116
120
  reasoning?: OpenRouterReasoning;
117
121
  } & Record<string, unknown>;
122
+ const mergedReasoning =
123
+ mkReasoning != null || openRouterReasoning != null
124
+ ? {
125
+ ...mkReasoning,
126
+ ...openRouterReasoning,
127
+ }
128
+ : undefined;
129
+ const runtimeReasoning =
130
+ mergedReasoning ??
131
+ (include_reasoning === true ? { enabled: true } : undefined);
132
+ const parentModelKwargs =
133
+ runtimeReasoning == null
134
+ ? restModelKwargs
135
+ : { ...restModelKwargs, reasoning: runtimeReasoning };
118
136
 
119
137
  super({
120
138
  ...fields,
121
- modelKwargs: restModelKwargs,
139
+ modelKwargs: parentModelKwargs,
122
140
  includeReasoningDetails: true,
123
141
  convertReasoningDetailsToContent: true,
124
142
  });
125
143
 
126
144
  // Merge reasoning config: modelKwargs.reasoning < constructor reasoning
127
- if (mkReasoning != null || openRouterReasoning != null) {
128
- this.openRouterReasoning = {
129
- ...mkReasoning,
130
- ...openRouterReasoning,
131
- };
145
+ if (mergedReasoning != null) {
146
+ this.openRouterReasoning = mergedReasoning;
132
147
  }
133
148
 
134
149
  this.includeReasoning = include_reasoning;
@@ -7,6 +7,17 @@ type CreateRouterOptions = Partial<
7
7
  Pick<OpenAIChatInput, 'model' | 'apiKey' | 'streamUsage'>
8
8
  >;
9
9
 
10
+ type RuntimeInvocationParams = {
11
+ reasoning?: OpenRouterReasoning;
12
+ reasoning_effort?: string;
13
+ };
14
+
15
+ class RuntimeInspectableChatOpenRouter extends ChatOpenRouter {
16
+ getRuntimeInvocationParams(): RuntimeInvocationParams {
17
+ return this.completions.invocationParams() as RuntimeInvocationParams;
18
+ }
19
+ }
20
+
10
21
  function createRouter(overrides: CreateRouterOptions = {}): ChatOpenRouter {
11
22
  return new ChatOpenRouter({
12
23
  model: 'openrouter/test-model',
@@ -91,6 +102,28 @@ describe('ChatOpenRouter reasoning handling', () => {
91
102
  expect(params.reasoning_effort).toBeUndefined();
92
103
  });
93
104
 
105
+ it('passes reasoning to the runtime completions delegate', () => {
106
+ const router = new RuntimeInspectableChatOpenRouter({
107
+ model: 'openrouter/test-model',
108
+ apiKey: 'test-key',
109
+ reasoning: { max_tokens: 1024 },
110
+ });
111
+ const params = router.getRuntimeInvocationParams();
112
+ expect(params.reasoning).toEqual({ max_tokens: 1024 });
113
+ expect(params.reasoning_effort).toBeUndefined();
114
+ });
115
+
116
+ it('passes legacy include_reasoning to the runtime completions delegate', () => {
117
+ const router = new RuntimeInspectableChatOpenRouter({
118
+ model: 'openrouter/test-model',
119
+ apiKey: 'test-key',
120
+ include_reasoning: true,
121
+ });
122
+ const params = router.getRuntimeInvocationParams();
123
+ expect(params.reasoning).toEqual({ enabled: true });
124
+ expect(params.reasoning_effort).toBeUndefined();
125
+ });
126
+
94
127
  it('does not include reasoning when none is configured', () => {
95
128
  const router = createRouter();
96
129
  const params = router.invocationParams();
@@ -0,0 +1,83 @@
1
+ import { tool } from '@langchain/core/tools';
2
+ import type { GraphTools } from '@/types';
3
+ import { partitionAndMarkOpenRouterToolCache } from './toolCache';
4
+
5
+ type OpenRouterTool = {
6
+ type: 'function';
7
+ function: {
8
+ name: string;
9
+ description?: string;
10
+ parameters?: object;
11
+ };
12
+ cache_control?: { type: 'ephemeral' };
13
+ defer_loading?: boolean;
14
+ };
15
+
16
+ function createOpenAITool(name: string): OpenRouterTool {
17
+ return {
18
+ type: 'function',
19
+ function: {
20
+ name,
21
+ description: `${name} description`,
22
+ parameters: {
23
+ type: 'object',
24
+ properties: {},
25
+ },
26
+ },
27
+ };
28
+ }
29
+
30
+ describe('partitionAndMarkOpenRouterToolCache', () => {
31
+ it('marks the last static OpenRouter tool before deferred tools', () => {
32
+ const tools = [
33
+ createOpenAITool('static_one'),
34
+ createOpenAITool('static_two'),
35
+ createOpenAITool('dynamic_one'),
36
+ ] as GraphTools;
37
+
38
+ const result = partitionAndMarkOpenRouterToolCache(
39
+ tools,
40
+ (name) => name === 'dynamic_one'
41
+ ) as OpenRouterTool[];
42
+
43
+ expect(result.map((entry) => entry.function.name)).toEqual([
44
+ 'static_one',
45
+ 'static_two',
46
+ 'dynamic_one',
47
+ ]);
48
+ expect(result[0]).not.toHaveProperty('cache_control');
49
+ expect(result[1].cache_control).toEqual({ type: 'ephemeral' });
50
+ expect(result[2]).not.toHaveProperty('cache_control');
51
+ });
52
+
53
+ it('converts LangChain tools to OpenAI tools before adding cache control', () => {
54
+ const staticTool = tool(async () => 'static', {
55
+ name: 'static_tool',
56
+ description: 'Static tool',
57
+ schema: {
58
+ type: 'object',
59
+ properties: {},
60
+ },
61
+ });
62
+ const dynamicTool = tool(async () => 'dynamic', {
63
+ name: 'dynamic_tool',
64
+ description: 'Dynamic tool',
65
+ schema: {
66
+ type: 'object',
67
+ properties: {},
68
+ },
69
+ });
70
+
71
+ const result = partitionAndMarkOpenRouterToolCache(
72
+ [dynamicTool, staticTool] as GraphTools,
73
+ (name) => name === 'dynamic_tool'
74
+ ) as OpenRouterTool[];
75
+
76
+ expect(result.map((entry) => entry.function.name)).toEqual([
77
+ 'static_tool',
78
+ 'dynamic_tool',
79
+ ]);
80
+ expect(result[0].cache_control).toEqual({ type: 'ephemeral' });
81
+ expect(result[1]).not.toHaveProperty('cache_control');
82
+ });
83
+ });
@@ -0,0 +1,89 @@
1
+ import type { BindToolsInput } from '@langchain/core/language_models/chat_models';
2
+ import type { OpenAIClient } from '@langchain/openai';
3
+ import type { GraphTools } from '@/types';
4
+ import { _convertToOpenAITool } from '@/llm/openai';
5
+
6
+ const CACHE_CONTROL = { type: 'ephemeral' as const };
7
+
8
+ type OpenRouterToolWithCacheControl = OpenAIClient.ChatCompletionTool & {
9
+ cache_control?: typeof CACHE_CONTROL;
10
+ defer_loading?: boolean;
11
+ };
12
+
13
+ type ToolNameCandidate = {
14
+ name?: unknown;
15
+ function?: {
16
+ name?: unknown;
17
+ };
18
+ defer_loading?: unknown;
19
+ };
20
+
21
+ function getToolName(tool: unknown): string | undefined {
22
+ const candidate = tool as ToolNameCandidate;
23
+ if (typeof candidate.name === 'string') {
24
+ return candidate.name;
25
+ }
26
+ if (typeof candidate.function?.name === 'string') {
27
+ return candidate.function.name;
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ function hasDeferredMarker(tool: unknown): boolean {
33
+ return (tool as ToolNameCandidate).defer_loading === true;
34
+ }
35
+
36
+ function toOpenRouterTool(tool: unknown): OpenRouterToolWithCacheControl {
37
+ const converted = _convertToOpenAITool(
38
+ tool as BindToolsInput
39
+ ) as OpenRouterToolWithCacheControl;
40
+
41
+ if (hasDeferredMarker(tool)) {
42
+ return { ...converted, defer_loading: true };
43
+ }
44
+
45
+ return converted;
46
+ }
47
+
48
+ function markCacheControl(
49
+ tool: OpenRouterToolWithCacheControl
50
+ ): OpenRouterToolWithCacheControl {
51
+ return {
52
+ ...tool,
53
+ cache_control: CACHE_CONTROL,
54
+ };
55
+ }
56
+
57
+ export function partitionAndMarkOpenRouterToolCache(
58
+ tools: GraphTools | undefined,
59
+ isDeferred: (toolName: string) => boolean
60
+ ): GraphTools | undefined {
61
+ if (tools == null || tools.length === 0) {
62
+ return tools;
63
+ }
64
+
65
+ const staticTools: OpenRouterToolWithCacheControl[] = [];
66
+ const deferredTools: OpenRouterToolWithCacheControl[] = [];
67
+
68
+ for (const tool of tools as readonly unknown[]) {
69
+ const converted = toOpenRouterTool(tool);
70
+ const name = getToolName(converted) ?? getToolName(tool);
71
+
72
+ if (name != null && isDeferred(name)) {
73
+ deferredTools.push(converted);
74
+ continue;
75
+ }
76
+
77
+ staticTools.push(converted);
78
+ }
79
+
80
+ if (staticTools.length === 0) {
81
+ return [...deferredTools] as GraphTools;
82
+ }
83
+
84
+ staticTools[staticTools.length - 1] = markCacheControl(
85
+ staticTools[staticTools.length - 1]
86
+ );
87
+
88
+ return [...staticTools, ...deferredTools] as GraphTools;
89
+ }
@@ -14,9 +14,14 @@ import {
14
14
  addBedrockCacheControl,
15
15
  addCacheControl,
16
16
  } from './cache';
17
+ import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
17
18
  import { toLangChainContent } from './langchain';
18
19
  import { ContentTypes } from '@/common/enum';
19
20
 
21
+ type CacheControlBlock = MessageContentComplex & {
22
+ cache_control?: { type: 'ephemeral'; ttl?: '1h' };
23
+ };
24
+
20
25
  describe('addCacheControl', () => {
21
26
  test('should add cache control to the last two user messages with array content', () => {
22
27
  const messages: AnthropicMessages = [
@@ -1483,3 +1488,125 @@ describe('LangChain message type preservation', () => {
1483
1488
  expect((result[1] as AIMessage).tool_calls![0].name).toBe('navigate');
1484
1489
  });
1485
1490
  });
1491
+
1492
+ describe('OpenRouter prompt caching (reuses addCacheControl)', () => {
1493
+ it('adds cache_control to LangChain messages for OpenRouter (same format as Anthropic)', () => {
1494
+ const messages: BaseMessage[] = [
1495
+ new HumanMessage({ content: [{ type: 'text', text: 'System context' }] }),
1496
+ new AIMessage({ content: [{ type: 'text', text: 'Acknowledged' }] }),
1497
+ new HumanMessage({ content: [{ type: 'text', text: 'User query' }] }),
1498
+ ];
1499
+
1500
+ const result = addCacheControl(messages);
1501
+
1502
+ const firstContent = result[0].content as MessageContentComplex[];
1503
+ const lastContent = result[2].content as MessageContentComplex[];
1504
+
1505
+ expect((firstContent[0] as CacheControlBlock).cache_control).toEqual({
1506
+ type: 'ephemeral',
1507
+ });
1508
+ expect((lastContent[0] as CacheControlBlock).cache_control).toEqual({
1509
+ type: 'ephemeral',
1510
+ });
1511
+ });
1512
+
1513
+ it('preserves cache_control through OpenAI message conversion used by OpenRouter', () => {
1514
+ const messages: BaseMessage[] = [
1515
+ new HumanMessage({
1516
+ content: [
1517
+ {
1518
+ type: 'text',
1519
+ text: 'Hello',
1520
+ cache_control: { type: 'ephemeral' },
1521
+ },
1522
+ ],
1523
+ }),
1524
+ new AIMessage({ content: 'Hi there' }),
1525
+ new HumanMessage({
1526
+ content: [
1527
+ {
1528
+ type: 'text',
1529
+ text: 'Follow-up',
1530
+ cache_control: { type: 'ephemeral' },
1531
+ },
1532
+ ],
1533
+ }),
1534
+ ];
1535
+
1536
+ const converted = _convertMessagesToOpenAIParams(messages);
1537
+
1538
+ const firstUserContent = converted[0].content as CacheControlBlock[];
1539
+ const lastUserContent = converted[2].content as CacheControlBlock[];
1540
+
1541
+ expect(firstUserContent[0]).toHaveProperty('cache_control');
1542
+ expect(firstUserContent[0].cache_control).toEqual({ type: 'ephemeral' });
1543
+ expect(lastUserContent[0]).toHaveProperty('cache_control');
1544
+ expect(lastUserContent[0].cache_control).toEqual({ type: 'ephemeral' });
1545
+ });
1546
+
1547
+ it('end-to-end: addCacheControl then convert preserves breakpoints for OpenRouter', () => {
1548
+ const messages: BaseMessage[] = [
1549
+ new HumanMessage({ content: 'First message with context' }),
1550
+ new AIMessage({ content: 'Response' }),
1551
+ new HumanMessage({ content: 'Second question' }),
1552
+ ];
1553
+
1554
+ const cached = addCacheControl(messages);
1555
+ const converted = _convertMessagesToOpenAIParams(
1556
+ cached,
1557
+ 'anthropic/claude-sonnet-4-20250514'
1558
+ );
1559
+
1560
+ const firstUser = converted[0];
1561
+ const lastUser = converted[2];
1562
+
1563
+ expect(Array.isArray(firstUser.content)).toBe(true);
1564
+ expect(
1565
+ (firstUser.content as CacheControlBlock[])[0]
1566
+ ).toHaveProperty('cache_control');
1567
+
1568
+ expect(Array.isArray(lastUser.content)).toBe(true);
1569
+ expect(
1570
+ (lastUser.content as CacheControlBlock[])[0]
1571
+ ).toHaveProperty('cache_control');
1572
+ });
1573
+
1574
+ it('strips Bedrock cache before applying OpenRouter/Anthropic cache', () => {
1575
+ const messages: TestMsg[] = [
1576
+ {
1577
+ role: 'user',
1578
+ content: [
1579
+ { type: ContentTypes.TEXT, text: 'First message' },
1580
+ { cachePoint: { type: 'default' } },
1581
+ ],
1582
+ },
1583
+ {
1584
+ role: 'assistant',
1585
+ content: [
1586
+ { type: ContentTypes.TEXT, text: 'Response' },
1587
+ { cachePoint: { type: 'default' } },
1588
+ ],
1589
+ },
1590
+ {
1591
+ role: 'user',
1592
+ content: [{ type: ContentTypes.TEXT, text: 'Follow-up' }],
1593
+ },
1594
+ ];
1595
+
1596
+ /** @ts-expect-error - Testing cross-provider compatibility */
1597
+ const result = addCacheControl(messages);
1598
+
1599
+ for (const msg of result) {
1600
+ if (Array.isArray(msg.content)) {
1601
+ expect(
1602
+ (msg.content as MessageContentComplex[]).some(
1603
+ (b) => 'cachePoint' in b
1604
+ )
1605
+ ).toBe(false);
1606
+ }
1607
+ }
1608
+
1609
+ const lastContent = result[2].content as MessageContentComplex[];
1610
+ expect('cache_control' in lastContent[0]).toBe(true);
1611
+ });
1612
+ });