@librechat/agents 3.3.2 → 3.3.4

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 (162) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +2 -1
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +342 -109
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
  6. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  7. package/dist/cjs/langfuse.cjs +6 -0
  8. package/dist/cjs/langfuse.cjs.map +1 -1
  9. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
  10. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  11. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +37 -10
  12. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  13. package/dist/cjs/llm/google/utils/common.cjs +19 -7
  14. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  15. package/dist/cjs/llm/invoke.cjs +64 -3
  16. package/dist/cjs/llm/invoke.cjs.map +1 -1
  17. package/dist/cjs/llm/openai/index.cjs +41 -5
  18. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  19. package/dist/cjs/llm/openai/utils/index.cjs +25 -4
  20. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  21. package/dist/cjs/llm/openrouter/index.cjs +4 -1
  22. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  23. package/dist/cjs/main.cjs +19 -2
  24. package/dist/cjs/messages/cache.cjs +54 -33
  25. package/dist/cjs/messages/cache.cjs.map +1 -1
  26. package/dist/cjs/messages/contextPruning.cjs +17 -43
  27. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  28. package/dist/cjs/messages/core.cjs +315 -23
  29. package/dist/cjs/messages/core.cjs.map +1 -1
  30. package/dist/cjs/messages/format.cjs +218 -60
  31. package/dist/cjs/messages/format.cjs.map +1 -1
  32. package/dist/cjs/messages/index.cjs +1 -1
  33. package/dist/cjs/messages/prune.cjs +503 -127
  34. package/dist/cjs/messages/prune.cjs.map +1 -1
  35. package/dist/cjs/stream.cjs +7 -3
  36. package/dist/cjs/stream.cjs.map +1 -1
  37. package/dist/cjs/summarization/node.cjs +28 -14
  38. package/dist/cjs/summarization/node.cjs.map +1 -1
  39. package/dist/cjs/tools/ToolNode.cjs +54 -41
  40. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  41. package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
  42. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  43. package/dist/cjs/utils/index.cjs +1 -1
  44. package/dist/cjs/utils/llm.cjs +1 -1
  45. package/dist/cjs/utils/llm.cjs.map +1 -1
  46. package/dist/cjs/utils/tokens.cjs +307 -20
  47. package/dist/cjs/utils/tokens.cjs.map +1 -1
  48. package/dist/cjs/utils/toolContent.cjs +1514 -0
  49. package/dist/cjs/utils/toolContent.cjs.map +1 -0
  50. package/dist/esm/agents/AgentContext.mjs +2 -1
  51. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  52. package/dist/esm/graphs/Graph.mjs +349 -116
  53. package/dist/esm/graphs/Graph.mjs.map +1 -1
  54. package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
  55. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  56. package/dist/esm/langfuse.mjs +6 -0
  57. package/dist/esm/langfuse.mjs.map +1 -1
  58. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
  59. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  60. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +37 -10
  61. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  62. package/dist/esm/llm/google/utils/common.mjs +19 -7
  63. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  64. package/dist/esm/llm/invoke.mjs +65 -5
  65. package/dist/esm/llm/invoke.mjs.map +1 -1
  66. package/dist/esm/llm/openai/index.mjs +41 -5
  67. package/dist/esm/llm/openai/index.mjs.map +1 -1
  68. package/dist/esm/llm/openai/utils/index.mjs +25 -4
  69. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  70. package/dist/esm/llm/openrouter/index.mjs +4 -1
  71. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  72. package/dist/esm/main.mjs +7 -7
  73. package/dist/esm/messages/cache.mjs +55 -34
  74. package/dist/esm/messages/cache.mjs.map +1 -1
  75. package/dist/esm/messages/contextPruning.mjs +17 -43
  76. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  77. package/dist/esm/messages/core.mjs +305 -24
  78. package/dist/esm/messages/core.mjs.map +1 -1
  79. package/dist/esm/messages/format.mjs +218 -61
  80. package/dist/esm/messages/format.mjs.map +1 -1
  81. package/dist/esm/messages/index.mjs +1 -1
  82. package/dist/esm/messages/prune.mjs +500 -128
  83. package/dist/esm/messages/prune.mjs.map +1 -1
  84. package/dist/esm/stream.mjs +7 -3
  85. package/dist/esm/stream.mjs.map +1 -1
  86. package/dist/esm/summarization/node.mjs +28 -14
  87. package/dist/esm/summarization/node.mjs.map +1 -1
  88. package/dist/esm/tools/ToolNode.mjs +54 -41
  89. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  90. package/dist/esm/tools/toolOutputReferences.mjs +6 -0
  91. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  92. package/dist/esm/utils/index.mjs +1 -1
  93. package/dist/esm/utils/llm.mjs +1 -1
  94. package/dist/esm/utils/llm.mjs.map +1 -1
  95. package/dist/esm/utils/tokens.mjs +307 -21
  96. package/dist/esm/utils/tokens.mjs.map +1 -1
  97. package/dist/esm/utils/toolContent.mjs +1503 -0
  98. package/dist/esm/utils/toolContent.mjs.map +1 -0
  99. package/dist/types/graphs/Graph.d.ts +1 -1
  100. package/dist/types/llm/invoke.d.ts +27 -1
  101. package/dist/types/llm/openai/index.d.ts +4 -0
  102. package/dist/types/llm/openai/utils/index.d.ts +2 -0
  103. package/dist/types/llm/openrouter/index.d.ts +5 -0
  104. package/dist/types/messages/contextPruning.d.ts +2 -1
  105. package/dist/types/messages/core.d.ts +36 -0
  106. package/dist/types/messages/format.d.ts +6 -0
  107. package/dist/types/messages/prune.d.ts +16 -12
  108. package/dist/types/types/stream.d.ts +1 -1
  109. package/dist/types/utils/tokens.d.ts +7 -0
  110. package/dist/types/utils/toolContent.d.ts +107 -0
  111. package/package.json +13 -9
  112. package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
  113. package/src/agents/AgentContext.ts +1 -0
  114. package/src/graphs/Graph.ts +698 -200
  115. package/src/graphs/MultiAgentGraph.ts +6 -1
  116. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1128 -4
  117. package/src/langfuse.ts +15 -0
  118. package/src/llm/anthropic/utils/message_inputs.ts +158 -12
  119. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
  120. package/src/llm/bedrock/utils/message_inputs.test.ts +49 -1
  121. package/src/llm/bedrock/utils/message_inputs.ts +86 -13
  122. package/src/llm/google/utils/common.test.ts +75 -9
  123. package/src/llm/google/utils/common.ts +34 -3
  124. package/src/llm/invoke.test.ts +488 -0
  125. package/src/llm/invoke.ts +196 -2
  126. package/src/llm/openai/index.ts +106 -6
  127. package/src/llm/openai/utils/index.ts +109 -53
  128. package/src/llm/openai/utils/messages.test.ts +330 -1
  129. package/src/llm/openrouter/index.ts +12 -2
  130. package/src/llm/openrouter/reasoning.test.ts +314 -0
  131. package/src/messages/__tests__/observationMasking.test.ts +93 -2
  132. package/src/messages/cache.tail.test.ts +193 -0
  133. package/src/messages/cache.test.ts +113 -0
  134. package/src/messages/cache.ts +92 -60
  135. package/src/messages/contextPruning.test.ts +184 -0
  136. package/src/messages/contextPruning.ts +49 -42
  137. package/src/messages/core.ts +653 -40
  138. package/src/messages/ensureThinkingBlock.test.ts +49 -1
  139. package/src/messages/foldToollessToolBlocks.test.ts +163 -5
  140. package/src/messages/format.ts +363 -91
  141. package/src/messages/formatAgentMessages.test.ts +604 -14
  142. package/src/messages/formatAgentMessages.tools.test.ts +103 -0
  143. package/src/messages/prune.ts +996 -183
  144. package/src/specs/langfuse-callbacks.test.ts +203 -1
  145. package/src/specs/prune.test.ts +1083 -6
  146. package/src/specs/summarization-unit.test.ts +105 -0
  147. package/src/specs/tokens.test.ts +609 -32
  148. package/src/stream.ts +23 -15
  149. package/src/summarization/__tests__/node.test.ts +77 -0
  150. package/src/summarization/node.ts +46 -18
  151. package/src/tools/ToolNode.ts +147 -68
  152. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
  153. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
  154. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
  155. package/src/tools/__tests__/directToolHooks.test.ts +255 -7
  156. package/src/tools/toolOutputReferences.ts +6 -0
  157. package/src/types/stream.ts +1 -1
  158. package/src/utils/llm.test.ts +18 -0
  159. package/src/utils/llm.ts +4 -1
  160. package/src/utils/tokens.ts +586 -31
  161. package/src/utils/toolContent.test.ts +1432 -0
  162. package/src/utils/toolContent.ts +2707 -0
