@librechat/agents 3.3.10 → 3.3.12

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 (211) hide show
  1. package/dist/cjs/graphs/Graph.cjs +248 -27
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/langfuseToolOutputTracing.cjs +228 -16
  4. package/dist/cjs/langfuseToolOutputTracing.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/index.cjs +13 -2
  6. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  7. package/dist/cjs/llm/init.cjs +1 -1
  8. package/dist/cjs/llm/invoke.cjs +160 -43
  9. package/dist/cjs/llm/invoke.cjs.map +1 -1
  10. package/dist/cjs/llm/openai/index.cjs +190 -13
  11. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  12. package/dist/cjs/llm/streamLimits.cjs +723 -0
  13. package/dist/cjs/llm/streamLimits.cjs.map +1 -0
  14. package/dist/cjs/main.cjs +12 -3
  15. package/dist/cjs/messages/core.cjs +592 -27
  16. package/dist/cjs/messages/core.cjs.map +1 -1
  17. package/dist/cjs/run.cjs +7 -3
  18. package/dist/cjs/run.cjs.map +1 -1
  19. package/dist/cjs/session/AgentSession.cjs +4 -1
  20. package/dist/cjs/session/AgentSession.cjs.map +1 -1
  21. package/dist/cjs/stream.cjs +105 -14
  22. package/dist/cjs/stream.cjs.map +1 -1
  23. package/dist/cjs/summarization/node.cjs +157 -37
  24. package/dist/cjs/summarization/node.cjs.map +1 -1
  25. package/dist/cjs/tools/BashExecutor.cjs +3 -2
  26. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  27. package/dist/cjs/tools/CodeExecutor.cjs +4 -3
  28. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  29. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +5 -3
  30. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  31. package/dist/cjs/tools/ToolNode.cjs +77 -4
  32. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  33. package/dist/cjs/tools/ToolSearch.cjs +3 -2
  34. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  35. package/dist/cjs/tools/search/crw-scraper.cjs +7 -1
  36. package/dist/cjs/tools/search/crw-scraper.cjs.map +1 -1
  37. package/dist/cjs/tools/search/crw-search.cjs +3 -1
  38. package/dist/cjs/tools/search/crw-search.cjs.map +1 -1
  39. package/dist/cjs/tools/search/firecrawl.cjs +7 -1
  40. package/dist/cjs/tools/search/firecrawl.cjs.map +1 -1
  41. package/dist/cjs/tools/search/keenable-scraper.cjs +7 -1
  42. package/dist/cjs/tools/search/keenable-scraper.cjs.map +1 -1
  43. package/dist/cjs/tools/search/keenable-search.cjs +3 -1
  44. package/dist/cjs/tools/search/keenable-search.cjs.map +1 -1
  45. package/dist/cjs/tools/search/rerankers.cjs +26 -8
  46. package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
  47. package/dist/cjs/tools/search/search.cjs +30 -10
  48. package/dist/cjs/tools/search/search.cjs.map +1 -1
  49. package/dist/cjs/tools/search/serper-scraper.cjs +7 -1
  50. package/dist/cjs/tools/search/serper-scraper.cjs.map +1 -1
  51. package/dist/cjs/tools/search/tavily-scraper.cjs +7 -1
  52. package/dist/cjs/tools/search/tavily-scraper.cjs.map +1 -1
  53. package/dist/cjs/tools/search/tavily-search.cjs +3 -1
  54. package/dist/cjs/tools/search/tavily-search.cjs.map +1 -1
  55. package/dist/cjs/tools/search/tool.cjs +17 -3
  56. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  57. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +54 -3
  58. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  59. package/dist/cjs/utils/index.cjs +2 -1
  60. package/dist/cjs/utils/misc.cjs +12 -0
  61. package/dist/cjs/utils/misc.cjs.map +1 -1
  62. package/dist/cjs/utils/proxy.cjs +63 -0
  63. package/dist/cjs/utils/proxy.cjs.map +1 -0
  64. package/dist/esm/graphs/Graph.mjs +247 -26
  65. package/dist/esm/graphs/Graph.mjs.map +1 -1
  66. package/dist/esm/langfuseToolOutputTracing.mjs +228 -16
  67. package/dist/esm/langfuseToolOutputTracing.mjs.map +1 -1
  68. package/dist/esm/llm/bedrock/index.mjs +13 -2
  69. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  70. package/dist/esm/llm/init.mjs +1 -1
  71. package/dist/esm/llm/invoke.mjs +160 -43
  72. package/dist/esm/llm/invoke.mjs.map +1 -1
  73. package/dist/esm/llm/openai/index.mjs +192 -15
  74. package/dist/esm/llm/openai/index.mjs.map +1 -1
  75. package/dist/esm/llm/streamLimits.mjs +704 -0
  76. package/dist/esm/llm/streamLimits.mjs.map +1 -0
  77. package/dist/esm/main.mjs +8 -6
  78. package/dist/esm/messages/core.mjs +592 -28
  79. package/dist/esm/messages/core.mjs.map +1 -1
  80. package/dist/esm/run.mjs +7 -3
  81. package/dist/esm/run.mjs.map +1 -1
  82. package/dist/esm/session/AgentSession.mjs +4 -1
  83. package/dist/esm/session/AgentSession.mjs.map +1 -1
  84. package/dist/esm/stream.mjs +105 -14
  85. package/dist/esm/stream.mjs.map +1 -1
  86. package/dist/esm/summarization/node.mjs +157 -37
  87. package/dist/esm/summarization/node.mjs.map +1 -1
  88. package/dist/esm/tools/BashExecutor.mjs +3 -2
  89. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  90. package/dist/esm/tools/CodeExecutor.mjs +4 -3
  91. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  92. package/dist/esm/tools/ProgrammaticToolCalling.mjs +5 -3
  93. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  94. package/dist/esm/tools/ToolNode.mjs +77 -4
  95. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  96. package/dist/esm/tools/ToolSearch.mjs +3 -2
  97. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  98. package/dist/esm/tools/search/crw-scraper.mjs +7 -1
  99. package/dist/esm/tools/search/crw-scraper.mjs.map +1 -1
  100. package/dist/esm/tools/search/crw-search.mjs +3 -1
  101. package/dist/esm/tools/search/crw-search.mjs.map +1 -1
  102. package/dist/esm/tools/search/firecrawl.mjs +7 -1
  103. package/dist/esm/tools/search/firecrawl.mjs.map +1 -1
  104. package/dist/esm/tools/search/keenable-scraper.mjs +7 -1
  105. package/dist/esm/tools/search/keenable-scraper.mjs.map +1 -1
  106. package/dist/esm/tools/search/keenable-search.mjs +3 -1
  107. package/dist/esm/tools/search/keenable-search.mjs.map +1 -1
  108. package/dist/esm/tools/search/rerankers.mjs +26 -8
  109. package/dist/esm/tools/search/rerankers.mjs.map +1 -1
  110. package/dist/esm/tools/search/search.mjs +30 -10
  111. package/dist/esm/tools/search/search.mjs.map +1 -1
  112. package/dist/esm/tools/search/serper-scraper.mjs +7 -1
  113. package/dist/esm/tools/search/serper-scraper.mjs.map +1 -1
  114. package/dist/esm/tools/search/tavily-scraper.mjs +7 -1
  115. package/dist/esm/tools/search/tavily-scraper.mjs.map +1 -1
  116. package/dist/esm/tools/search/tavily-search.mjs +3 -1
  117. package/dist/esm/tools/search/tavily-search.mjs.map +1 -1
  118. package/dist/esm/tools/search/tool.mjs +17 -3
  119. package/dist/esm/tools/search/tool.mjs.map +1 -1
  120. package/dist/esm/tools/subagent/SubagentExecutor.mjs +54 -3
  121. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  122. package/dist/esm/utils/index.mjs +2 -1
  123. package/dist/esm/utils/misc.mjs +12 -1
  124. package/dist/esm/utils/misc.mjs.map +1 -1
  125. package/dist/esm/utils/proxy.mjs +62 -0
  126. package/dist/esm/utils/proxy.mjs.map +1 -0
  127. package/dist/types/graphs/Graph.d.ts +53 -1
  128. package/dist/types/index.d.ts +2 -0
  129. package/dist/types/langfuseToolOutputTracing.d.ts +1 -0
  130. package/dist/types/llm/invoke.d.ts +26 -5
  131. package/dist/types/llm/openai/index.d.ts +3 -0
  132. package/dist/types/llm/streamLimits.d.ts +314 -0
  133. package/dist/types/messages/core.d.ts +11 -6
  134. package/dist/types/run.d.ts +1 -0
  135. package/dist/types/summarization/node.d.ts +27 -2
  136. package/dist/types/tools/BashExecutor.d.ts +2 -2
  137. package/dist/types/tools/CodeExecutor.d.ts +3 -3
  138. package/dist/types/tools/ToolNode.d.ts +11 -1
  139. package/dist/types/tools/search/crw-scraper.d.ts +2 -0
  140. package/dist/types/tools/search/firecrawl.d.ts +2 -0
  141. package/dist/types/tools/search/keenable-scraper.d.ts +2 -0
  142. package/dist/types/tools/search/rerankers.d.ts +9 -5
  143. package/dist/types/tools/search/serper-scraper.d.ts +2 -0
  144. package/dist/types/tools/search/tavily-scraper.d.ts +2 -0
  145. package/dist/types/tools/search/types.d.ts +28 -33
  146. package/dist/types/tools/subagent/SubagentExecutor.d.ts +44 -0
  147. package/dist/types/types/graph.d.ts +7 -1
  148. package/dist/types/types/run.d.ts +46 -1
  149. package/dist/types/types/tools.d.ts +21 -0
  150. package/dist/types/utils/index.d.ts +1 -0
  151. package/dist/types/utils/misc.d.ts +7 -0
  152. package/dist/types/utils/proxy.d.ts +31 -0
  153. package/package.json +2 -1
  154. package/src/__tests__/stream.eagerArgsDivergence.test.ts +158 -0
  155. package/src/__tests__/stream.eagerEventExecution.test.ts +1 -0
  156. package/src/__tests__/stream.streamLimits.test.ts +1982 -0
  157. package/src/graphs/Graph.ts +302 -27
  158. package/src/graphs/__tests__/Graph.breakerLifecycle.test.ts +234 -0
  159. package/src/index.ts +11 -0
  160. package/src/langfuseToolOutputTracing.ts +410 -14
  161. package/src/llm/bedrock/index.ts +22 -2
  162. package/src/llm/custom-chat-models.smoke.test.ts +747 -0
  163. package/src/llm/invoke.streamLimits.test.ts +142 -0
  164. package/src/llm/invoke.test.ts +187 -1
  165. package/src/llm/invoke.ts +231 -43
  166. package/src/llm/openai/cacheWriteTokens.test.ts +112 -0
  167. package/src/llm/openai/index.ts +348 -31
  168. package/src/llm/openai/llm.spec.ts +107 -6
  169. package/src/llm/streamLimits.test.ts +450 -0
  170. package/src/llm/streamLimits.ts +1158 -0
  171. package/src/messages/core.ts +1290 -42
  172. package/src/messages/formatAgentMessages.test.ts +2623 -0
  173. package/src/run.ts +4 -0
  174. package/src/session/AgentSession.ts +5 -0
  175. package/src/specs/langfuse-tool-output-tracing.test.ts +887 -0
  176. package/src/specs/preemptSeal.test.ts +374 -5
  177. package/src/stream.ts +141 -6
  178. package/src/summarization/__tests__/node.test.ts +269 -0
  179. package/src/summarization/chunkHandler.test.ts +196 -0
  180. package/src/summarization/node.ts +203 -6
  181. package/src/tools/BashExecutor.ts +4 -3
  182. package/src/tools/CodeExecutor.ts +5 -4
  183. package/src/tools/ProgrammaticToolCalling.ts +7 -5
  184. package/src/tools/ToolNode.ts +109 -6
  185. package/src/tools/ToolSearch.ts +4 -3
  186. package/src/tools/__tests__/BashExecutor.test.ts +2 -2
  187. package/src/tools/__tests__/ProgrammaticToolCalling.test.ts +2 -4
  188. package/src/tools/__tests__/SubagentExecutor.test.ts +126 -0
  189. package/src/tools/__tests__/ToolNode.breakerSignal.test.ts +389 -0
  190. package/src/tools/__tests__/ToolNode.streamLimits.test.ts +69 -0
  191. package/src/tools/search/crw-scraper.ts +6 -0
  192. package/src/tools/search/crw-search.ts +6 -1
  193. package/src/tools/search/firecrawl.ts +6 -0
  194. package/src/tools/search/http-agent.test.ts +133 -0
  195. package/src/tools/search/keenable-scraper.ts +6 -0
  196. package/src/tools/search/keenable-search.ts +6 -1
  197. package/src/tools/search/rerankers.ts +36 -10
  198. package/src/tools/search/search.ts +29 -11
  199. package/src/tools/search/serper-scraper.ts +6 -0
  200. package/src/tools/search/tavily-scraper.ts +6 -0
  201. package/src/tools/search/tavily-search.ts +2 -0
  202. package/src/tools/search/tool.ts +16 -0
  203. package/src/tools/search/types.ts +31 -33
  204. package/src/tools/subagent/SubagentExecutor.ts +96 -3
  205. package/src/types/graph.ts +7 -0
  206. package/src/types/run.ts +49 -1
  207. package/src/types/tools.ts +21 -0
  208. package/src/utils/index.ts +1 -0
  209. package/src/utils/misc.ts +19 -0
  210. package/src/utils/proxy.test.ts +176 -0
  211. package/src/utils/proxy.ts +93 -0
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `attemptInvoke`'s local stream branch (no registered SDK dispatcher) must
3
+ * charge the event budget even for chunks that produce no handling chunk:
4
+ * a pure OpenRouter reasoning-replay chunk is skipped for content handling,
5
+ * and in this branch no `streamEvents` consumer counts the wire event
6
+ * either, so a looping replay stream would otherwise bypass an enabled
7
+ * `maxDeltaEventsPerTurn` indefinitely.
8
+ */
9
+ import { AIMessageChunk, HumanMessage } from '@langchain/core/messages';
10
+ import { describe, it, expect, jest } from '@jest/globals';
11
+ import type { AgentContext } from '@/agents/AgentContext';
12
+ import type { StandardGraph } from '@/graphs';
13
+ import type * as t from '@/types';
14
+ import {
15
+ StreamLimitExceededError,
16
+ resolveStreamLimits,
17
+ } from '@/llm/streamLimits';
18
+ import { attemptInvoke } from '@/llm/invoke';
19
+ import { ContentTypes, Providers } from '@/common';
20
+
21
+ function createContext(
22
+ overrides: Partial<StandardGraph> = {}
23
+ ): StandardGraph {
24
+ const runSteps = new Map<string, t.RunStep>();
25
+ const stepIdsByKey = new Map<string, string>();
26
+ let stepCounter = 0;
27
+
28
+ const graph = {
29
+ config: { configurable: { user_id: 'user_1' }, metadata: { run_id: 'r1' } },
30
+ eagerEventToolExecution: undefined,
31
+ eagerEventToolExecutions: new Map(),
32
+ eagerEventToolCallChunks: new Map(),
33
+ eagerEventToolSuppressions: new Set<string>(),
34
+ handlerRegistry: undefined,
35
+ hookRegistry: undefined,
36
+ humanInTheLoop: undefined,
37
+ toolOutputReferences: undefined,
38
+ sessions: new Map(),
39
+ toolCallStepIds: new Map(),
40
+ messageIdsByStepKey: new Map(),
41
+ messageStepHasToolCalls: new Map(),
42
+ prelimMessageIdsByStepKey: new Map([['step-key', 'msg_1']]),
43
+ getAgentContext: jest.fn(
44
+ (): Partial<AgentContext> => ({
45
+ provider: Providers.OPENAI,
46
+ reasoningKey: 'reasoning_content',
47
+ currentTokenType: ContentTypes.TEXT,
48
+ toolDefinitions: [],
49
+ graphTools: [],
50
+ agentId: 'agent_1',
51
+ })
52
+ ),
53
+ getOrCreateToolOutputRegistry: jest.fn(() => undefined),
54
+ shouldPreemptStream: jest.fn(() => false),
55
+ claimPreemptSeal: jest.fn(() => false),
56
+ getStepKey: jest.fn(() => 'step-key'),
57
+ getStepIdByKey: jest.fn((stepKey: string) => {
58
+ const stepId = stepIdsByKey.get(stepKey);
59
+ if (stepId == null) {
60
+ throw new Error('no current step');
61
+ }
62
+ return stepId;
63
+ }),
64
+ getRunStep: jest.fn((stepId: string) => runSteps.get(stepId)),
65
+ dispatchRunStep: jest.fn(async (stepKey: string, details: unknown) => {
66
+ const id = `step_${++stepCounter}`;
67
+ stepIdsByKey.set(stepKey, id);
68
+ runSteps.set(id, {
69
+ id,
70
+ type: (details as { type: t.RunStep['type'] }).type,
71
+ stepDetails: details as t.RunStep['stepDetails'],
72
+ } as t.RunStep);
73
+ return id;
74
+ }),
75
+ dispatchRunStepDelta: jest.fn(async () => undefined),
76
+ dispatchMessageDelta: jest.fn(async () => undefined),
77
+ dispatchReasoningDelta: jest.fn(async () => undefined),
78
+ ...overrides,
79
+ };
80
+ return graph as unknown as StandardGraph;
81
+ }
82
+
83
+ const replayChunk = (): AIMessageChunk =>
84
+ new AIMessageChunk({
85
+ content: 'abc',
86
+ additional_kwargs: { reasoning_details: [{ type: 'reasoning.text' }] },
87
+ });
88
+
89
+ function createReplayModel(replays: number): t.ChatModel {
90
+ return {
91
+ stream: async function* stream(): AsyncGenerator<AIMessageChunk> {
92
+ yield new AIMessageChunk({ content: 'abc' });
93
+ for (let i = 0; i < replays; i++) {
94
+ yield replayChunk();
95
+ }
96
+ },
97
+ } as unknown as t.ChatModel;
98
+ }
99
+
100
+ describe('attemptInvoke local branch stream limits', () => {
101
+ it('charges skipped OpenRouter replay chunks against the event budget', async () => {
102
+ const context = createContext({
103
+ streamLimits: resolveStreamLimits({ maxDeltaEventsPerTurn: 3 }),
104
+ });
105
+
106
+ let caught: unknown;
107
+ try {
108
+ await attemptInvoke(
109
+ {
110
+ model: createReplayModel(10),
111
+ messages: [new HumanMessage('hi')],
112
+ provider: Providers.OPENROUTER,
113
+ context: context as unknown as Parameters<
114
+ typeof attemptInvoke
115
+ >[0]['context'],
116
+ },
117
+ { metadata: { langgraph_node: 'agent', langgraph_step: 1 } }
118
+ );
119
+ } catch (error) {
120
+ caught = error;
121
+ }
122
+ expect(caught).toBeInstanceOf(StreamLimitExceededError);
123
+ expect((caught as StreamLimitExceededError).kind).toBe('delta_events');
124
+ });
125
+
126
+ it('leaves replay streams unbounded when the cap is disabled', async () => {
127
+ const context = createContext({ streamLimits: resolveStreamLimits() });
128
+
129
+ const result = await attemptInvoke(
130
+ {
131
+ model: createReplayModel(10),
132
+ messages: [new HumanMessage('hi')],
133
+ provider: Providers.OPENROUTER,
134
+ context: context as unknown as Parameters<
135
+ typeof attemptInvoke
136
+ >[0]['context'],
137
+ },
138
+ { metadata: { langgraph_node: 'agent', langgraph_step: 1 } }
139
+ );
140
+ expect(result.messages).toHaveLength(1);
141
+ });
142
+ });
@@ -18,6 +18,8 @@ import type * as t from '@/types';
18
18
  import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/message_inputs';
