@librechat/agents 3.3.3 → 3.3.5

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 (274) 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/common/constants.cjs +21 -0
  4. package/dist/cjs/common/constants.cjs.map +1 -1
  5. package/dist/cjs/events.cjs +10 -1
  6. package/dist/cjs/events.cjs.map +1 -1
  7. package/dist/cjs/graphs/Graph.cjs +793 -111
  8. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  9. package/dist/cjs/graphs/MultiAgentGraph.cjs +3 -1
  10. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  11. package/dist/cjs/hooks/HookRegistry.cjs +25 -0
  12. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  13. package/dist/cjs/hooks/index.cjs +12 -0
  14. package/dist/cjs/hooks/index.cjs.map +1 -1
  15. package/dist/cjs/hooks/types.cjs +1 -0
  16. package/dist/cjs/hooks/types.cjs.map +1 -1
  17. package/dist/cjs/langfuse.cjs +8 -0
  18. package/dist/cjs/langfuse.cjs.map +1 -1
  19. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +79 -6
  20. package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
  21. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs +38 -13
  22. package/dist/cjs/llm/bedrock/utils/message_inputs.cjs.map +1 -1
  23. package/dist/cjs/llm/google/utils/common.cjs +19 -7
  24. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  25. package/dist/cjs/llm/invoke.cjs +331 -5
  26. package/dist/cjs/llm/invoke.cjs.map +1 -1
  27. package/dist/cjs/llm/openai/index.cjs +41 -5
  28. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  29. package/dist/cjs/llm/openai/utils/index.cjs +25 -4
  30. package/dist/cjs/llm/openai/utils/index.cjs.map +1 -1
  31. package/dist/cjs/llm/openrouter/index.cjs +4 -1
  32. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  33. package/dist/cjs/llm/preempt.cjs +132 -0
  34. package/dist/cjs/llm/preempt.cjs.map +1 -0
  35. package/dist/cjs/main.cjs +36 -5
  36. package/dist/cjs/messages/alternation.cjs +92 -0
  37. package/dist/cjs/messages/alternation.cjs.map +1 -0
  38. package/dist/cjs/messages/cache.cjs +54 -33
  39. package/dist/cjs/messages/cache.cjs.map +1 -1
  40. package/dist/cjs/messages/contextPruning.cjs +17 -43
  41. package/dist/cjs/messages/contextPruning.cjs.map +1 -1
  42. package/dist/cjs/messages/core.cjs +315 -23
  43. package/dist/cjs/messages/core.cjs.map +1 -1
  44. package/dist/cjs/messages/format.cjs +290 -60
  45. package/dist/cjs/messages/format.cjs.map +1 -1
  46. package/dist/cjs/messages/handoffCue.cjs +63 -0
  47. package/dist/cjs/messages/handoffCue.cjs.map +1 -0
  48. package/dist/cjs/messages/index.cjs +4 -1
  49. package/dist/cjs/messages/injected.cjs +60 -0
  50. package/dist/cjs/messages/injected.cjs.map +1 -0
  51. package/dist/cjs/messages/prune.cjs +503 -127
  52. package/dist/cjs/messages/prune.cjs.map +1 -1
  53. package/dist/cjs/run.cjs +80 -7
  54. package/dist/cjs/run.cjs.map +1 -1
  55. package/dist/cjs/session/handlers.cjs +18 -9
  56. package/dist/cjs/session/handlers.cjs.map +1 -1
  57. package/dist/cjs/stream.cjs +35 -10
  58. package/dist/cjs/stream.cjs.map +1 -1
  59. package/dist/cjs/summarization/node.cjs +28 -14
  60. package/dist/cjs/summarization/node.cjs.map +1 -1
  61. package/dist/cjs/tools/ToolNode.cjs +56 -63
  62. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  63. package/dist/cjs/tools/search/keenable-scraper.cjs +90 -0
  64. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -0
  65. package/dist/cjs/tools/search/tool.cjs +9 -1
  66. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  67. package/dist/cjs/tools/toolOutputReferences.cjs +6 -0
  68. package/dist/cjs/tools/toolOutputReferences.cjs.map +1 -1
  69. package/dist/cjs/utils/index.cjs +1 -1
  70. package/dist/cjs/utils/llm.cjs +1 -1
  71. package/dist/cjs/utils/llm.cjs.map +1 -1
  72. package/dist/cjs/utils/tokens.cjs +307 -20
  73. package/dist/cjs/utils/tokens.cjs.map +1 -1
  74. package/dist/cjs/utils/toolContent.cjs +1514 -0
  75. package/dist/cjs/utils/toolContent.cjs.map +1 -0
  76. package/dist/esm/agents/AgentContext.mjs +2 -1
  77. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  78. package/dist/esm/common/constants.mjs +19 -1
  79. package/dist/esm/common/constants.mjs.map +1 -1
  80. package/dist/esm/events.mjs +10 -1
  81. package/dist/esm/events.mjs.map +1 -1
  82. package/dist/esm/graphs/Graph.mjs +798 -116
  83. package/dist/esm/graphs/Graph.mjs.map +1 -1
  84. package/dist/esm/graphs/MultiAgentGraph.mjs +3 -1
  85. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  86. package/dist/esm/hooks/HookRegistry.mjs +25 -0
  87. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  88. package/dist/esm/hooks/index.mjs +12 -1
  89. package/dist/esm/hooks/index.mjs.map +1 -1
  90. package/dist/esm/hooks/types.mjs +1 -0
  91. package/dist/esm/hooks/types.mjs.map +1 -1
  92. package/dist/esm/langfuse.mjs +9 -1
  93. package/dist/esm/langfuse.mjs.map +1 -1
  94. package/dist/esm/llm/anthropic/utils/message_inputs.mjs +79 -6
  95. package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
  96. package/dist/esm/llm/bedrock/utils/message_inputs.mjs +38 -13
  97. package/dist/esm/llm/bedrock/utils/message_inputs.mjs.map +1 -1
  98. package/dist/esm/llm/google/utils/common.mjs +19 -7
  99. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  100. package/dist/esm/llm/invoke.mjs +332 -8
  101. package/dist/esm/llm/invoke.mjs.map +1 -1
  102. package/dist/esm/llm/openai/index.mjs +41 -5
  103. package/dist/esm/llm/openai/index.mjs.map +1 -1
  104. package/dist/esm/llm/openai/utils/index.mjs +25 -4
  105. package/dist/esm/llm/openai/utils/index.mjs.map +1 -1
  106. package/dist/esm/llm/openrouter/index.mjs +4 -1
  107. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  108. package/dist/esm/llm/preempt.mjs +131 -0
  109. package/dist/esm/llm/preempt.mjs.map +1 -0
  110. package/dist/esm/main.mjs +14 -11
  111. package/dist/esm/messages/alternation.mjs +91 -0
  112. package/dist/esm/messages/alternation.mjs.map +1 -0
  113. package/dist/esm/messages/cache.mjs +55 -34
  114. package/dist/esm/messages/cache.mjs.map +1 -1
  115. package/dist/esm/messages/contextPruning.mjs +17 -43
  116. package/dist/esm/messages/contextPruning.mjs.map +1 -1
  117. package/dist/esm/messages/core.mjs +305 -24
  118. package/dist/esm/messages/core.mjs.map +1 -1
  119. package/dist/esm/messages/format.mjs +290 -61
  120. package/dist/esm/messages/format.mjs.map +1 -1
  121. package/dist/esm/messages/handoffCue.mjs +61 -0
  122. package/dist/esm/messages/handoffCue.mjs.map +1 -0
  123. package/dist/esm/messages/index.mjs +4 -1
  124. package/dist/esm/messages/injected.mjs +60 -0
  125. package/dist/esm/messages/injected.mjs.map +1 -0
  126. package/dist/esm/messages/prune.mjs +500 -128
  127. package/dist/esm/messages/prune.mjs.map +1 -1
  128. package/dist/esm/run.mjs +80 -7
  129. package/dist/esm/run.mjs.map +1 -1
  130. package/dist/esm/session/handlers.mjs +19 -10
  131. package/dist/esm/session/handlers.mjs.map +1 -1
  132. package/dist/esm/stream.mjs +34 -11
  133. package/dist/esm/stream.mjs.map +1 -1
  134. package/dist/esm/summarization/node.mjs +28 -14
  135. package/dist/esm/summarization/node.mjs.map +1 -1
  136. package/dist/esm/tools/ToolNode.mjs +56 -63
  137. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  138. package/dist/esm/tools/search/keenable-scraper.mjs +88 -0
  139. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -0
  140. package/dist/esm/tools/search/tool.mjs +9 -1
  141. package/dist/esm/tools/search/tool.mjs.map +1 -1
  142. package/dist/esm/tools/toolOutputReferences.mjs +6 -0
  143. package/dist/esm/tools/toolOutputReferences.mjs.map +1 -1
  144. package/dist/esm/utils/index.mjs +1 -1
  145. package/dist/esm/utils/llm.mjs +1 -1
  146. package/dist/esm/utils/llm.mjs.map +1 -1
  147. package/dist/esm/utils/tokens.mjs +307 -21
  148. package/dist/esm/utils/tokens.mjs.map +1 -1
  149. package/dist/esm/utils/toolContent.mjs +1503 -0
  150. package/dist/esm/utils/toolContent.mjs.map +1 -0
  151. package/dist/types/common/constants.d.ts +18 -0
  152. package/dist/types/graphs/Graph.d.ts +139 -2
  153. package/dist/types/hooks/HookRegistry.d.ts +15 -0
  154. package/dist/types/hooks/index.d.ts +12 -1
  155. package/dist/types/hooks/types.d.ts +45 -6
  156. package/dist/types/index.d.ts +1 -1
  157. package/dist/types/llm/invoke.d.ts +34 -1
  158. package/dist/types/llm/openai/index.d.ts +4 -0
  159. package/dist/types/llm/openai/utils/index.d.ts +2 -0
  160. package/dist/types/llm/openrouter/index.d.ts +5 -0
  161. package/dist/types/llm/preempt.d.ts +40 -0
  162. package/dist/types/messages/alternation.d.ts +23 -0
  163. package/dist/types/messages/contextPruning.d.ts +2 -1
  164. package/dist/types/messages/core.d.ts +36 -0
  165. package/dist/types/messages/format.d.ts +6 -0
  166. package/dist/types/messages/handoffCue.d.ts +40 -0
  167. package/dist/types/messages/index.d.ts +3 -0
  168. package/dist/types/messages/injected.d.ts +3 -0
  169. package/dist/types/messages/prune.d.ts +16 -12
  170. package/dist/types/run.d.ts +7 -0
  171. package/dist/types/stream.d.ts +14 -0
  172. package/dist/types/tools/ToolNode.d.ts +0 -7
  173. package/dist/types/tools/search/keenable-scraper.d.ts +15 -0
  174. package/dist/types/tools/search/types.d.ts +31 -2
  175. package/dist/types/types/graph.d.ts +8 -1
  176. package/dist/types/types/run.d.ts +65 -0
  177. package/dist/types/types/stream.d.ts +1 -26
  178. package/dist/types/utils/tokens.d.ts +7 -0
  179. package/dist/types/utils/toolContent.d.ts +107 -0
  180. package/package.json +1 -1
  181. package/src/__tests__/stream.eagerEventExecution.test.ts +74 -0
  182. package/src/agents/AgentContext.ts +1 -0
  183. package/src/{splitStream.test.ts → aggregator.test.ts} +59 -666
  184. package/src/common/constants.ts +21 -0
  185. package/src/events.ts +15 -1
  186. package/src/graphs/Graph.ts +1261 -198
  187. package/src/graphs/MultiAgentGraph.ts +6 -1
  188. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +1139 -4
  189. package/src/graphs/__tests__/Graph.preemptSignal.test.ts +126 -0
  190. package/src/hooks/HookRegistry.ts +40 -0
  191. package/src/hooks/__tests__/preemptBoundary.test.ts +152 -0
  192. package/src/hooks/index.ts +16 -2
  193. package/src/hooks/types.ts +47 -3
  194. package/src/index.ts +1 -1
  195. package/src/langfuse.ts +26 -1
  196. package/src/llm/anthropic/utils/message_inputs.ts +158 -12
  197. package/src/llm/anthropic/utils/tool-id-normalization.test.ts +109 -0
  198. package/src/llm/bedrock/utils/message_inputs.test.ts +131 -1
  199. package/src/llm/bedrock/utils/message_inputs.ts +100 -24
  200. package/src/llm/bedrock/utils/toolResultCachePoint.test.ts +15 -0
  201. package/src/llm/google/utils/common.test.ts +75 -9
  202. package/src/llm/google/utils/common.ts +34 -3
  203. package/src/llm/invoke.alternation.test.ts +87 -0
  204. package/src/llm/invoke.handoffCue.test.ts +112 -0
  205. package/src/llm/invoke.test.ts +488 -0
  206. package/src/llm/invoke.ts +611 -8
  207. package/src/llm/openai/index.ts +106 -6
  208. package/src/llm/openai/utils/index.ts +109 -53
  209. package/src/llm/openai/utils/messages.test.ts +330 -1
  210. package/src/llm/openrouter/index.ts +12 -2
  211. package/src/llm/openrouter/reasoning.test.ts +314 -0
  212. package/src/llm/preempt.test.ts +323 -0
  213. package/src/llm/preempt.ts +178 -0
  214. package/src/messages/__tests__/observationMasking.test.ts +93 -2
  215. package/src/messages/alternation.test.ts +212 -0
  216. package/src/messages/alternation.ts +112 -0
  217. package/src/messages/cache.tail.test.ts +193 -0
  218. package/src/messages/cache.test.ts +113 -0
  219. package/src/messages/cache.ts +92 -60
  220. package/src/messages/contextPruning.test.ts +184 -0
  221. package/src/messages/contextPruning.ts +49 -42
  222. package/src/messages/core.ts +653 -40
  223. package/src/messages/ensureThinkingBlock.test.ts +49 -1
  224. package/src/messages/foldToollessToolBlocks.test.ts +163 -5
  225. package/src/messages/format.ts +454 -91
  226. package/src/messages/formatAgentMessages.steer.test.ts +267 -0
  227. package/src/messages/formatAgentMessages.test.ts +604 -14
  228. package/src/messages/formatAgentMessages.tools.test.ts +103 -0
  229. package/src/messages/handoffCue.test.ts +96 -0
  230. package/src/messages/handoffCue.ts +78 -0
  231. package/src/messages/index.ts +3 -0
  232. package/src/messages/injected.test.ts +90 -0
  233. package/src/messages/injected.ts +74 -0
  234. package/src/messages/prune.ts +996 -183
  235. package/src/run.ts +91 -6
  236. package/src/scripts/preempt-probe.ts +330 -0
  237. package/src/scripts/preempt-scenarios.ts +388 -0
  238. package/src/session/handlers.ts +32 -12
  239. package/src/specs/handoffCue.test.ts +165 -0
  240. package/src/specs/langfuse-callbacks.test.ts +352 -2
  241. package/src/specs/preemptSeal.test.ts +309 -0
  242. package/src/specs/prune.test.ts +1083 -6
  243. package/src/specs/summarization-unit.test.ts +105 -0
  244. package/src/specs/tokens.test.ts +609 -32
  245. package/src/stream.dispatch.test.ts +63 -0
  246. package/src/stream.ts +59 -23
  247. package/src/summarization/__tests__/node.test.ts +77 -0
  248. package/src/summarization/node.ts +46 -18
  249. package/src/tools/ToolNode.ts +150 -99
  250. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +148 -1
  251. package/src/tools/__tests__/ToolNode.outputReferences.test.ts +112 -3
  252. package/src/tools/__tests__/annotateMessagesForLLM.test.ts +32 -0
  253. package/src/tools/__tests__/directToolHooks.test.ts +255 -7
  254. package/src/tools/search/keenable-scraper.test.ts +153 -0
  255. package/src/tools/search/keenable-scraper.ts +137 -0
  256. package/src/tools/search/tool.ts +13 -2
  257. package/src/tools/search/types.ts +50 -3
  258. package/src/tools/toolOutputReferences.ts +6 -0
  259. package/src/types/graph.ts +12 -1
  260. package/src/types/run.ts +67 -0
  261. package/src/types/stream.ts +1 -42
  262. package/src/utils/llm.test.ts +18 -0
  263. package/src/utils/llm.ts +4 -1
  264. package/src/utils/tokens.ts +586 -31
  265. package/src/utils/toolContent.test.ts +1432 -0
  266. package/src/utils/toolContent.ts +2707 -0
  267. package/dist/cjs/splitStream.cjs +0 -151
  268. package/dist/cjs/splitStream.cjs.map +0 -1
  269. package/dist/esm/splitStream.mjs +0 -150
  270. package/dist/esm/splitStream.mjs.map +0 -1
  271. package/dist/types/mockStream.d.ts +0 -32
  272. package/dist/types/splitStream.d.ts +0 -37
  273. package/src/mockStream.ts +0 -99
  274. package/src/splitStream.ts +0 -234
