@librechat/agents 3.3.0 → 3.3.2

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 (130) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +116 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +2 -0
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +229 -48
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +102 -27
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/llm/bedrock/index.cjs +11 -2
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/contextOverflowRecovery.cjs +132 -0
  12. package/dist/cjs/llm/contextOverflowRecovery.cjs.map +1 -0
  13. package/dist/cjs/llm/google/index.cjs +1 -1
  14. package/dist/cjs/llm/invoke.cjs +60 -3
  15. package/dist/cjs/llm/invoke.cjs.map +1 -1
  16. package/dist/cjs/main.cjs +20 -7
  17. package/dist/cjs/messages/prune.cjs +16 -5
  18. package/dist/cjs/messages/prune.cjs.map +1 -1
  19. package/dist/cjs/messages/recency.cjs +2 -0
  20. package/dist/cjs/messages/recency.cjs.map +1 -1
  21. package/dist/cjs/run.cjs +15 -2
  22. package/dist/cjs/run.cjs.map +1 -1
  23. package/dist/cjs/stream.cjs +205 -49
  24. package/dist/cjs/stream.cjs.map +1 -1
  25. package/dist/cjs/summarization/node.cjs +55 -7
  26. package/dist/cjs/summarization/node.cjs.map +1 -1
  27. package/dist/cjs/tools/BashExecutor.cjs +2 -2
  28. package/dist/cjs/tools/BashExecutor.cjs.map +1 -1
  29. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +2 -2
  30. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +1 -1
  31. package/dist/cjs/tools/CodeExecutor.cjs +69 -8
  32. package/dist/cjs/tools/CodeExecutor.cjs.map +1 -1
  33. package/dist/cjs/tools/ProgrammaticToolCalling.cjs +19 -15
  34. package/dist/cjs/tools/ProgrammaticToolCalling.cjs.map +1 -1
  35. package/dist/cjs/tools/ToolNode.cjs +32 -3
  36. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  37. package/dist/cjs/utils/errors.cjs +317 -52
  38. package/dist/cjs/utils/errors.cjs.map +1 -1
  39. package/dist/esm/agents/AgentContext.mjs +117 -3
  40. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  41. package/dist/esm/common/enum.mjs +2 -0
  42. package/dist/esm/common/enum.mjs.map +1 -1
  43. package/dist/esm/graphs/Graph.mjs +227 -46
  44. package/dist/esm/graphs/Graph.mjs.map +1 -1
  45. package/dist/esm/graphs/MultiAgentGraph.mjs +102 -27
  46. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  47. package/dist/esm/llm/bedrock/index.mjs +11 -2
  48. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  49. package/dist/esm/llm/contextOverflowRecovery.mjs +130 -0
  50. package/dist/esm/llm/contextOverflowRecovery.mjs.map +1 -0
  51. package/dist/esm/llm/google/index.mjs +1 -1
  52. package/dist/esm/llm/invoke.mjs +59 -4
  53. package/dist/esm/llm/invoke.mjs.map +1 -1
  54. package/dist/esm/main.mjs +9 -9
  55. package/dist/esm/messages/prune.mjs +14 -6
  56. package/dist/esm/messages/prune.mjs.map +1 -1
  57. package/dist/esm/messages/recency.mjs +2 -1
  58. package/dist/esm/messages/recency.mjs.map +1 -1
  59. package/dist/esm/run.mjs +15 -2
  60. package/dist/esm/run.mjs.map +1 -1
  61. package/dist/esm/stream.mjs +205 -49
  62. package/dist/esm/stream.mjs.map +1 -1
  63. package/dist/esm/summarization/node.mjs +55 -7
  64. package/dist/esm/summarization/node.mjs.map +1 -1
  65. package/dist/esm/tools/BashExecutor.mjs +3 -3
  66. package/dist/esm/tools/BashExecutor.mjs.map +1 -1
  67. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +3 -3
  68. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +1 -1
  69. package/dist/esm/tools/CodeExecutor.mjs +62 -9
  70. package/dist/esm/tools/CodeExecutor.mjs.map +1 -1
  71. package/dist/esm/tools/ProgrammaticToolCalling.mjs +20 -16
  72. package/dist/esm/tools/ProgrammaticToolCalling.mjs.map +1 -1
  73. package/dist/esm/tools/ToolNode.mjs +32 -3
  74. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  75. package/dist/esm/utils/errors.mjs +317 -53
  76. package/dist/esm/utils/errors.mjs.map +1 -1
  77. package/dist/types/agents/AgentContext.d.ts +62 -3
  78. package/dist/types/common/enum.d.ts +2 -0
  79. package/dist/types/graphs/Graph.d.ts +16 -2
  80. package/dist/types/graphs/MultiAgentGraph.d.ts +1 -0
  81. package/dist/types/llm/contextOverflowRecovery.d.ts +85 -0
  82. package/dist/types/llm/invoke.d.ts +35 -6
  83. package/dist/types/messages/prune.d.ts +10 -2
  84. package/dist/types/messages/recency.d.ts +1 -0
  85. package/dist/types/run.d.ts +2 -0
  86. package/dist/types/tools/CodeExecutor.d.ts +14 -1
  87. package/dist/types/types/llm.d.ts +7 -4
  88. package/dist/types/types/stream.d.ts +5 -4
  89. package/dist/types/types/summarize.d.ts +22 -0
  90. package/dist/types/utils/__tests__/fixtures/contextOverflowSignatures.d.ts +40 -0
  91. package/dist/types/utils/errors.d.ts +65 -16
  92. package/dist/types/utils/redactSecrets.d.ts +3 -0
  93. package/package.json +10 -11
  94. package/src/agents/AgentContext.ts +188 -7
  95. package/src/agents/__tests__/AgentContext.overflow.test.ts +205 -0
  96. package/src/common/enum.ts +2 -0
  97. package/src/graphs/Graph.ts +389 -58
  98. package/src/graphs/MultiAgentGraph.ts +184 -46
  99. package/src/graphs/__tests__/Graph.contextOverflow.test.ts +631 -0
  100. package/src/llm/__tests__/contextOverflowRecovery.test.ts +401 -0
  101. package/src/llm/__tests__/fallbackOverflow.test.ts +287 -0
  102. package/src/llm/anthropic/llm.spec.ts +8 -0
  103. package/src/llm/bedrock/index.ts +25 -12
  104. package/src/llm/contextOverflowRecovery.ts +292 -0
  105. package/src/llm/invoke.ts +119 -4
  106. package/src/messages/prune.ts +24 -11
  107. package/src/messages/recency.ts +3 -1
  108. package/src/run.ts +24 -1
  109. package/src/scripts/context-overflow-probe.ts +997 -0
  110. package/src/specs/agent-handoffs.test.ts +903 -1
  111. package/src/specs/context-overflow-recovery.live.test.ts +213 -0
  112. package/src/splitStream.test.ts +882 -0
  113. package/src/stream.ts +315 -51
  114. package/src/summarization/__tests__/aggregator.test.ts +83 -0
  115. package/src/summarization/__tests__/node.test.ts +139 -0
  116. package/src/summarization/node.ts +99 -14
  117. package/src/tools/BashExecutor.ts +4 -2
  118. package/src/tools/BashProgrammaticToolCalling.ts +4 -7
  119. package/src/tools/CodeExecutor.ts +119 -8
  120. package/src/tools/ProgrammaticToolCalling.ts +29 -27
  121. package/src/tools/ToolNode.ts +50 -8
  122. package/src/tools/__tests__/CodeApiAuthHeaders.test.ts +297 -3
  123. package/src/types/llm.ts +8 -1
  124. package/src/types/stream.ts +5 -4
  125. package/src/types/summarize.ts +22 -0
  126. package/src/utils/__tests__/errors.test.ts +270 -0
  127. package/src/utils/__tests__/fixtures/contextOverflowSignatures.ts +336 -0
  128. package/src/utils/__tests__/redactSecrets.test.ts +56 -0
  129. package/src/utils/errors.ts +484 -66
  130. package/src/utils/redactSecrets.ts +61 -0