@@ -3,7 +3,6 @@
3
3
  import { type OpenAI as OpenAIClient } from 'openai';
4
4
  import { ChatGenerationChunk } from '@langchain/core/outputs';
5
5
  import {
6
- convertLangChainToolCallToOpenAI,
7
6
  makeInvalidToolCall,
8
7
  parseToolCall,
9
8
  } from '@langchain/core/output_parsers/openai_tools';
@@ -37,11 +36,23 @@ import type {
37
36
  ChatOpenAIReasoningSummary,
38
37
  } from '@langchain/openai';
39
38
  import type { ToolCall, ToolCallChunk } from '@langchain/core/messages/tool';
39
+ import {
40
+ getBoundedCacheControlledTextToolContent,
41
+ getComputerCallOutputScreenshot,
42
+ isComputerCallOutputMessage,
43
+ serializeStructuredValueBounded,
44
+ } from '@/utils/toolContent';
40
45
  import {
41
46
  STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
42
47
  STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
43
48
  OPENAI_RESPONSES_STREAMED_TOOL_CALL_ADAPTER,
44
49
  } from '@/tools/streamedToolCallSeals';
50
+ import {
51
+ calculateMaxToolCallInputChars,
52
+ projectToolCallInputs,
53
+ serializeToolCallInput,
54
+ } from '@/messages/prune';
55
+ import { HARD_MAX_TOOL_RESULT_CHARS } from '@/utils/truncation';
45
56
  import { toLangChainContent } from '@/messages/langchain';