19
19
  import { ToolOutputReferenceRegistry } from '@/tools/toolOutputReferences';
20
20
  import { convertMessageContentToParts } from '@/llm/google/utils/common';
21
+ import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
22
+ import { StreamLimitExceededError } from '@/llm/streamLimits';
21
23
  import { attemptInvoke, tryFallbackProviders } from '@/llm/invoke';
22
24
  import { toLangChainContent } from '@/messages/langchain';
23
25
  import { Constants, Providers } from '@/common';
@@ -555,7 +557,7 @@ describe('attemptInvoke applies lazy ref annotation', () => {
555
557
  responses.model.defaultOptions = { tools: [] };
556
558
  responses.model._useResponsesApi = (options) =>
557
559
  Array.isArray((options as { tools?: unknown[] } | undefined)?.tools) &&
558
- ((options as { tools: unknown[] }).tools.length ?? 0) > 0;
560
+ (options as { tools: unknown[] }).tools.length > 0;
559
561
 
560
562
  await attemptInvoke(
561
563
  {
@@ -827,6 +829,190 @@ describe('tryFallbackProviders applies the same lazy annotation transform', () =
827
829
  jest.dontMock('@/llm/init');
828
830
  jest.resetModules();
829
831
  });
832
+
833
+ it('stops draining a cancellation-ignoring stream once the breaker trips', async () => {
834
+ const abort = new AbortController();
835
+ /** Statically imported class: neighboring tests reset the module
836
+ * registry, and a dynamically imported instance would fail the
837
+ * statically-linked drain loop's instanceof check. */
838
+ const trip = new StreamLimitExceededError({
839
+ kind: 'tool_call_args',
840
+ limit: 10,
841
+ observed: 11,
842
+ toolName: 'db_query',
843
+ });
844
+ let yields = 0;
845
+ const model: StubModel = {
846
+ stream: jest.fn(async function* (): AsyncGenerator<AIMessageChunk> {
847
+ for (let i = 0; i < 5; i++) {
848
+ yields += 1;
849
+ yield new AIMessageChunk({ content: `c${i}` });
850
+ }
851
+ }),
852
+ };
853
+
854
+ /** The stub ignores the abort signal entirely; only the per-chunk
855
+ * breaker check in the drain loop can stop it. */
856
+ await expect(
857
+ attemptInvoke(
858
+ {
859
+ model: model as t.ChatModel,
860
+ messages: [new HumanMessage('hi')],
861
+ provider: Providers.ANTHROPIC,
862
+ onChunk: () => {
863
+ abort.abort(trip);
864
+ },
865
+ },
866
+ { signal: abort.signal }
867
+ )
868
+ ).rejects.toBe(trip);
869
+ expect(yields).toBeLessThanOrEqual(2);
870
+ });
871
+
872
+ it('rejects before invoking a fallback when the breaker tripped during preparation', async () => {
873
+ const abort = new AbortController();
874
+ const { invokeMessages, model } = buildCapturingModel();
875
+ await jest.isolateModulesAsync(async () => {
876
+ jest.doMock('@/llm/init', () => ({
877
+ initializeModel: (): unknown => model,
878
+ }));
879
+ const { tryFallbackProviders: freshTry } = (await import(
880
+ '@/llm/invoke'
881
+ )) as { tryFallbackProviders: typeof tryFallbackProviders };
882
+ const { StreamLimitExceededError: FreshStreamLimitError } = (await import(
883
+ '@/llm/streamLimits'
884
+ )) as typeof import('@/llm/streamLimits');
885
+ const trip = new FreshStreamLimitError({
886
+ kind: 'tool_call_args',
887
+ limit: 10,
888
+ observed: 11,
889
+ toolName: 'db_query',
890
+ });
891
+
892
+ /** The sibling's trip lands while fallback preparation is awaited; the
893
+ * fallback must not be invoked at all — a provider that ignores the
894
+ * aborted signal and SUCCEEDS would resolve a run that must reject. */
895
+ await expect(
896
+ freshTry({
897
+ fallbacks: [{ provider: Providers.ANTHROPIC }],
898
+ messages: [
899
+ new ToolMessage({
900
+ name: 'echo',
901
+ tool_call_id: 'tc1',
902
+ status: 'success',
903
+ content: 'output',
904
+ }),
905
+ ],
906
+ primaryError: new Error('primary failed'),
907
+ config: { signal: abort.signal },
908
+ prepareProviderMessages: async ({ messages: preparedMessages }) => {
909
+ abort.abort(trip);
910
+ return preparedMessages;
911
+ },
912
+ })
913
+ ).rejects.toBe(trip);
914
+ });
915
+ expect(invokeMessages).toHaveLength(0);
916
+
917
+ jest.dontMock('@/llm/init');
918
+ });
919
+
920
+ it('neutralizes preempted Responses history before an OpenAI Chat fallback', async () => {
921
+ const reasoning = {
922
+ id: 'rs_fallback',
923
+ type: 'reasoning',
924
+ status: 'completed',
925
+ summary: [],
926
+ encrypted_content: 'opaque-fallback-reasoning',
927
+ };
928
+ const toolOutput = {
929
+ id: 'ci_fallback',
930
+ type: 'code_interpreter_call',
931
+ status: 'completed',
932
+ code: 'print("fallback result")',
933
+ outputs: [{ type: 'logs', logs: 'fallback result' }],
934
+ };
935
+ const translatedMessage = new AIMessage({
936
+ content: [{ type: 'text', text: 'Partial answer.' }],
937
+ additional_kwargs: {
938
+ reasoning,
939
+ tool_outputs: [toolOutput],
940
+ },
941
+ response_metadata: { model_provider: 'openai' },
942
+ });
943
+ const message = new AIMessage({
944
+ contentBlocks: [
945
+ ...translatedMessage.contentBlocks,
946
+ {
947
+ type: 'non_standard',
948
+ value: {
949
+ id: 'rs_bare_fallback',
950
+ type: 'reasoning',
951
+ summary: [],
952
+ },
953
+ },
954
+ {
955
+ type: 'image',
956
+ mimeType: 'image/png',
957
+ data: 'AA==',
958
+ id: 'ig_fallback',
959
+ metadata: { status: 'completed' },
960
+ },
961
+ ],
962
+ additional_kwargs: {
963
+ reasoning,
964
+ tool_outputs: [toolOutput],
965
+ },
966
+ response_metadata: {
967
+ model_provider: 'openai',
968
+ output_version: 'v1',
969
+ preempted: true,
970
+ },
971
+ });
972
+ const { invokeMessages, model } = buildCapturingModel();
973
+ model._useResponsesApi = () => false;
974
+ jest.doMock('@/llm/init', () => ({
975
+ initializeModel: (): unknown => model,
976
+ }));
977
+ jest.resetModules();
978
+ const { tryFallbackProviders: freshTry } = (await import(
979
+ '@/llm/invoke'
980
+ )) as { tryFallbackProviders: typeof tryFallbackProviders };
981
+
982
+ await freshTry({
983
+ fallbacks: [
984
+ {
985
+ provider: Providers.OPENAI,
986
+ clientOptions: { model: 'gpt-5.6' },
987
+ },
988
+ ],
989
+ messages: [message],
990
+ primaryError: new Error('primary failed'),
991
+ });
992
+
993
+ const sent = invokeMessages[invokeMessages.length - 1][0] as AIMessage;
994
+ expect(sent).not.toBe(message);
995
+ expect(sent.content).toEqual([
996
+ { type: 'text', text: 'Partial answer.' },
997
+ { type: 'text', text: expect.stringContaining('fallback result') },
998
+ ]);
999
+ expect(sent.additional_kwargs).toEqual({});
1000
+ expect(JSON.stringify(sent.toJSON())).not.toMatch(/rs_|ci_|ig_/);
1001
+ const chatPayload = _convertMessagesToOpenAIParams([sent], 'gpt-5.6');
1002
+ expect(chatPayload).toEqual([
1003
+ {
1004
+ role: 'assistant',
1005
+ content: sent.content,
1006
+ },
1007
+ ]);
1008
+ expect(JSON.stringify(chatPayload)).not.toMatch(/rs_|ci_|ig_/);
1009
+ expect(JSON.stringify(message.toJSON())).toContain('rs_fallback');
1010
+ expect(JSON.stringify(message.toJSON())).toContain('ci_fallback');
1011
+ expect(JSON.stringify(message.toJSON())).toContain('ig_fallback');
1012
+
1013
+ jest.dontMock('@/llm/init');
1014
+ jest.resetModules();
1015
+ });
830
1016
  });
831
1017
 
832
1018
  describe('invocation attribution metadata', () => {