@@ -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;
@@ -1,5 +1,10 @@
1
+ import { tools as openAITools } from '@langchain/openai';
2
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
1
3
  import type { OpenAIChatInput } from '@langchain/openai';
2
4
  import type { OpenRouterReasoning, ChatOpenRouterCallOptions } from './index';
5
+ import type { GraphTools } from '@/types';
6
+ import { partitionAndMarkOpenRouterToolCache } from './toolCache';
7
+ import { addTailCacheControl } from '@/messages/cache';
3
8
  import { ChatOpenRouter } from './index';
4
9
 
5
10
  type CreateRouterOptions = Partial<
@@ -245,3 +250,312 @@ describe('ChatOpenRouter reasoning handling', () => {
245
250
  });
246
251
  });
247
252
  });
253
+
254
+ describe('ChatOpenRouter Responses prompt caching', () => {
255
+ it('maps promptCache to the Responses top-level cache control', () => {
256
+ const router = new ChatOpenRouter({
257
+ model: 'anthropic/claude-sonnet-4',
258
+ apiKey: 'test-key',
259
+ useResponsesApi: true,
260
+ promptCache: true,
261
+ promptCacheTtl: '1h',
262
+ });
263
+
264
+ expect(router.invocationParams().cache_control).toEqual({
265
+ type: 'ephemeral',
266
+ ttl: '1h',
267
+ });
268
+ });
269
+
270
+ it('sends cache control on the actual Responses request', async () => {
271
+ const router = new ChatOpenRouter({
272
+ model: 'openai/gpt-4.1',
273
+ apiKey: 'test-key',
274
+ useResponsesApi: true,
275
+ promptCache: true,
276
+ promptCacheTtl: '1h',
277
+ streaming: false,
278
+ });
279
+ const sentinel = new Error('captured request');
280
+ let capturedRequest: unknown;
281
+ const responses = (
282
+ router as unknown as {
283
+ responses: {
284
+ completionWithRetry: (request: unknown) => Promise<never>;
285
+ };
286
+ }
287
+ ).responses;
288
+ responses.completionWithRetry = async (request) => {
289
+ capturedRequest = request;
290
+ throw sentinel;
291
+ };
292
+
293
+ await expect(router.invoke([new HumanMessage('hello')])).rejects.toThrow(
294
+ sentinel
295
+ );
296
+ expect(capturedRequest).toMatchObject({
297
+ cache_control: { type: 'ephemeral', ttl: '1h' },
298
+ input: [{ type: 'message', role: 'user', content: 'hello' }],
299
+ });
300
+ });
301
+
302
+ it('honors per-call Responses cache overrides', async () => {
303
+ const router = new ChatOpenRouter({
304
+ model: 'openai/gpt-4.1',
305
+ apiKey: 'test-key',
306
+ useResponsesApi: true,
307
+ promptCache: false,
308
+ streaming: false,
309
+ });
310
+ const sentinel = new Error('captured override');
311
+ let capturedRequest: unknown;
312
+ const responses = (
313
+ router as unknown as {
314
+ responses: {
315
+ completionWithRetry: (request: unknown) => Promise<never>;
316
+ };
317
+ }
318
+ ).responses;
319
+ responses.completionWithRetry = async (request) => {
320
+ capturedRequest = request;
321
+ throw sentinel;
322
+ };
323
+
324
+ await expect(
325
+ router.invoke([new HumanMessage('hello')], {
326
+ promptCache: true,
327
+ promptCacheTtl: '5m',
328
+ } as unknown as Parameters<typeof router.invoke>[1])
329
+ ).rejects.toThrow(sentinel);
330
+ expect(capturedRequest).toMatchObject({
331
+ cache_control: { type: 'ephemeral' },
332
+ });
333
+ expect(JSON.stringify(capturedRequest)).not.toContain('"ttl"');
334
+ });
335
+
336
+ it('removes tail block markers added after invocation normalization', async () => {
337
+ const router = new ChatOpenRouter({
338
+ model: 'openai/gpt-4.1',
339
+ apiKey: 'test-key',
340
+ useResponsesApi: true,
341
+ promptCache: true,
342
+ streaming: false,
343
+ });
344
+ const sentinel = new Error('captured tail cache');
345
+ let capturedRequest: unknown;
346
+ const responses = (
347
+ router as unknown as {
348
+ responses: {
349
+ completionWithRetry: (request: unknown) => Promise<never>;
350
+ };
351
+ }
352
+ ).responses;
353
+ responses.completionWithRetry = async (request) => {
354
+ capturedRequest = request;
355
+ throw sentinel;
356
+ };
357
+ const messages = addTailCacheControl([
358
+ new HumanMessage('search'),
359
+ new AIMessage({
360
+ content: '',
361
+ tool_calls: [
362
+ {
363
+ id: 'call_cached_result',
364
+ name: 'search',
365
+ args: {},
366
+ type: 'tool_call',
367
+ },
368
+ ],
369
+ }),
370
+ new ToolMessage({
371
+ content: 'result body',
372
+ tool_call_id: 'call_cached_result',
373
+ name: 'search',
374
+ }),
375
+ ]);
376
+
377
+ await expect(router.invoke(messages)).rejects.toThrow(sentinel);
378
+
379
+ const request = capturedRequest as {
380
+ cache_control?: unknown;
381
+ input?: Array<{ type?: string; output?: unknown }>;
382
+ };
383
+ expect(
384
+ request.input?.find((item) => item.type === 'function_call_output')
385
+ ).toMatchObject({ output: 'result body' });
386
+ expect(request.cache_control).toEqual({
387
+ type: 'ephemeral',
388
+ ttl: '1h',
389
+ });
390
+ expect(JSON.stringify(request.input)).not.toContain('cache_control');
391
+ });
392
+
393
+ it('normalizes cache markers for native Responses event streaming', async () => {
394
+ const router = new ChatOpenRouter({
395
+ model: 'openai/gpt-4.1',
396
+ apiKey: 'test-key',
397
+ useResponsesApi: true,
398
+ promptCache: true,
399
+ streaming: true,
400
+ });
401
+ let capturedRequest: unknown;
402
+ const responses = (
403
+ router as unknown as {
404
+ responses: {
405
+ completionWithRetry: (
406
+ request: unknown
407
+ ) => Promise<AsyncIterable<never>>;
408
+ _streamChatModelEvents: (
409
+ messages: unknown[],
410
+ options: Record<string, unknown>
411
+ ) => AsyncIterable<unknown>;
412
+ };
413
+ }
414
+ ).responses;
415
+ responses.completionWithRetry = async (request) => {
416
+ capturedRequest = request;
417
+ return {
418
+ async *[Symbol.asyncIterator]() {
419
+ yield* [];
420
+ },
421
+ };
422
+ };
423
+ const messages = addTailCacheControl([
424
+ new AIMessage({
425
+ content: '',
426
+ tool_calls: [
427
+ {
428
+ id: 'call_stream_cache',
429
+ name: 'search',
430
+ args: {},
431
+ type: 'tool_call',
432
+ },
433
+ ],
434
+ }),
435
+ new ToolMessage({
436
+ content: 'stream result',
437
+ tool_call_id: 'call_stream_cache',
438
+ name: 'search',
439
+ }),
440
+ ]);
441
+
442
+ for await (const _event of responses._streamChatModelEvents(messages, {})) {
443
+ // The stubbed provider stream is empty; consuming it triggers request creation.
444
+ }
445
+
446
+ const request = capturedRequest as {
447
+ input?: Array<{ type?: string; output?: unknown }>;
448
+ cache_control?: unknown;
449
+ };
450
+ expect(
451
+ request.input?.find((item) => item.type === 'function_call_output')
452
+ ).toMatchObject({ output: 'stream result' });
453
+ expect(request.cache_control).toEqual({
454
+ type: 'ephemeral',
455
+ ttl: '1h',
456
+ });
457
+ expect(JSON.stringify(request.input)).not.toContain('cache_control');
458
+ });
459
+
460
+ it('removes Chat-style tool cache markers from Responses tools', async () => {
461
+ const router = new ChatOpenRouter({
462
+ model: 'openai/gpt-4.1',
463
+ apiKey: 'test-key',
464
+ useResponsesApi: true,
465
+ promptCache: true,
466
+ streaming: false,
467
+ });
468
+ const sentinel = new Error('captured tool cache');
469
+ let capturedRequest: unknown;
470
+ const responses = (
471
+ router as unknown as {
472
+ responses: {
473
+ completionWithRetry: (request: unknown) => Promise<never>;
474
+ };
475
+ }
476
+ ).responses;
477
+ responses.completionWithRetry = async (request) => {
478
+ capturedRequest = request;
479
+ throw sentinel;
480
+ };
481
+ const markedTools = partitionAndMarkOpenRouterToolCache(
482
+ [
483
+ {
484
+ type: 'function',
485
+ function: {
486
+ name: 'search',
487
+ description: 'Search records',
488
+ parameters: { type: 'object', properties: {} },
489
+ },
490
+ },
491
+ ] as GraphTools,
492
+ () => false,
493
+ '1h'
494
+ )!;
495
+ const bound = router.bindTools(markedTools);
496
+
497
+ await expect(bound.invoke([new HumanMessage('search')])).rejects.toThrow(
498
+ sentinel
499
+ );
500
+
501
+ const request = capturedRequest as {
502
+ cache_control?: unknown;
503
+ tools?: unknown[];
504
+ };
505
+ expect(request.cache_control).toEqual({
506
+ type: 'ephemeral',
507
+ ttl: '1h',
508
+ });
509
+ expect(JSON.stringify(request.tools)).not.toContain('cache_control');
510
+ });
511
+
512
+ it('keeps top-level cache control when a computer tool auto-selects Responses', async () => {
513
+ const router = new ChatOpenRouter({
514
+ model: 'openai/gpt-4.1',
515
+ apiKey: 'test-key',
516
+ useResponsesApi: false,
517
+ promptCache: true,
518
+ streaming: false,
519
+ });
520
+ const sentinel = new Error('captured computer cache');
521
+ let capturedRequest: unknown;
522
+ const responses = (
523
+ router as unknown as {
524
+ responses: {
525
+ completionWithRetry: (request: unknown) => Promise<never>;
526
+ };
527
+ }
528
+ ).responses;
529
+ responses.completionWithRetry = async (request) => {
530
+ capturedRequest = request;
531
+ throw sentinel;
532
+ };
533
+ const bound = router.bindTools([
534
+ openAITools.computerUse({
535
+ displayWidth: 1024,
536
+ displayHeight: 768,
537
+ environment: 'browser',
538
+ execute: async () => 'data:image/png;base64,AA==',
539
+ }),
540
+ ]);
541
+
542
+ await expect(bound.invoke([new HumanMessage('inspect')])).rejects.toThrow(
543
+ sentinel
544
+ );
545
+
546
+ expect(capturedRequest).toMatchObject({
547
+ cache_control: { type: 'ephemeral', ttl: '1h' },
548
+ });
549
+ });
550
+
551
+ it('keeps Chat prompt caching on content blocks', () => {
552
+ const router = new ChatOpenRouter({
553
+ model: 'anthropic/claude-sonnet-4',
554
+ apiKey: 'test-key',
555
+ useResponsesApi: false,
556
+ promptCache: true,
557
+ });
558
+
559
+ expect(router.invocationParams().cache_control).toBeUndefined();
560
+ });
561
+ });