@@ -0,0 +1,401 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import {
3
+ getBlindRecoveryBudget,
4
+ planContextOverflowRecovery,
5
+ DEFAULT_MAX_OVERFLOW_RECOVERIES,
6
+ translateRecoveryBudget,
7
+ } from '@/llm/contextOverflowRecovery';
8
+ import { OVERFLOW_SIGNATURES } from '@/utils/__tests__/fixtures/contextOverflowSignatures';
9
+ import { clampCalibrationRatio } from '@/messages';
10
+ import { Providers } from '@/common';
11
+
12
+ function signatureFor(model: string) {
13
+ const signature = OVERFLOW_SIGNATURES.find((s) => s.model === model);
14
+ if (signature == null) {
15
+ throw new Error(`missing fixture for ${model}`);
16
+ }
17
+ return signature;
18
+ }
19
+
20
+ describe('planContextOverflowRecovery', () => {
21
+ it('targets the ceiling the provider reported, with headroom', () => {
22
+ const anthropic = signatureFor('claude-haiku-4-5-20251001');
23
+ const plan = planContextOverflowRecovery({
24
+ error: anthropic.error,
25
+ provider: Providers.ANTHROPIC,
26
+ /** Misconfigured: caller believed the window was far larger. */
27
+ maxContextTokens: 1_000_000,
28
+ estimatedPromptTokens: 274_468,
29
+ attemptsSoFar: 0,
30
+ });
31
+
32
+ expect(plan).not.toBeNull();
33
+ expect(plan?.budgetTokens).toBeLessThan(200_000);
34
+ expect(plan?.budgetTokens).toBeGreaterThan(180_000);
35
+ expect(plan?.info.limitTokens).toBe(200_000);
36
+ });
37
+
38
+ it('keeps the corrected budget in provider token units', () => {
39
+ const openrouter = signatureFor('qwen/qwen-2.5-7b-instruct');
40
+ /**
41
+ * OpenRouter counted 56,811 input tokens for a prompt we estimated at
42
+ * 42,599 — a ~1.33 divergence. `maxContextTokens` remains in provider
43
+ * units, while the observed ratio seeds the pruner's conversion into its
44
+ * local counter units.
45
+ */
46
+ const plan = planContextOverflowRecovery({
47
+ error: openrouter.error,
48
+ provider: Providers.OPENROUTER,
49
+ maxContextTokens: 40_000,
50
+ estimatedPromptTokens: 42_599,
51
+ attemptsSoFar: 0,
52
+ });
53
+
54
+ expect(plan?.observedCalibrationRatio).toBeCloseTo(1.333, 2);
55
+ expect(plan?.budgetTokens).toBe(Math.floor((32_768 - 16) * 0.95));
56
+ });
57
+
58
+ it('calibrates message tokens without scaling fixed instruction overhead', () => {
59
+ const error = {
60
+ message:
61
+ 'This model\'s maximum context length is 4096 tokens. However, your messages resulted in 1400 tokens.',
62
+ };
63
+ const plan = planContextOverflowRecovery({
64
+ error,
65
+ provider: Providers.OPENAI,
66
+ maxContextTokens: 4_096,
67
+ estimatedPromptTokens: 1_000,
68
+ calibrationRatio: 2,
69
+ instructionTokens: 600,
70
+ attemptsSoFar: 0,
71
+ });
72
+
73
+ expect(plan?.observedCalibrationRatio).toBe(4);
74
+ });
75
+
76
+ it('translates fallback budgets into the primary pruner calibration', () => {
77
+ expect(translateRecoveryBudget(80_000, 2, 1.5)).toBe(60_000);
78
+ });
79
+
80
+ it('uses a primary-space blind shrink when fallback units cannot be translated', () => {
81
+ expect(getBlindRecoveryBudget(100_000)).toBe(70_000);
82
+ });
83
+
84
+ it('conservatively translates an uncalibrated fallback ceiling', () => {
85
+ const primaryBlindBudget = getBlindRecoveryBudget(1_000_000);
86
+ const fallbackBound = translateRecoveryBudget(
87
+ 32_000,
88
+ clampCalibrationRatio(Number.POSITIVE_INFINITY),
89
+ 1
90
+ );
91
+
92
+ expect(
93
+ Math.min(
94
+ primaryBlindBudget ?? Number.POSITIVE_INFINITY,
95
+ fallbackBound ?? Number.POSITIVE_INFINITY
96
+ )
97
+ ).toBe(6_400);
98
+ });
99
+
100
+ it('recovers on a small-window model despite the absolute floor', () => {
101
+ /** 4,096-token window: 95% of the ceiling lands under MIN_RECOVERY_BUDGET. */
102
+ const error = {
103
+ name: 'ContextOverflowError',
104
+ message:
105
+ '400 This model\'s maximum context length is 4096 tokens. However, your messages resulted in 6000 tokens. Please reduce the length of the messages.',
106
+ };
107
+ const plan = planContextOverflowRecovery({
108
+ error,
109
+ provider: Providers.OPENAI,
110
+ maxContextTokens: 8_192,
111
+ estimatedPromptTokens: 6_000,
112
+ instructionTokens: 200,
113
+ attemptsSoFar: 0,
114
+ });
115
+
116
+ expect(plan?.budgetTokens).toBe(Math.floor(4_096 * 0.95));
117
+ });
118
+
119
+ it('permits small-window summary-only recovery below the pruning floor', () => {
120
+ const error = {
121
+ message:
122
+ '400 This model\'s maximum context length is 4096 tokens. However, your messages resulted in 6000 tokens.',
123
+ };
124
+ const plan = planContextOverflowRecovery({
125
+ error,
126
+ provider: Providers.OPENAI,
127
+ maxContextTokens: 8_192,
128
+ instructionTokens: 0,
129
+ canSummarize: true,
130
+ attemptsSoFar: 0,
131
+ });
132
+
133
+ expect(plan?.budgetTokens).toBe(Math.floor(4_096 * 0.95));
134
+ });
135
+
136
+ it('calibrates on the prompt, never on a completion-inclusive total', () => {
137
+ const openai = signatureFor('gpt-5-nano');
138
+ /**
139
+ * The 429 quotes "Requested 480002", which includes the output
140
+ * allowance. Reading that as the prompt size would invent a ratio and
141
+ * shrink the budget far past what the overflow calls for.
142
+ */
143
+ const plan = planContextOverflowRecovery({
144
+ error: openai.error,
145
+ provider: Providers.OPENAI,
146
+ maxContextTokens: 400_000,
147
+ estimatedPromptTokens: 240_000,
148
+ attemptsSoFar: 0,
149
+ });
150
+
151
+ expect(plan?.observedCalibrationRatio).toBeUndefined();
152
+ expect(plan?.budgetTokens).toBe(Math.floor(200_000 * 0.95));
153
+ });
154
+
155
+ it('reserves the completion allowance the provider counted against the ceiling', () => {
156
+ /**
157
+ * A 64k completion allowance inside a 128k ceiling leaves 64k for the
158
+ * prompt; targeting the full ceiling would overflow again no matter how
159
+ * far the prompt is compacted.
160
+ */
161
+ const error = {
162
+ name: 'ContextOverflowError',
163
+ message:
164
+ '400 This model\'s maximum context length is 128000 tokens. However, you requested 190000 tokens (126000 in the messages, 64000 in the completion). Please reduce the length of the messages or completion.',
165
+ };
166
+ const plan = planContextOverflowRecovery({
167
+ error,
168
+ provider: Providers.DEEPSEEK,
169
+ maxContextTokens: 128_000,
170
+ estimatedPromptTokens: 126_000,
171
+ attemptsSoFar: 0,
172
+ });
173
+
174
+ expect(plan?.budgetTokens).toBe(Math.floor((128_000 - 64_000) * 0.95));
175
+ });
176
+
177
+ it('shrinks blindly when the provider named no numbers', () => {
178
+ const bedrock = signatureFor(
179
+ 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
180
+ );
181
+ const plan = planContextOverflowRecovery({
182
+ error: bedrock.error,
183
+ provider: Providers.BEDROCK,
184
+ maxContextTokens: 200_000,
185
+ estimatedPromptTokens: 190_000,
186
+ attemptsSoFar: 0,
187
+ });
188
+
189
+ expect(plan?.info.limitTokens).toBeUndefined();
190
+ expect(plan?.budgetTokens).toBe(Math.floor(190_000 * 0.7));
191
+ });
192
+
193
+ it('recovers the mid-stream Bedrock overflow that arrives as HTTP 200', () => {
194
+ const nova = signatureFor('us.amazon.nova-lite-v1:0');
195
+ const plan = planContextOverflowRecovery({
196
+ error: nova.error,
197
+ provider: Providers.BEDROCK,
198
+ maxContextTokens: 300_000,
199
+ estimatedPromptTokens: 290_000,
200
+ attemptsSoFar: 0,
201
+ });
202
+
203
+ expect(plan).not.toBeNull();
204
+ expect(plan?.budgetTokens).toBeLessThan(300_000);
205
+ });
206
+
207
+ it('recovers an OpenAI token-bucket rejection', () => {
208
+ const openai = signatureFor('gpt-5-nano');
209
+ const plan = planContextOverflowRecovery({
210
+ error: openai.error,
211
+ provider: Providers.OPENAI,
212
+ maxContextTokens: 400_000,
213
+ estimatedPromptTokens: 480_000,
214
+ attemptsSoFar: 0,
215
+ });
216
+
217
+ expect(plan?.info.kind).toBe('request_too_large');
218
+ /** The bucket, not the context window, is the binding constraint. */
219
+ expect(plan?.budgetTokens).toBeLessThan(200_000);
220
+ });
221
+
222
+ it('reserves the configured completion allowance on a total-only error', () => {
223
+ const openai = signatureFor('gpt-5-nano');
224
+ /**
225
+ * The 429 quotes a 200k bucket and no breakdown. With a 32k completion
226
+ * allowance configured, a retry aimed at the whole bucket would still be
227
+ * `prompt + 32k` and over the limit.
228
+ */
229
+ const plan = planContextOverflowRecovery({
230
+ error: openai.error,
231
+ provider: Providers.OPENAI,
232
+ maxContextTokens: 400_000,
233
+ estimatedPromptTokens: 240_000,
234
+ configuredCompletionTokens: 32_000,
235
+ attemptsSoFar: 0,
236
+ });
237
+
238
+ expect(plan?.budgetTokens).toBe(Math.floor((200_000 - 32_000) * 0.95));
239
+ });
240
+
241
+ it('prefers the provider breakdown over the configured allowance', () => {
242
+ const openrouter = signatureFor('qwen/qwen-2.5-7b-instruct');
243
+ /** The error itemized 16 output tokens; that beats a stale config value. */
244
+ const withConfig = planContextOverflowRecovery({
245
+ error: openrouter.error,
246
+ provider: Providers.OPENROUTER,
247
+ maxContextTokens: 32_768,
248
+ estimatedPromptTokens: 42_599,
249
+ configuredCompletionTokens: 8_000,
250
+ attemptsSoFar: 0,
251
+ });
252
+ const withoutConfig = planContextOverflowRecovery({
253
+ error: openrouter.error,
254
+ provider: Providers.OPENROUTER,
255
+ maxContextTokens: 32_768,
256
+ estimatedPromptTokens: 42_599,
257
+ attemptsSoFar: 0,
258
+ });
259
+
260
+ expect(withConfig?.budgetTokens).toBe(withoutConfig?.budgetTokens);
261
+ });
262
+
263
+ it('declines when the completion allowance alone exceeds the ceiling', () => {
264
+ const openai = signatureFor('gpt-5-nano');
265
+ /**
266
+ * A 240k output allowance against a 200k bucket: no prompt, however
267
+ * small, makes this request fit. Spending recovery attempts on it would
268
+ * bury the real problem.
269
+ */
270
+ const plan = planContextOverflowRecovery({
271
+ error: openai.error,
272
+ provider: Providers.OPENAI,
273
+ maxContextTokens: 400_000,
274
+ estimatedPromptTokens: 240_000,
275
+ configuredCompletionTokens: 240_000,
276
+ attemptsSoFar: 0,
277
+ });
278
+
279
+ expect(plan).toBeNull();
280
+ });
281
+
282
+ it('declines when the corrected budget would not clear the instructions', () => {
283
+ const anthropic = signatureFor('claude-haiku-4-5-20251001');
284
+ /**
285
+ * Tool schemas alone fill the provider's real window, so compaction has
286
+ * nowhere to put the messages and the summarize node would refuse to run —
287
+ * the detour would bounce between nodes without shrinking anything.
288
+ */
289
+ const plan = planContextOverflowRecovery({
290
+ error: anthropic.error,
291
+ provider: Providers.ANTHROPIC,
292
+ maxContextTokens: 1_000_000,
293
+ estimatedPromptTokens: 274_468,
294
+ instructionTokens: 199_000,
295
+ attemptsSoFar: 0,
296
+ });
297
+
298
+ expect(plan).toBeNull();
299
+ });
300
+
301
+ it('still recovers when the instructions leave room for messages', () => {
302
+ const anthropic = signatureFor('claude-haiku-4-5-20251001');
303
+ const plan = planContextOverflowRecovery({
304
+ error: anthropic.error,
305
+ provider: Providers.ANTHROPIC,
306
+ maxContextTokens: 1_000_000,
307
+ estimatedPromptTokens: 274_468,
308
+ instructionTokens: 20_000,
309
+ attemptsSoFar: 0,
310
+ });
311
+
312
+ expect(plan?.budgetTokens).toBeGreaterThan(180_000);
313
+ });
314
+
315
+ it('stops once the per-run recovery budget is spent', () => {
316
+ const anthropic = signatureFor('claude-haiku-4-5-20251001');
317
+ const params = {
318
+ error: anthropic.error,
319
+ provider: Providers.ANTHROPIC,
320
+ maxContextTokens: 1_000_000,
321
+ estimatedPromptTokens: 274_468,
322
+ };
323
+
324
+ expect(
325
+ planContextOverflowRecovery({
326
+ ...params,
327
+ attemptsSoFar: DEFAULT_MAX_OVERFLOW_RECOVERIES - 1,
328
+ })
329
+ ).not.toBeNull();
330
+ expect(
331
+ planContextOverflowRecovery({
332
+ ...params,
333
+ attemptsSoFar: DEFAULT_MAX_OVERFLOW_RECOVERIES,
334
+ })
335
+ ).toBeNull();
336
+ });
337
+
338
+ it('declines when the budget cannot meaningfully shrink further', () => {
339
+ const anthropic = signatureFor('claude-haiku-4-5-20251001');
340
+ const plan = planContextOverflowRecovery({
341
+ error: anthropic.error,
342
+ provider: Providers.ANTHROPIC,
343
+ maxContextTokens: 3_000,
344
+ estimatedPromptTokens: 2_900,
345
+ attemptsSoFar: 0,
346
+ });
347
+
348
+ expect(plan).toBeNull();
349
+ });
350
+
351
+ it('declines for failures compaction cannot fix', () => {
352
+ const plan = planContextOverflowRecovery({
353
+ error: {
354
+ status: 429,
355
+ message: '429 Rate limit reached. Try again in 4ms.',
356
+ },
357
+ provider: Providers.OPENAI,
358
+ maxContextTokens: 128_000,
359
+ estimatedPromptTokens: 100_000,
360
+ attemptsSoFar: 0,
361
+ });
362
+
363
+ expect(plan).toBeNull();
364
+ });
365
+
366
+ it('successive recoveries keep shrinking', () => {
367
+ const bedrock = signatureFor(
368
+ 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
369
+ );
370
+ const first = planContextOverflowRecovery({
371
+ error: bedrock.error,
372
+ provider: Providers.BEDROCK,
373
+ maxContextTokens: 200_000,
374
+ estimatedPromptTokens: 190_000,
375
+ attemptsSoFar: 0,
376
+ });
377
+ const second = planContextOverflowRecovery({
378
+ error: bedrock.error,
379
+ provider: Providers.BEDROCK,
380
+ maxContextTokens: first?.budgetTokens,
381
+ estimatedPromptTokens: first?.budgetTokens,
382
+ attemptsSoFar: 1,
383
+ });
384
+
385
+ expect(second?.budgetTokens).toBeLessThan(first?.budgetTokens ?? 0);
386
+ });
387
+
388
+ it('plans summary-only recovery when no token budget is configured', () => {
389
+ const bedrock = signatureFor(
390
+ 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
391
+ );
392
+ const plan = planContextOverflowRecovery({
393
+ error: bedrock.error,
394
+ provider: Providers.BEDROCK,
395
+ attemptsSoFar: 0,
396
+ });
397
+
398
+ expect(plan).not.toBeNull();
399
+ expect(plan?.budgetTokens).toBeUndefined();
400
+ });
401
+ });
@@ -0,0 +1,287 @@
1
+ import { AIMessageChunk } from '@langchain/core/messages';
2
+ import { describe, expect, it, jest } from '@jest/globals';
3
+ import type { BaseMessage } from '@langchain/core/messages';
4
+ import {
5
+ tryFallbackProviders,
6
+ getFallbackErrorContext,
7
+ getFallbackOverflowCandidates,
8
+ } from '@/llm/invoke';
9
+ import { OVERFLOW_SIGNATURES } from '@/utils/__tests__/fixtures/contextOverflowSignatures';
10
+ import { Providers } from '@/common';
11
+ import * as init from '@/llm/init';
12
+
13
+ function signatureError(model: string): Error {
14
+ const signature = OVERFLOW_SIGNATURES.find((s) => s.model === model);
15
+ if (signature == null) {
16
+ throw new Error(`missing fixture for ${model}`);
17
+ }
18
+ const error = new Error(String(signature.error.message));
19
+ return Object.assign(error, signature.error);
20
+ }
21
+
22
+ /**
23
+ * `tryFallbackProviders` builds its own clients through `initializeModel`, so
24
+ * the only seam for driving per-fallback outcomes is that factory.
25
+ */
26
+ function stubModels(
27
+ outcomes: Array<Error | 'ok'>
28
+ ): jest.SpiedFunction<typeof init.initializeModel> {
29
+ let call = 0;
30
+ return jest.spyOn(init, 'initializeModel').mockImplementation(() => {
31
+ const outcome = outcomes[call++];
32
+ return {
33
+ invoke: async (): Promise<AIMessageChunk> => {
34
+ if (outcome instanceof Error) {
35
+ throw outcome;
36
+ }
37
+ return new AIMessageChunk({ content: 'ok' });
38
+ },
39
+ } as unknown as ReturnType<typeof init.initializeModel>;
40
+ });
41
+ }
42
+
43
+ const messages: BaseMessage[] = [];
44
+ const fallbacks: Array<{ provider: Providers }> = [
45
+ { provider: Providers.ANTHROPIC },
46
+ { provider: Providers.OPENAI },
47
+ ];
48
+
49
+ describe('tryFallbackProviders surfacing', () => {
50
+ afterEach(() => {
51
+ jest.restoreAllMocks();
52
+ });
53
+
54
+ it('throws the overflow even when a later fallback failed for another reason', async () => {
55
+ const overflow = signatureError('claude-haiku-4-5-20251001');
56
+ const unrelated = new Error('503 upstream unavailable');
57
+ stubModels([overflow, unrelated]);
58
+
59
+ await expect(
60
+ tryFallbackProviders({
61
+ fallbacks,
62
+ messages,
63
+ primaryError: new Error('primary boom'),
64
+ })
65
+ ).rejects.toThrow(/prompt is too long/i);
66
+ });
67
+
68
+ it('keeps last-error-wins when no fallback overflowed', async () => {
69
+ stubModels([
70
+ new Error('first failed'),
71
+ new Error('503 upstream unavailable'),
72
+ ]);
73
+
74
+ await expect(
75
+ tryFallbackProviders({
76
+ fallbacks,
77
+ messages,
78
+ primaryError: new Error('primary boom'),
79
+ })
80
+ ).rejects.toThrow(/upstream unavailable/);
81
+ });
82
+
83
+ it('surfaces a primary overflow when every fallback also fails', async () => {
84
+ stubModels([new Error('first failed'), new Error('second failed')]);
85
+
86
+ await expect(
87
+ tryFallbackProviders({
88
+ fallbacks,
89
+ messages,
90
+ primaryError: signatureError('us.amazon.nova-lite-v1:0'),
91
+ })
92
+ ).rejects.toThrow(/Input Tokens Exceeded/i);
93
+ });
94
+
95
+ it('recognises a reasonless Vertex overflow when given corroboration', async () => {
96
+ const vertex = signatureError('gemini-2.5-flash-lite');
97
+ stubModels([vertex, new Error('503 upstream unavailable')]);
98
+
99
+ await expect(
100
+ tryFallbackProviders({
101
+ fallbacks: [
102
+ { provider: Providers.VERTEXAI, maxContextTokens: 200_000 },
103
+ { provider: Providers.OPENAI },
104
+ ],
105
+ messages,
106
+ primaryError: new Error('primary boom'),
107
+ overflowContext: {
108
+ provider: Providers.VERTEXAI,
109
+ estimatedPromptTokens: 190_000,
110
+ },
111
+ })
112
+ ).rejects.toThrow(/Google request failed/);
113
+ });
114
+
115
+ it('uses the fallback context window to corroborate a Vertex overflow', async () => {
116
+ const vertex = signatureError('gemini-2.5-flash-lite');
117
+ stubModels([vertex, new Error('503 upstream unavailable')]);
118
+
119
+ const error = await tryFallbackProviders({
120
+ fallbacks: [
121
+ { provider: Providers.VERTEXAI, maxContextTokens: 32_000 },
122
+ { provider: Providers.OPENAI },
123
+ ],
124
+ messages,
125
+ primaryError: new Error('primary boom'),
126
+ overflowContext: {
127
+ provider: Providers.VERTEXAI,
128
+ estimatedPromptTokens: 50_000,
129
+ maxContextTokens: 200_000,
130
+ },
131
+ }).catch((fallbackError: Error) => fallbackError);
132
+
133
+ expect(error).toBe(vertex);
134
+ expect(getFallbackErrorContext(error)).toEqual({
135
+ provider: Providers.VERTEXAI,
136
+ clientOptions: undefined,
137
+ maxContextTokens: 32_000,
138
+ });
139
+ });
140
+
141
+ it('does not corroborate Vertex against the primary context window', async () => {
142
+ const vertex = signatureError('gemini-2.5-flash-lite');
143
+ stubModels([vertex, new Error('503 upstream unavailable')]);
144
+
145
+ await expect(
146
+ tryFallbackProviders({
147
+ fallbacks: [
148
+ { provider: Providers.VERTEXAI },
149
+ { provider: Providers.OPENAI },
150
+ ],
151
+ messages,
152
+ primaryError: new Error('primary boom'),
153
+ overflowContext: {
154
+ provider: Providers.OPENAI,
155
+ estimatedPromptTokens: 190_000,
156
+ maxContextTokens: 200_000,
157
+ },
158
+ })
159
+ ).rejects.toThrow(/upstream unavailable/);
160
+ });
161
+
162
+ it('prefers a fallback overflow over the primary one', async () => {
163
+ /**
164
+ * Reaching this function with an overflowing primary means the caller
165
+ * already failed to recover from it, so the fallback's — which sits
166
+ * against a different window — is the more useful of the two.
167
+ */
168
+ const primary = signatureError('gpt-5-nano');
169
+ const fallbackOverflow = signatureError('claude-haiku-4-5-20251001');
170
+ stubModels([fallbackOverflow, new Error('503 upstream unavailable')]);
171
+
172
+ await expect(
173
+ tryFallbackProviders({ fallbacks, messages, primaryError: primary })
174
+ ).rejects.toThrow(/prompt is too long/i);
175
+ });
176
+
177
+ it('attributes a fallback overflow to the client that produced it', async () => {
178
+ const fallbackOverflow = signatureError('claude-haiku-4-5-20251001');
179
+ stubModels([fallbackOverflow, new Error('503 upstream unavailable')]);
180
+
181
+ const thrown = await tryFallbackProviders({
182
+ fallbacks,
183
+ messages,
184
+ primaryError: new Error('primary boom'),
185
+ }).catch((error: unknown) => error);
186
+
187
+ const attribution = getFallbackErrorContext(thrown);
188
+ expect(attribution?.provider).toBe(Providers.ANTHROPIC);
189
+ });
190
+
191
+ it('retains every fallback overflow candidate', async () => {
192
+ const first = new Error(
193
+ 'This model\'s maximum context length is 4096 tokens. However, your messages resulted in 6000 tokens.'
194
+ );
195
+ const second = signatureError('claude-haiku-4-5-20251001');
196
+ stubModels([first, second]);
197
+
198
+ const thrown = await tryFallbackProviders({
199
+ fallbacks,
200
+ messages,
201
+ primaryError: new Error('primary boom'),
202
+ }).catch((error: unknown) => error);
203
+
204
+ expect(getFallbackOverflowCandidates(thrown)).toEqual([
205
+ {
206
+ error: first,
207
+ context: {
208
+ provider: Providers.ANTHROPIC,
209
+ clientOptions: undefined,
210
+ maxContextTokens: undefined,
211
+ },
212
+ },
213
+ {
214
+ error: second,
215
+ context: {
216
+ provider: Providers.OPENAI,
217
+ clientOptions: undefined,
218
+ maxContextTokens: undefined,
219
+ },
220
+ },
221
+ ]);
222
+ });
223
+
224
+ it('declines ambiguous pressure from another provider token space', async () => {
225
+ const vertex = signatureError('gemini-2.5-flash-lite');
226
+ stubModels([vertex, new Error('503 upstream unavailable')]);
227
+
228
+ await expect(
229
+ tryFallbackProviders({
230
+ fallbacks: [
231
+ { provider: Providers.VERTEXAI, maxContextTokens: 32_000 },
232
+ { provider: Providers.OPENAI },
233
+ ],
234
+ messages,
235
+ primaryError: new Error('primary boom'),
236
+ overflowContext: {
237
+ provider: Providers.OPENAI,
238
+ estimatedPromptTokens: 50_000,
239
+ maxContextTokens: 200_000,
240
+ },
241
+ })
242
+ ).rejects.toThrow(/upstream unavailable/);
243
+ });
244
+
245
+ it('continues past a frozen fallback error', async () => {
246
+ const fallbackOverflow = Object.freeze(
247
+ signatureError('claude-haiku-4-5-20251001')
248
+ );
249
+ stubModels([fallbackOverflow, 'ok']);
250
+
251
+ const result = await tryFallbackProviders({
252
+ fallbacks,
253
+ messages,
254
+ primaryError: new Error('primary boom'),
255
+ });
256
+
257
+ expect((result?.messages?.[0] as AIMessageChunk | undefined)?.content).toBe(
258
+ 'ok'
259
+ );
260
+ });
261
+
262
+ it('leaves a primary overflow unattributed', async () => {
263
+ stubModels([new Error('first failed'), new Error('second failed')]);
264
+
265
+ const thrown = await tryFallbackProviders({
266
+ fallbacks,
267
+ messages,
268
+ primaryError: signatureError('us.amazon.nova-lite-v1:0'),
269
+ }).catch((error: unknown) => error);
270
+
271
+ expect(getFallbackErrorContext(thrown)).toBeUndefined();
272
+ });
273
+
274
+ it('returns the first success without consulting later fallbacks', async () => {
275
+ stubModels([signatureError('claude-haiku-4-5-20251001'), 'ok']);
276
+
277
+ const result = await tryFallbackProviders({
278
+ fallbacks,
279
+ messages,
280
+ primaryError: new Error('primary boom'),
281
+ });
282
+
283
+ expect((result?.messages?.[0] as AIMessageChunk | undefined)?.content).toBe(
284
+ 'ok'
285
+ );
286
+ });
287
+ });