@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
@@ -145,6 +145,38 @@ describe('CodeAPI auth header injection', () => {
145
145
  );
146
146
  });
147
147
 
148
+ it('maps dynamic auth-header failures to the safe authorization error', async () => {
149
+ const authHeaders = jest.fn(async () => {
150
+ throw new Error(
151
+ 'credential helper failed for secret codeapi-signing-key in namespace internal-auth'
152
+ );
153
+ });
154
+ const tool = createProgrammaticToolCallingTool({ authHeaders });
155
+
156
+ const error = await tool
157
+ .invoke(
158
+ { code: 'print("hello")' },
159
+ {
160
+ toolCall: {
161
+ name: 'programmatic_code_execution',
162
+ args: {},
163
+ toolMap: toolMap(),
164
+ toolDefs,
165
+ },
166
+ }
167
+ )
168
+ .catch((caught: unknown) => caught);
169
+
170
+ expect(error).toBeInstanceOf(Error);
171
+ expect((error as Error).message).toContain(
172
+ 'Code execution is not authorized. Verify access before trying again.'
173
+ );
174
+ expect((error as Error).message).not.toContain('Please retry');
175
+ expect((error as Error).message).not.toContain('codeapi-signing-key');
176
+ expect((error as Error).message).not.toContain('internal-auth');
177
+ expect(fetchMock).not.toHaveBeenCalled();
178
+ });
179
+
148
180
  it('forwards Authorization for direct code execution', async () => {
149
181
  fetchMock.mockResolvedValueOnce(
150
182
  jsonResponse({ session_id: 'session_123', stdout: '1\n' })
@@ -202,12 +234,274 @@ describe('CodeAPI auth header injection', () => {
202
234
  ).not.toHaveProperty('authHeaders');
203
235
  });
204
236
 
205
- it('includes the CodeAPI endpoint and response body on direct execution failures', async () => {
237
+ it('redacts the CodeAPI endpoint and response body on direct execution failures', async () => {
206
238
  fetchMock.mockResolvedValueOnce(errorResponse(404, 'Cannot POST /exec'));
207
239
  const tool = createBashExecutionTool();
208
240
 
209
- await expect(tool.invoke({ command: 'echo 1' })).rejects.toThrow(
210
- /CodeAPI request failed: POST .*\/exec returned 404, body: Cannot POST \/exec/
241
+ const error = await tool
242
+ .invoke({ command: 'echo 1' })
243
+ .catch((caught: unknown) => caught);
244
+
245
+ expect(error).toBeInstanceOf(Error);
246
+ expect((error as Error).message).toContain(
247
+ 'Code execution is temporarily unavailable. Please retry.'
248
+ );
249
+ expect((error as Error).message).not.toContain('/exec');
250
+ expect((error as Error).message).not.toContain('Cannot POST');
251
+ });
252
+
253
+ it.each([401, 403])(
254
+ 'reports CodeAPI HTTP %s authorization failures as non-retryable',
255
+ async (status) => {
256
+ fetchMock.mockResolvedValueOnce(
257
+ errorResponse(
258
+ status,
259
+ 'Invalid bearer token for codeapi.internal.svc.cluster.local'
260
+ )
261
+ );
262
+ const tool = createBashExecutionTool();
263
+
264
+ const error = await tool
265
+ .invoke({ command: 'echo 1' })
266
+ .catch((caught: unknown) => caught);
267
+
268
+ expect(error).toBeInstanceOf(Error);
269
+ expect((error as Error).message).toContain(
270
+ 'Code execution is not authorized. Verify access before trying again.'
271
+ );
272
+ expect((error as Error).message).not.toContain('Please retry');
273
+ expect((error as Error).message).not.toContain('svc.cluster.local');
274
+ expect((error as Error).message).not.toContain('Invalid bearer token');
275
+ }
276
+ );
277
+
278
+ it('preserves only a bounded retry delay from CodeAPI rate-limit failures', async () => {
279
+ fetchMock.mockResolvedValueOnce(
280
+ errorResponse(
281
+ 429,
282
+ JSON.stringify({
283
+ error: 'rate_limited',
284
+ message:
285
+ 'Too many CodeAPI execution requests from internal deployment details.',
286
+ retry_after_seconds: 8.2,
287
+ })
288
+ )
289
+ );
290
+ const tool = createBashExecutionTool();
291
+
292
+ const error = await tool
293
+ .invoke({ command: 'echo 1' })
294
+ .catch((caught: unknown) => caught);
295
+
296
+ expect(error).toBeInstanceOf(Error);
297
+ expect((error as Error).message).toContain(
298
+ 'Code execution is temporarily rate-limited. Retry after 9 seconds.'
299
+ );
300
+ expect((error as Error).message).not.toContain('CodeAPI');
301
+ expect((error as Error).message).not.toContain('internal deployment');
302
+ });
303
+
304
+ it('redacts network details from direct execution failures', async () => {
305
+ fetchMock.mockRejectedValueOnce(
306
+ new Error(
307
+ 'request to http://codeapi.internal.svc.cluster.local/exec failed: getaddrinfo ENOTFOUND'
308
+ )
309
+ );
310
+ const tool = createBashExecutionTool();
311
+
312
+ const error = await tool
313
+ .invoke({ command: 'echo 1' })
314
+ .catch((caught: unknown) => caught);
315
+
316
+ expect(error).toBeInstanceOf(Error);
317
+ expect((error as Error).message).toContain(
318
+ 'Code execution is temporarily unavailable. Please retry.'
319
+ );
320
+ expect((error as Error).message).not.toContain('svc.cluster.local');
321
+ expect((error as Error).message).not.toContain('ENOTFOUND');
322
+ });
323
+
324
+ it('redacts network details from programmatic execution failures', async () => {
325
+ fetchMock.mockRejectedValueOnce(
326
+ new Error(
327
+ 'request to http://codeapi.internal.svc.cluster.local/exec/programmatic failed'
328
+ )
329
+ );
330
+ const tool = createProgrammaticToolCallingTool();
331
+
332
+ const error = await tool
333
+ .invoke(
334
+ { code: 'print("hello")' },
335
+ {
336
+ toolCall: {
337
+ name: 'programmatic_code_execution',
338
+ args: {},
339
+ toolMap: toolMap(),
340
+ toolDefs,
341
+ },
342
+ }
343
+ )
344
+ .catch((caught: unknown) => caught);
345
+
346
+ expect(error).toBeInstanceOf(Error);
347
+ expect((error as Error).message).toContain(
348
+ 'Code execution is temporarily unavailable. Please retry.'
349
+ );
350
+ expect((error as Error).message).not.toContain('svc.cluster.local');
351
+ });
352
+
353
+ it('redacts CodeAPI programmatic errors while preserving execution stderr', async () => {
354
+ fetchMock.mockResolvedValueOnce(
355
+ jsonResponse({
356
+ status: 'error',
357
+ error:
358
+ 'sandbox worker codeapi-runtime-7f9d failed in internal namespace',
359
+ stderr: 'NameError: tenant_variable is not defined',
360
+ })
361
+ );
362
+ const tool = createProgrammaticToolCallingTool();
363
+
364
+ const error = await tool
365
+ .invoke(
366
+ { code: 'print(tenant_variable)' },
367
+ {
368
+ toolCall: {
369
+ name: 'programmatic_code_execution',
370
+ args: {},
371
+ toolMap: toolMap(),
372
+ toolDefs,
373
+ },
374
+ }
375
+ )
376
+ .catch((caught: unknown) => caught);
377
+
378
+ expect(error).toBeInstanceOf(Error);
379
+ expect((error as Error).message).toContain('Code execution failed.');
380
+ expect((error as Error).message).toContain(
381
+ 'NameError: tenant_variable is not defined'
382
+ );
383
+ expect((error as Error).message).not.toContain('codeapi-runtime');
384
+ expect((error as Error).message).not.toContain('internal namespace');
385
+ });
386
+
387
+ it('preserves an allowlisted programmatic execution error when stderr is absent', async () => {
388
+ fetchMock.mockResolvedValueOnce(
389
+ jsonResponse({
390
+ status: 'error',
391
+ error: 'Time limit exceeded',
392
+ })
393
+ );
394
+ const tool = createProgrammaticToolCallingTool();
395
+
396
+ const error = await tool
397
+ .invoke(
398
+ { code: 'while True: pass' },
399
+ {
400
+ toolCall: {
401
+ name: 'programmatic_code_execution',
402
+ args: {},
403
+ toolMap: toolMap(),
404
+ toolDefs,
405
+ },
406
+ }
407
+ )
408
+ .catch((caught: unknown) => caught);
409
+
410
+ expect(error).toBeInstanceOf(Error);
411
+ expect((error as Error).message).toContain(
412
+ 'Code execution failed. Execution exceeded the time limit.'
413
+ );
414
+ });
415
+
416
+ it('preserves an allowlisted execution error alongside stderr', async () => {
417
+ fetchMock.mockResolvedValueOnce(
418
+ jsonResponse({
419
+ status: 'error',
420
+ error: 'Time limit exceeded',
421
+ stderr: 'processed 42 records before termination',
422
+ })
423
+ );
424
+ const tool = createProgrammaticToolCallingTool();
425
+
426
+ const error = await tool
427
+ .invoke(
428
+ { code: 'while True: process_next_record()' },
429
+ {
430
+ toolCall: {
431
+ name: 'programmatic_code_execution',
432
+ args: {},
433
+ toolMap: toolMap(),
434
+ toolDefs,
435
+ },
436
+ }
437
+ )
438
+ .catch((caught: unknown) => caught);
439
+
440
+ expect(error).toBeInstanceOf(Error);
441
+ expect((error as Error).message).toContain(
442
+ 'Code execution failed. Execution exceeded the time limit.'
443
+ );
444
+ expect((error as Error).message).toContain(
445
+ 'Stderr:\nprocessed 42 records before termination'
446
+ );
447
+ });
448
+
449
+ it('keeps arbitrary programmatic execution errors redacted when stderr is absent', async () => {
450
+ fetchMock.mockResolvedValueOnce(
451
+ jsonResponse({
452
+ status: 'error',
453
+ error:
454
+ 'sandbox worker codeapi-runtime-7f9d failed in internal namespace',
455
+ })
456
+ );
457
+ const tool = createProgrammaticToolCallingTool();
458
+
459
+ const error = await tool
460
+ .invoke(
461
+ { code: 'print("hello")' },
462
+ {
463
+ toolCall: {
464
+ name: 'programmatic_code_execution',
465
+ args: {},
466
+ toolMap: toolMap(),
467
+ toolDefs,
468
+ },
469
+ }
470
+ )
471
+ .catch((caught: unknown) => caught);
472
+
473
+ expect(error).toBeInstanceOf(Error);
474
+ expect((error as Error).message).toContain('Code execution failed.');
475
+ expect((error as Error).message).not.toContain('codeapi-runtime');
476
+ expect((error as Error).message).not.toContain('internal namespace');
477
+ });
478
+
479
+ it('preserves an allowlisted bash execution error when stderr is absent', async () => {
480
+ fetchMock.mockResolvedValueOnce(
481
+ jsonResponse({
482
+ status: 'error',
483
+ error: 'Out of memory',
484
+ })
485
+ );
486
+ const tool = createBashProgrammaticToolCallingTool();
487
+
488
+ const error = await tool
489
+ .invoke(
490
+ { code: 'lookup_user "{}"' },
491
+ {
492
+ toolCall: {
493
+ name: 'bash_programmatic_code_execution',
494
+ args: {},
495
+ toolMap: toolMap(),
496
+ toolDefs,
497
+ },
498
+ }
499
+ )
500
+ .catch((caught: unknown) => caught);
501
+
502
+ expect(error).toBeInstanceOf(Error);
503
+ expect((error as Error).message).toContain(
504
+ 'Code execution failed. Execution exceeded the memory limit.'
211
505
  );
212
506
  });
213
507
 
package/src/types/llm.ts CHANGED
@@ -137,10 +137,17 @@ export type SharedLLMConfig = {
137
137
  _lc_stream_delay?: number;
138
138
  };
139
139
 
140
+ export interface FallbackConfig {
141
+ provider: Providers;
142
+ clientOptions?: ClientOptions;
143
+ /** Context window used to corroborate ambiguous fallback overflow errors. */
144
+ maxContextTokens?: number;
145
+ }
146
+
140
147
  export type LLMConfig = SharedLLMConfig &
141
148
  ClientOptions & {
142
149
  /** Optional provider fallbacks in order of attempt */
143
- fallbacks?: Array<{ provider: Providers; clientOptions?: ClientOptions }>;
150
+ fallbacks?: FallbackConfig[];
144
151
  };
145
152
 
146
153
  export type ProviderOptionsMap = {
@@ -68,13 +68,14 @@ export type RunStep = {
68
68
  runId?: string; // #new
69
69
  agentId?: string; // #new - tracks which agent this step belongs to
70
70
  /**
71
- * Group ID - incrementing number (1, 2, 3...) reflecting execution order.
72
- * Agents with the same groupId run in parallel and should be rendered together.
73
- * undefined means the agent runs sequentially (not part of any parallel group).
71
+ * Opaque positive safe-integer identifier for parallel execution.
72
+ * Agents with the same groupId should be rendered together.
73
+ * Consumers must use content indexes, not groupId ordering, for execution order.
74
+ * undefined means the agent runs sequentially (not part of a parallel group).
74
75
  *
75
76
  * Example for: researcher -> [analyst1, analyst2, analyst3] -> summarizer
76
77
  * - researcher: undefined (sequential)
77
- * - analyst1, analyst2, analyst3: 1 (first parallel group)
78
+ * - analyst1, analyst2, analyst3: the same groupId (parallel group)
78
79
  * - summarizer: undefined (sequential)
79
80
  */
80
81
  groupId?: number; // #new
@@ -63,6 +63,28 @@ export interface SummarizeResult {
63
63
  export interface SummarizationNodeInput {
64
64
  remainingContextTokens: number;
65
65
  agentId: string;
66
+ /**
67
+ * Why the detour was requested.
68
+ *
69
+ * - `trigger` (default): the configured summarization trigger fired during
70
+ * the pre-call budget check.
71
+ * - `overflow`: the provider rejected the prompt as too large and the run
72
+ * is compacting to recover. When summarization is not enabled, this
73
+ * variant performs no model call — the corrected budget alone is what the
74
+ * retry needs.
75
+ */
76
+ reason?: 'trigger' | 'overflow';
77
+ /**
78
+ * Whether an overflow recovery may spend a summarization model call.
79
+ *
80
+ * The first recovery deliberately does not: re-pruning against the
81
+ * corrected budget raises context pressure, which drives the pruner's
82
+ * existing tool-output compression and masking. That is cheaper, needs no
83
+ * model call, and cannot lose message content the way a summary can. Only
84
+ * when deterministic compression proves insufficient does the next attempt
85
+ * allow the summarizer to run.
86
+ */
87
+ allowSummarization?: boolean;
66
88
  }
67
89
 
68
90
  export interface SummarizeStartEvent {
@@ -0,0 +1,270 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import {
3
+ getContextOverflowInfo,
4
+ isLikelyContextOverflowError,
5
+ isContextOverflowError,
6
+ extractErrorMessage,
7
+ } from '@/utils/errors';
8
+ import {
9
+ OVERFLOW_SIGNATURES,
10
+ NON_OVERFLOW_SIGNATURES,
11
+ } from './fixtures/contextOverflowSignatures';
12
+ import { Providers } from '@/common';
13
+
14
+ /** Enough pressure to satisfy the corroboration gate for ambiguous errors. */
15
+ const UNDER_PRESSURE = {
16
+ estimatedPromptTokens: 190_000,
17
+ maxContextTokens: 200_000,
18
+ };
19
+
20
+ describe('getContextOverflowInfo — captured provider signatures', () => {
21
+ for (const signature of OVERFLOW_SIGNATURES) {
22
+ const label = `${signature.provider}/${signature.model} (${signature.thrownAs})`;
23
+
24
+ it(`classifies ${label}`, () => {
25
+ const info = getContextOverflowInfo(signature.error, {
26
+ provider: signature.provider,
27
+ ...(signature.requiresContextPressure === true ? UNDER_PRESSURE : {}),
28
+ });
29
+
30
+ expect(info).not.toBeNull();
31
+ expect(info?.kind).toBe(signature.expected.kind);
32
+ expect(info?.provider).toBe(signature.provider);
33
+ expect(info?.limitTokens).toBe(signature.expected.limitTokens);
34
+ expect(info?.requestedTokens).toBe(signature.expected.requestedTokens);
35
+ });
36
+ }
37
+
38
+ it('covers every provider the SDK ships a client for, or records why not', () => {
39
+ const covered = new Set(OVERFLOW_SIGNATURES.map((s) => s.provider));
40
+ /**
41
+ * Azure and Moonshot reuse the OpenAI client verbatim, and MistralAI is
42
+ * the same client as Mistral, so the captured OpenAI/Mistral signatures
43
+ * apply unchanged. Every other provider has its own captured entry.
44
+ */
45
+ const byOpenAIClient = new Set([
46
+ Providers.AZURE,
47
+ Providers.MOONSHOT,
48
+ Providers.MISTRALAI,
49
+ ]);
50
+ for (const provider of Object.values(Providers)) {
51
+ expect(covered.has(provider) || byOpenAIClient.has(provider)).toBe(true);
52
+ }
53
+ });
54
+ });
55
+
56
+ describe('getContextOverflowInfo — errors compaction cannot fix', () => {
57
+ for (const signature of NON_OVERFLOW_SIGNATURES) {
58
+ it(`ignores ${signature.label}`, () => {
59
+ expect(getContextOverflowInfo(signature.error)).toBeNull();
60
+ expect(isContextOverflowError(signature.error)).toBe(false);
61
+ });
62
+ }
63
+
64
+ it('ignores an unrelated error entirely', () => {
65
+ expect(getContextOverflowInfo(new Error('socket hang up'))).toBeNull();
66
+ expect(getContextOverflowInfo(undefined)).toBeNull();
67
+ expect(getContextOverflowInfo(null)).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe('token-bucket rejections', () => {
72
+ const requestTooLarge = OVERFLOW_SIGNATURES.find(
73
+ (s) => s.expected.kind === 'request_too_large'
74
+ );
75
+
76
+ it('treats a request that alone overruns the bucket as recoverable', () => {
77
+ const info = getContextOverflowInfo(requestTooLarge?.error);
78
+ expect(info?.kind).toBe('request_too_large');
79
+ expect(info?.requestedTokens).toBeGreaterThan(info?.limitTokens ?? 0);
80
+ });
81
+
82
+ it('treats an exactly-filling request as throttling, not overflow', () => {
83
+ /** It fits an empty bucket, so waiting can succeed — compaction would lose context needlessly. */
84
+ const exactFit = {
85
+ name: 'Error',
86
+ status: 429,
87
+ code: 'rate_limit_exceeded',
88
+ type: 'tokens',
89
+ message:
90
+ '429 Request too large for gpt-4o on tokens per min (TPM): Limit 30000, Requested 30000.',
91
+ };
92
+ expect(getContextOverflowInfo(exactFit)).toBeNull();
93
+ });
94
+
95
+ it('leaves ordinary throttling alone even when phrased with the same fields', () => {
96
+ const throttled = {
97
+ name: 'Error',
98
+ status: 429,
99
+ code: 'rate_limit_exceeded',
100
+ type: 'tokens',
101
+ message:
102
+ '429 Request too large for gpt-4o on tokens per min (TPM): Limit 30000, Requested 900.',
103
+ };
104
+ expect(getContextOverflowInfo(throttled)).toBeNull();
105
+ });
106
+ });
107
+
108
+ describe('ambiguous signatures require corroboration', () => {
109
+ const vertex = OVERFLOW_SIGNATURES.find(
110
+ (s) => s.requiresContextPressure === true
111
+ );
112
+
113
+ it('does not fire without a caller-side pressure signal', () => {
114
+ expect(
115
+ getContextOverflowInfo(vertex?.error, { provider: Providers.VERTEXAI })
116
+ ).toBeNull();
117
+ });
118
+
119
+ it('does not fire when the prompt was comfortably inside the budget', () => {
120
+ expect(
121
+ getContextOverflowInfo(vertex?.error, {
122
+ provider: Providers.VERTEXAI,
123
+ estimatedPromptTokens: 20_000,
124
+ maxContextTokens: 200_000,
125
+ })
126
+ ).toBeNull();
127
+ });
128
+
129
+ it('fires once the prompt is near the budget', () => {
130
+ expect(
131
+ getContextOverflowInfo(vertex?.error, {
132
+ provider: Providers.VERTEXAI,
133
+ ...UNDER_PRESSURE,
134
+ })
135
+ ).not.toBeNull();
136
+ });
137
+
138
+ it('still reports a Vertex error that did carry a reason', () => {
139
+ const withBody = {
140
+ name: 'Error',
141
+ message:
142
+ 'Google request failed with status code 400: {"error":{"code":400,"message":"The input token count exceeds the maximum number of tokens allowed (1048576).","status":"INVALID_ARGUMENT"}}',
143
+ };
144
+ const info = getContextOverflowInfo(withBody, {
145
+ provider: Providers.VERTEXAI,
146
+ });
147
+ expect(info?.limitTokens).toBe(1_048_576);
148
+ });
149
+ });
150
+
151
+ describe('prompt size is separated from completion-inclusive totals', () => {
152
+ it('reads the input-only figure out of OpenRouter’s breakdown', () => {
153
+ const openrouter = OVERFLOW_SIGNATURES.find(
154
+ (s) => s.provider === Providers.OPENROUTER
155
+ );
156
+ const info = getContextOverflowInfo(openrouter?.error);
157
+ expect(info?.requestedTokens).toBe(56_827);
158
+ /** 56,811 of text input; the remaining 16 were the output allowance. */
159
+ expect(info?.promptTokens).toBe(56_811);
160
+ });
161
+
162
+ it('reads the input-only figure out of DeepSeek’s breakdown', () => {
163
+ const deepseek = OVERFLOW_SIGNATURES.find(
164
+ (s) => s.provider === Providers.DEEPSEEK
165
+ );
166
+ const info = getContextOverflowInfo(deepseek?.error);
167
+ expect(info?.requestedTokens).toBe(1_179_668);
168
+ expect(info?.promptTokens).toBe(1_179_652);
169
+ });
170
+
171
+ it('treats an unqualified input-only count as the prompt', () => {
172
+ const anthropic = OVERFLOW_SIGNATURES.find(
173
+ (s) => s.provider === Providers.ANTHROPIC
174
+ );
175
+ const info = getContextOverflowInfo(anthropic?.error);
176
+ expect(info?.promptTokens).toBe(info?.requestedTokens);
177
+ });
178
+
179
+ it('refuses to read a token-bucket total as a prompt measurement', () => {
180
+ const openai = OVERFLOW_SIGNATURES.find(
181
+ (s) => s.expected.kind === 'request_too_large'
182
+ );
183
+ const info = getContextOverflowInfo(openai?.error);
184
+ /** "Requested 480002" folds in the completion allowance. */
185
+ expect(info?.requestedTokens).toBe(480_002);
186
+ expect(info?.promptTokens).toBeUndefined();
187
+ });
188
+
189
+ it('refuses to read xAI’s request total as a prompt measurement', () => {
190
+ const xai = OVERFLOW_SIGNATURES.find((s) => s.provider === Providers.XAI);
191
+ const info = getContextOverflowInfo(xai?.error);
192
+ expect(info?.requestedTokens).toBe(332_986);
193
+ expect(info?.promptTokens).toBeUndefined();
194
+ });
195
+ });
196
+
197
+ describe('reported numbers are usable for recalibration', () => {
198
+ it('reads the true ceiling even when it differs from what we configured', () => {
199
+ const deepseek = OVERFLOW_SIGNATURES.find(
200
+ (s) => s.provider === Providers.DEEPSEEK
201
+ );
202
+ const info = getContextOverflowInfo(deepseek?.error);
203
+ expect(info?.limitTokens).toBe(1_048_565);
204
+ });
205
+
206
+ it('reads the provider-side count of the prompt we sent', () => {
207
+ const bedrock = OVERFLOW_SIGNATURES.find(
208
+ (s) => s.model === 'us.anthropic.claude-haiku-4-5-20251001-v1:0'
209
+ );
210
+ const info = getContextOverflowInfo(bedrock?.error);
211
+ expect(info?.requestedTokens).toBe(207_848);
212
+ expect(info?.limitTokens).toBe(200_000);
213
+ });
214
+ });
215
+
216
+ describe('back-compatible helpers', () => {
217
+ it('accepts a bare message string', () => {
218
+ expect(
219
+ isContextOverflowError(
220
+ 'prompt is too long: 274468 tokens > 200000 maximum'
221
+ )
222
+ ).toBe(true);
223
+ expect(isContextOverflowError('connection reset')).toBe(false);
224
+ });
225
+
226
+ it('retains the legacy space-separated context overflow signature', () => {
227
+ expect(isContextOverflowError('context length exceeded')).toBe(true);
228
+ });
229
+
230
+ it('retains the legacy input-too-long signature', () => {
231
+ expect(getContextOverflowInfo(new Error('Input too long'))).not.toBeNull();
232
+ expect(isContextOverflowError('Input too long')).toBe(true);
233
+ });
234
+
235
+ it('reads structured overflow reason fields alongside generic messages', () => {
236
+ expect(
237
+ isContextOverflowError({
238
+ message: 'Bad Request',
239
+ code: 'context_length_exceeded',
240
+ })
241
+ ).toBe(true);
242
+ expect(
243
+ isContextOverflowError({
244
+ message: 'Bad Request',
245
+ error: { type: 'context_length_exceeded' },
246
+ })
247
+ ).toBe(true);
248
+ });
249
+
250
+ it('treats body-size rejections as likely overflow only in the loose check', () => {
251
+ const payload = { status: 413, message: 'request entity too large' };
252
+ expect(isContextOverflowError(payload)).toBe(false);
253
+ expect(isLikelyContextOverflowError(payload)).toBe(true);
254
+ });
255
+
256
+ it('keeps the loose check away from throttling and auth', () => {
257
+ for (const signature of NON_OVERFLOW_SIGNATURES) {
258
+ expect(isLikelyContextOverflowError(signature.error)).toBe(false);
259
+ }
260
+ });
261
+
262
+ it('extracts messages from nested provider shapes', () => {
263
+ expect(extractErrorMessage(new Error('boom'))).toBe('boom');
264
+ expect(extractErrorMessage({ error: { message: 'nested' } })).toBe(
265
+ 'nested'
266
+ );
267
+ expect(extractErrorMessage('raw')).toBe('raw');
268
+ expect(extractErrorMessage(null)).toBe('');
269
+ });
270
+ });