46
57
 
47
58
  export type { OpenAICallOptions, OpenAIChatInput };
@@ -83,6 +94,32 @@ type OpenAIRoleEnum =
83
94
  type OpenAICompletionParam =
84
95
  OpenAIClient.Chat.Completions.ChatCompletionMessageParam;
85
96
 
97
+ const MAX_PROVIDER_TOOL_CALL_INPUT_CHARS = calculateMaxToolCallInputChars();
98
+
99
+ function convertLangChainToolCallToBoundedOpenAI(toolCall: ToolCall): {
100
+ id: string;
101
+ type: 'function';
102
+ function: {
103
+ name: string;
104
+ arguments: string;
105
+ };
106
+ } {
107
+ if (toolCall.id == null) {
108
+ throw new Error('All OpenAI tool calls must have an "id" field.');
109
+ }
110
+ return {
111
+ id: toolCall.id,
112
+ type: 'function',
113
+ function: {
114
+ name: toolCall.name,
115
+ arguments: serializeToolCallInput(
116
+ toolCall.args,
117
+ MAX_PROVIDER_TOOL_CALL_INPUT_CHARS
118
+ ),
119
+ },
120
+ };
121
+ }
122
+
86
123
  function extractGenericMessageCustomRole(message: ChatMessage) {
87
124
  if (
88
125
  message.role !== 'system' &&
@@ -300,6 +337,8 @@ export interface ConvertMessagesOptions {
300
337
  includeReasoningDetails?: boolean;
301
338
  /** Convert reasoning_details to content blocks for Claude (requires content array format) */
302
339
  convertReasoningDetailsToContent?: boolean;
340
+ /** Preserve OpenRouter's canonical cache-decorated tool text block. */
341
+ preserveToolCacheControl?: boolean;
303
342
  }
304
343
 
305
344
  // Used in LangSmith, export is important here
@@ -308,9 +347,13 @@ export function _convertMessagesToOpenAIParams(
308
347
  model?: string,
309
348
  options?: ConvertMessagesOptions
310
349
  ): OpenAICompletionParam[] {
350
+ const projectedMessages = projectToolCallInputs(
351
+ messages,
352
+ MAX_PROVIDER_TOOL_CALL_INPUT_CHARS
353
+ );
311
354
  let hasReasoningToolCallContext = false;
312
355
  // TODO: Function messages do not support array content, fix cast
313
- return messages.flatMap((message) => {
356
+ return projectedMessages.flatMap((message) => {
314
357
  let role = messageToOpenAIRole(message);
315
358
  if (role === 'system' && isReasoningModel(model)) {
316
359
  role = 'developer';
@@ -318,22 +361,40 @@ export function _convertMessagesToOpenAIParams(
318
361
 
319
362
  let hasAnthropicThinkingBlock: boolean = false;
320
363
 
321
- const content =
322
- typeof message.content === 'string'
323
- ? message.content
324
- : message.content.map((m) => {
325
- if ('type' in m && m.type === 'thinking') {
326
- hasAnthropicThinkingBlock = true;
327
- return m;
328
- }
329
- if (isDataContentBlock(m)) {
330
- return convertToProviderContentBlock(
331
- m,
332
- completionsApiContentBlockConverter
333
- );
334
- }
364
+ let content: unknown;
365
+ if (
366
+ role === 'tool' &&
367
+ typeof message.content !== 'string' &&
368
+ !isComputerCallOutputMessage(message)
369
+ ) {
370
+ content =
371
+ options?.preserveToolCacheControl === true
372
+ ? getBoundedCacheControlledTextToolContent(
373
+ message.content,
374
+ HARD_MAX_TOOL_RESULT_CHARS
375
+ )
376
+ : undefined;
377
+ content ??= serializeStructuredValueBounded(
378
+ message.content,
379
+ HARD_MAX_TOOL_RESULT_CHARS
380
+ ).content;
381
+ } else if (typeof message.content === 'string') {
382
+ content = message.content;
383
+ } else {
384
+ content = message.content.map((m) => {
385
+ if ('type' in m && m.type === 'thinking') {
386
+ hasAnthropicThinkingBlock = true;
335
387
  return m;
336
- });
388
+ }
389
+ if (isDataContentBlock(m)) {
390
+ return convertToProviderContentBlock(
391
+ m,
392
+ completionsApiContentBlockConverter
393
+ );
394
+ }
395
+ return m;
396
+ });
397
+ }
337
398
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
338
399
  const completionParam: Record<string, any> = {
339
400
  role,
@@ -351,7 +412,7 @@ export function _convertMessagesToOpenAIParams(
351
412
  if (isAIMessage(message) && !!message.tool_calls?.length) {
352
413
  messageHasToolCalls = true;
353
414
  completionParam.tool_calls = message.tool_calls.map(
354
- convertLangChainToolCallToOpenAI
415
+ convertLangChainToolCallToBoundedOpenAI
355
416
  );
356
417
  completionParam.content = hasAnthropicThinkingBlock ? content : '';
357
418
  if (
@@ -527,7 +588,11 @@ export function _convertMessagesToOpenAIResponsesParams(
527
588
  model?: string,
528
589
  zdrEnabled?: boolean
529
590
  ): ResponsesInputItem[] {
530
- return messages.flatMap(
591
+ const projectedMessages = projectToolCallInputs(
592
+ messages,
593
+ MAX_PROVIDER_TOOL_CALL_INPUT_CHARS
594
+ );
595
+ return projectedMessages.flatMap(
531
596
  (lcMsg): ResponsesInputItem | ResponsesInputItem[] => {
532
597
  const additional_kwargs =
533
598
  lcMsg.additional_kwargs as BaseMessageFields['additional_kwargs'] & {
@@ -552,44 +617,29 @@ export function _convertMessagesToOpenAIResponsesParams(
552
617
 
553
618
  // Handle computer call output
554
619
  if (additional_kwargs.type === 'computer_call_output') {
555
- const output = (() => {
556
- if (typeof toolMessage.content === 'string') {
557
- return {
558
- type: 'computer_screenshot' as const,
559
- image_url: toolMessage.content,
560
- };
561
- }
562
-
563
- if (Array.isArray(toolMessage.content)) {
564
- const oaiScreenshot = toolMessage.content.find(
565
- (i) => i.type === 'computer_screenshot'
566
- ) as { type: 'computer_screenshot'; image_url: string };
567
-
568
- if (oaiScreenshot) return oaiScreenshot;
569
-
570
- const lcImage = toolMessage.content.find(
571
- (i) => i.type === 'image_url'
572
- ) as MessageContentImageUrl;
573
-
574
- if (lcImage) {
575
- return {
576
- type: 'computer_screenshot' as const,
577
- image_url:
578
- typeof lcImage.image_url === 'string'
579
- ? lcImage.image_url
580
- : lcImage.image_url.url,
581
- };
582
- }
583
- }
584
-
620
+ const screenshot = getComputerCallOutputScreenshot(
621
+ toolMessage.content
622
+ );
623
+ if (screenshot == null) {
585
624
  throw new Error('Invalid computer call output');
586
- })();
625
+ }
587
626
 
588
- return {
627
+ const output: OpenAIClient.Responses.ResponseComputerToolCallOutputScreenshot =
628
+ 'image_url' in screenshot
629
+ ? {
630
+ type: 'computer_screenshot',
631
+ image_url: screenshot.image_url,
632
+ }
633
+ : {
634
+ type: 'computer_screenshot',
635
+ file_id: screenshot.file_id,
636
+ };
637
+ const computerCallOutput: ResponsesInputItem = {
589
638
  type: 'computer_call_output',
590
639
  output,
591
640
  call_id: toolMessage.tool_call_id,
592
641
  };
642
+ return computerCallOutput;
593
643
  }
594
644
 
595
645
  return {
@@ -598,7 +648,10 @@ export function _convertMessagesToOpenAIResponsesParams(
598
648
  id: toolMessage.id?.startsWith('fc_') ? toolMessage.id : undefined,
599
649
  output:
600
650
  typeof toolMessage.content !== 'string'
601
- ? JSON.stringify(toolMessage.content)
651
+ ? serializeStructuredValueBounded(
652
+ toolMessage.content,
653
+ HARD_MAX_TOOL_RESULT_CHARS
654
+ ).content
602
655
  : toolMessage.content,
603
656
  };
604
657
  }
@@ -682,7 +735,10 @@ export function _convertMessagesToOpenAIResponsesParams(
682
735
  (toolCall): ResponsesInputItem => ({
683
736
  type: 'function_call',
684
737
  name: toolCall.name,
685
- arguments: JSON.stringify(toolCall.args),
738
+ arguments: serializeToolCallInput(
739
+ toolCall.args,
740
+ MAX_PROVIDER_TOOL_CALL_INPUT_CHARS
741
+ ),
686
742
  call_id: toolCall.id!,
687
743
  ...(zdrEnabled ? { id: functionCallIds?.[toolCall.id!] } : {}),
688
744
  })
@@ -1,5 +1,10 @@
1
1
  import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2
- import { _convertMessagesToOpenAIParams } from './index';
2
+ import {
3
+ _convertMessagesToOpenAIParams,
4
+ _convertMessagesToOpenAIResponsesParams,
5
+ } from './index';
6
+ import { calculateMaxToolCallInputChars } from '@/messages/prune';
7
+ import { HARD_MAX_TOOL_RESULT_CHARS } from '@/utils/truncation';
3
8
 
4
9
  describe('_convertMessagesToOpenAIParams', () => {
5
10
  it('includes reasoning_content for assistant messages in tool-call context when requested', () => {
@@ -156,4 +161,328 @@ describe('_convertMessagesToOpenAIParams', () => {
156
161
  })
157
162
  );
158
163
  });
164
+
165
+ it('bounds structured function-tool output for Chat and Responses adapters', () => {
166
+ let toJSONCalls = 0;
167
+ const toolMessage = new ToolMessage({
168
+ content: [
169
+ { type: 'text', text: 'rendered chart' },
170
+ {
171
+ type: 'image_url',
172
+ image_url: {
173
+ url: `data:image/png;base64,${'A'.repeat(
174
+ HARD_MAX_TOOL_RESULT_CHARS + 1_000
175
+ )}`,
176
+ },
177
+ toJSON() {
178
+ toJSONCalls++;
179
+ return { expanded: 'B'.repeat(HARD_MAX_TOOL_RESULT_CHARS * 2) };
180
+ },
181
+ },
182
+ ],
183
+ tool_call_id: 'call_structured',
184
+ });
185
+
186
+ const chat = _convertMessagesToOpenAIParams([toolMessage]);
187
+ const responses = _convertMessagesToOpenAIResponsesParams([toolMessage]);
188
+ const chatContent = chat[0].content;
189
+
190
+ expect(typeof chatContent).toBe('string');
191
+ expect((chatContent as string).length).toBeLessThanOrEqual(
192
+ HARD_MAX_TOOL_RESULT_CHARS
193
+ );
194
+ expect(responses[0]).toMatchObject({
195
+ type: 'function_call_output',
196
+ call_id: 'call_structured',
197
+ output: chatContent,
198
+ });
199
+ expect(toJSONCalls).toBe(0);
200
+ expect(Array.isArray(toolMessage.content)).toBe(true);
201
+ });
202
+
203
+ it('preserves canonical OpenRouter cache blocks only when requested', () => {
204
+ const toolMessage = new ToolMessage({
205
+ content: [
206
+ {
207
+ type: 'text',
208
+ text: 'result body',
209
+ cache_control: { type: 'ephemeral', ttl: '1h' },
210
+ },
211
+ ],
212
+ tool_call_id: 'call_cached',
213
+ });
214
+
215
+ const defaultContent = _convertMessagesToOpenAIParams([toolMessage])[0]
216
+ .content;
217
+ const openRouterContent = _convertMessagesToOpenAIParams(
218
+ [toolMessage],
219
+ 'anthropic/claude-sonnet-4',
220
+ { preserveToolCacheControl: true }
221
+ )[0].content;
222
+
223
+ expect(typeof defaultContent).toBe('string');
224
+ expect(openRouterContent).toEqual(toolMessage.content);
225
+ });
226
+
227
+ it('bounds direct structured tool-call args without invoking accessors or toJSON', () => {
228
+ const maxInputChars = calculateMaxToolCallInputChars();
229
+ let getterCalls = 0;
230
+ let toJSONCalls = 0;
231
+ const args: Record<string, unknown> = {
232
+ query: 'safe',
233
+ payload: 'x'.repeat(maxInputChars + 1_000),
234
+ };
235
+ Object.defineProperty(args, 'expanded', {
236
+ enumerable: true,
237
+ get() {
238
+ getterCalls++;
239
+ return 'y'.repeat(maxInputChars * 2);
240
+ },
241
+ });
242
+ args.self = args;
243
+ args.toJSON = (): Record<string, string> => {
244
+ toJSONCalls++;
245
+ return { payload: 'z'.repeat(maxInputChars * 2) };
246
+ };
247
+ const message = new AIMessage({
248
+ content: '',
249
+ tool_calls: [
250
+ {
251
+ id: 'call_adversarial',
252
+ name: 'lookup',
253
+ args,
254
+ },
255
+ ],
256
+ });
257
+
258
+ const chat = _convertMessagesToOpenAIParams([message]);
259
+ const responses = _convertMessagesToOpenAIResponsesParams([message]);
260
+ const chatArguments = (
261
+ chat[0] as {
262
+ tool_calls: Array<{ function: { arguments: string } }>;
263
+ }
264
+ ).tool_calls[0].function.arguments;
265
+ const responseArguments = (
266
+ responses.find((item) => item.type === 'function_call') as {
267
+ arguments: string;
268
+ }
269
+ ).arguments;
270
+
271
+ expect(chatArguments).toBe(responseArguments);
272
+ expect(chatArguments.length).toBeLessThanOrEqual(maxInputChars);
273
+ expect(() => JSON.parse(chatArguments)).not.toThrow();
274
+ expect(getterCalls).toBe(0);
275
+ expect(toJSONCalls).toBe(0);
276
+ expect(args.self).toBe(args);
277
+ });
278
+
279
+ it('bounds and synchronizes raw Chat and Responses function-call arguments', () => {
280
+ const maxInputChars = calculateMaxToolCallInputChars();
281
+ const rawArguments = `{"payload":"${'x'.repeat(maxInputChars + 1_000)}"}`;
282
+ const rawOnlyMessage = new AIMessage({
283
+ content: '',
284
+ additional_kwargs: {
285
+ tool_calls: [
286
+ {
287
+ id: 'call_raw',
288
+ type: 'function',
289
+ function: { name: 'lookup', arguments: rawArguments },
290
+ },
291
+ ],
292
+ },
293
+ response_metadata: {
294
+ output: [
295
+ {
296
+ type: 'function_call',
297
+ id: 'fc_raw',
298
+ call_id: 'call_raw',
299
+ name: 'lookup',
300
+ arguments: rawArguments,
301
+ },
302
+ ],
303
+ },
304
+ });
305
+
306
+ const chat = _convertMessagesToOpenAIParams([rawOnlyMessage]);
307
+ const responses = _convertMessagesToOpenAIResponsesParams([rawOnlyMessage]);
308
+ const chatArguments = (
309
+ chat[0] as {
310
+ tool_calls: Array<{ function: { arguments: string } }>;
311
+ }
312
+ ).tool_calls[0].function.arguments;
313
+ const responseArguments = (
314
+ responses.find((item) => item.type === 'function_call') as {
315
+ arguments: string;
316
+ }
317
+ ).arguments;
318
+
319
+ expect(chatArguments.length).toBeLessThanOrEqual(maxInputChars);
320
+ expect(responseArguments.length).toBeLessThanOrEqual(maxInputChars);
321
+ expect(() => JSON.parse(chatArguments)).not.toThrow();
322
+ expect(() => JSON.parse(responseArguments)).not.toThrow();
323
+ expect(
324
+ (
325
+ rawOnlyMessage.additional_kwargs.tool_calls as Array<{
326
+ function: { arguments: string };
327
+ }>
328
+ )[0].function.arguments
329
+ ).toBe(rawArguments);
330
+ expect(
331
+ (
332
+ rawOnlyMessage.response_metadata.output as Array<{
333
+ arguments: string;
334
+ }>
335
+ )[0].arguments
336
+ ).toBe(rawArguments);
337
+ });
338
+
339
+ it('normalizes legacy function calls before wire serialization', () => {
340
+ let toJSONCalls = 0;
341
+ const functionCall = {
342
+ name: 'legacy_lookup',
343
+ arguments: '{}',
344
+ };
345
+ const message = new AIMessage({
346
+ content: '',
347
+ additional_kwargs: {
348
+ function_call: functionCall,
349
+ },
350
+ });
351
+ Object.defineProperty(functionCall, 'toJSON', {
352
+ enumerable: true,
353
+ value: () => {
354
+ toJSONCalls++;
355
+ return {
356
+ name: 'legacy_lookup',
357
+ arguments: 'x'.repeat(500_000),
358
+ };
359
+ },
360
+ });
361
+
362
+ const [chat] = _convertMessagesToOpenAIParams([message]);
363
+ const serialized = JSON.stringify(chat);
364
+
365
+ expect(chat).toMatchObject({
366
+ role: 'assistant',
367
+ function_call: {
368
+ name: 'legacy_lookup',
369
+ arguments: '{}',
370
+ },
371
+ });
372
+ expect(serialized.length).toBeLessThanOrEqual(300);
373
+ expect(toJSONCalls).toBe(0);
374
+ });
375
+
376
+ it('makes Responses raw reuse match the projected LangChain tool-call args', () => {
377
+ const message = new AIMessage({
378
+ content: '',
379
+ tool_calls: [
380
+ {
381
+ id: 'call_synced',
382
+ name: 'lookup',
383
+ args: { query: 'canonical', payload: 'x'.repeat(500_000) },
384
+ },
385
+ ],
386
+ response_metadata: {
387
+ output: [
388
+ {
389
+ type: 'function_call',
390
+ id: 'fc_synced',
391
+ call_id: 'call_synced',
392
+ name: 'lookup',
393
+ arguments: '{"query":"stale"}',
394
+ },
395
+ ],
396
+ },
397
+ });
398
+
399
+ const chat = _convertMessagesToOpenAIParams([message]);
400
+ const responses = _convertMessagesToOpenAIResponsesParams([message]);
401
+ const chatArguments = (
402
+ chat[0] as {
403
+ tool_calls: Array<{ function: { arguments: string } }>;
404
+ }
405
+ ).tool_calls[0].function.arguments;
406
+ const responseArguments = (
407
+ responses.find((item) => item.type === 'function_call') as {
408
+ arguments: string;
409
+ }
410
+ ).arguments;
411
+
412
+ expect(responseArguments).toBe(chatArguments);
413
+ expect(() => JSON.parse(responseArguments)).not.toThrow();
414
+ expect(
415
+ (
416
+ message.response_metadata.output as Array<{
417
+ arguments: string;
418
+ }>
419
+ )[0].arguments
420
+ ).toBe('{"query":"stale"}');
421
+ });
422
+
423
+ it('normalizes native Responses computer-call output screenshots', () => {
424
+ const screenshot = `data:image/png;base64,${'A'.repeat(2_000)}`;
425
+ const messages = [
426
+ new ToolMessage({
427
+ content: screenshot,
428
+ tool_call_id: 'call_computer_string',
429
+ additional_kwargs: { type: 'computer_call_output' },
430
+ }),
431
+ new ToolMessage({
432
+ content: [{ type: 'computer_screenshot', image_url: screenshot }],
433
+ tool_call_id: 'call_computer_structured',
434
+ additional_kwargs: { type: 'computer_call_output' },
435
+ }),
436
+ new ToolMessage({
437
+ content: [{ type: 'input_image', file_id: 'file_screenshot123' }],
438
+ tool_call_id: 'call_computer_file',
439
+ additional_kwargs: { type: 'computer_call_output' },
440
+ }),
441
+ ];
442
+
443
+ expect(_convertMessagesToOpenAIResponsesParams(messages)).toEqual([
444
+ {
445
+ type: 'computer_call_output',
446
+ call_id: 'call_computer_string',
447
+ output: { type: 'computer_screenshot', image_url: screenshot },
448
+ },
449
+ {
450
+ type: 'computer_call_output',
451
+ call_id: 'call_computer_structured',
452
+ output: { type: 'computer_screenshot', image_url: screenshot },
453
+ },
454
+ {
455
+ type: 'computer_call_output',
456
+ call_id: 'call_computer_file',
457
+ output: {
458
+ type: 'computer_screenshot',
459
+ file_id: 'file_screenshot123',
460
+ },
461
+ },
462
+ ]);
463
+ });
464
+
465
+ it('rejects malformed native Responses computer-call output', () => {
466
+ const message = new ToolMessage({
467
+ content: 'not a screenshot URL',
468
+ tool_call_id: 'call_computer_invalid',
469
+ additional_kwargs: { type: 'computer_call_output' },
470
+ });
471
+
472
+ expect(() => _convertMessagesToOpenAIResponsesParams([message])).toThrow(
473
+ 'Invalid computer call output'
474
+ );
475
+ });
476
+
477
+ it('rejects a truncated data URI marked as a computer screenshot', () => {
478
+ const message = new ToolMessage({
479
+ content: 'data:image/png;base64,AAAA\n\n… [truncated: 1000 chars] …',
480
+ tool_call_id: 'call_computer_truncated',
481
+ additional_kwargs: { type: 'computer_call_output' },
482
+ });
483
+
484
+ expect(() => _convertMessagesToOpenAIResponsesParams([message])).toThrow(
485
+ 'Invalid computer call output'
486
+ );
487
+ });
159
488
  });
@@ -32,6 +32,7 @@ export interface ChatOpenRouterCallOptions
32
32
  include_reasoning?: boolean;
33
33
  reasoning?: OpenRouterReasoning;
34
34
  modelKwargs?: OpenAIChatInput['modelKwargs'];
35
+ useResponsesApi?: boolean;
35
36
  promptCache?: boolean;
36
37
  /**
37
38
  * Prompt-cache breakpoint TTL. Defaults to `'1h'` (extended cache) when
@@ -52,6 +53,7 @@ export type OpenRouterInvocationParams = Omit<
52
53
  'messages'
53
54
  > & {
54
55
  reasoning?: OpenRouterReasoning;
56
+ cache_control?: { type: 'ephemeral'; ttl?: '1h' };
55
57
  };
56
58
 
57
59
  type InvocationParamsExtra = {
@@ -150,6 +152,9 @@ export class ChatOpenRouter extends ChatOpenAI {
150
152
  modelKwargs: parentModelKwargs,
151
153
  includeReasoningDetails: true,
152
154
  convertReasoningDetailsToContent: true,
155
+ preserveToolCacheControl: true,
156
+ responsesPromptCache: _fields.promptCache,
157
+ responsesPromptCacheTtl: _fields.promptCacheTtl,
153
158
  });
154
159
 
155
160
  // Merge reasoning config: modelKwargs.reasoning < constructor reasoning
@@ -171,11 +176,16 @@ export class ChatOpenRouter extends ChatOpenAI {
171
176
  type MutableParams = Omit<
172
177
  OpenAIClient.Chat.ChatCompletionCreateParams,
173
178
  'messages'
174
- > & { reasoning_effort?: string; reasoning?: OpenRouterReasoning };
179
+ > & {
180
+ reasoning_effort?: string;
181
+ reasoning?: OpenRouterReasoning;
182
+ cache_control?: { type: 'ephemeral'; ttl?: '1h' };
183
+ };
175
184
 
176
185
  const optionsWithDefaults = this._combineCallOptions(options);
186
+ const useResponsesApi = this._useResponsesApi(options);
177
187
  const params = (
178
- this._useResponsesApi(options)
188
+ useResponsesApi
179
189
  ? this.responses.invocationParams(optionsWithDefaults)
180
190
  : this.completions.invocationParams(optionsWithDefaults, extra)
181
191
  ) as MutableParams;