@librechat/agents 3.2.61 → 3.2.63

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 (125) hide show
  1. package/dist/cjs/common/enum.cjs +2 -0
  2. package/dist/cjs/common/enum.cjs.map +1 -1
  3. package/dist/cjs/graphs/Graph.cjs +6 -2
  4. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  5. package/dist/cjs/hooks/HookRegistry.cjs +32 -0
  6. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  7. package/dist/cjs/hooks/executeHooks.cjs +6 -0
  8. package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
  9. package/dist/cjs/hooks/index.cjs +12 -0
  10. package/dist/cjs/hooks/index.cjs.map +1 -0
  11. package/dist/cjs/hooks/types.cjs.map +1 -1
  12. package/dist/cjs/langfuse.cjs +62 -1
  13. package/dist/cjs/langfuse.cjs.map +1 -1
  14. package/dist/cjs/llm/bedrock/index.cjs +266 -43
  15. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  16. package/dist/cjs/llm/openai/index.cjs +274 -7
  17. package/dist/cjs/llm/openai/index.cjs.map +1 -1
  18. package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
  19. package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
  20. package/dist/cjs/llm/openrouter/index.cjs +5 -6
  21. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  22. package/dist/cjs/main.cjs +6 -1
  23. package/dist/cjs/messages/format.cjs +61 -0
  24. package/dist/cjs/messages/format.cjs.map +1 -1
  25. package/dist/cjs/messages/prune.cjs +31 -19
  26. package/dist/cjs/messages/prune.cjs.map +1 -1
  27. package/dist/cjs/stream.cjs +13 -3
  28. package/dist/cjs/stream.cjs.map +1 -1
  29. package/dist/cjs/tools/ToolNode.cjs +76 -11
  30. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  31. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +2 -0
  32. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  33. package/dist/cjs/utils/tokens.cjs +111 -0
  34. package/dist/cjs/utils/tokens.cjs.map +1 -1
  35. package/dist/esm/common/enum.mjs +2 -0
  36. package/dist/esm/common/enum.mjs.map +1 -1
  37. package/dist/esm/graphs/Graph.mjs +6 -2
  38. package/dist/esm/graphs/Graph.mjs.map +1 -1
  39. package/dist/esm/hooks/HookRegistry.mjs +32 -0
  40. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  41. package/dist/esm/hooks/executeHooks.mjs +6 -0
  42. package/dist/esm/hooks/executeHooks.mjs.map +1 -1
  43. package/dist/esm/hooks/index.mjs +12 -1
  44. package/dist/esm/hooks/index.mjs.map +1 -0
  45. package/dist/esm/hooks/types.mjs.map +1 -1
  46. package/dist/esm/langfuse.mjs +62 -1
  47. package/dist/esm/langfuse.mjs.map +1 -1
  48. package/dist/esm/llm/bedrock/index.mjs +266 -43
  49. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  50. package/dist/esm/llm/openai/index.mjs +274 -7
  51. package/dist/esm/llm/openai/index.mjs.map +1 -1
  52. package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
  53. package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
  54. package/dist/esm/llm/openrouter/index.mjs +5 -6
  55. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  56. package/dist/esm/main.mjs +3 -3
  57. package/dist/esm/messages/format.mjs +61 -0
  58. package/dist/esm/messages/format.mjs.map +1 -1
  59. package/dist/esm/messages/prune.mjs +31 -19
  60. package/dist/esm/messages/prune.mjs.map +1 -1
  61. package/dist/esm/stream.mjs +13 -3
  62. package/dist/esm/stream.mjs.map +1 -1
  63. package/dist/esm/tools/ToolNode.mjs +76 -11
  64. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  65. package/dist/esm/tools/subagent/SubagentExecutor.mjs +2 -0
  66. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  67. package/dist/esm/utils/tokens.mjs +108 -1
  68. package/dist/esm/utils/tokens.mjs.map +1 -1
  69. package/dist/types/common/enum.d.ts +3 -1
  70. package/dist/types/graphs/Graph.d.ts +3 -1
  71. package/dist/types/hooks/HookRegistry.d.ts +10 -0
  72. package/dist/types/hooks/index.d.ts +7 -0
  73. package/dist/types/hooks/types.d.ts +18 -1
  74. package/dist/types/llm/bedrock/index.d.ts +5 -0
  75. package/dist/types/llm/openai/index.d.ts +19 -0
  76. package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
  77. package/dist/types/messages/prune.d.ts +4 -3
  78. package/dist/types/tools/ToolNode.d.ts +1 -0
  79. package/dist/types/types/graph.d.ts +10 -0
  80. package/dist/types/types/llm.d.ts +12 -2
  81. package/dist/types/types/tools.d.ts +1 -1
  82. package/dist/types/utils/tokens.d.ts +30 -0
  83. package/package.json +6 -6
  84. package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
  85. package/src/common/enum.ts +2 -0
  86. package/src/graphs/Graph.ts +12 -4
  87. package/src/hooks/HookRegistry.ts +45 -0
  88. package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
  89. package/src/hooks/__tests__/executeHooks.test.ts +85 -2
  90. package/src/hooks/executeHooks.ts +15 -0
  91. package/src/hooks/index.ts +7 -0
  92. package/src/hooks/types.ts +18 -1
  93. package/src/langfuse.ts +134 -1
  94. package/src/llm/bedrock/index.ts +434 -83
  95. package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
  96. package/src/llm/custom-chat-models.smoke.test.ts +7 -0
  97. package/src/llm/openai/index.ts +604 -6
  98. package/src/llm/openai/managedRequests.test.ts +182 -0
  99. package/src/llm/openai/streamMetadata.spec.ts +86 -0
  100. package/src/llm/openai/streamMetadata.ts +95 -0
  101. package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
  102. package/src/llm/openrouter/index.ts +9 -5
  103. package/src/messages/format.ts +96 -3
  104. package/src/messages/formatAgentMessages.steer.test.ts +326 -0
  105. package/src/messages/labelContentByAgent.test.ts +75 -0
  106. package/src/messages/prune.ts +56 -30
  107. package/src/specs/anthropic.simple.test.ts +4 -2
  108. package/src/specs/cache.simple.test.ts +17 -4
  109. package/src/specs/langfuse-callbacks.test.ts +61 -0
  110. package/src/specs/openai.simple.test.ts +4 -2
  111. package/src/specs/spec.utils.ts +12 -0
  112. package/src/specs/summarization.test.ts +9 -13
  113. package/src/specs/token-accounting-pipeline.test.ts +130 -4
  114. package/src/specs/tokens.test.ts +214 -0
  115. package/src/stream.ts +22 -3
  116. package/src/tools/ToolNode.ts +112 -13
  117. package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
  118. package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
  119. package/src/tools/__tests__/hitl.test.ts +112 -0
  120. package/src/tools/__tests__/subagentHooks.test.ts +124 -0
  121. package/src/tools/subagent/SubagentExecutor.ts +3 -0
  122. package/src/types/graph.ts +10 -0
  123. package/src/types/llm.ts +12 -2
  124. package/src/types/tools.ts +1 -1
  125. package/src/utils/tokens.ts +181 -3
@@ -11,7 +11,7 @@ import {
11
11
  } from '@langchain/core/messages';
12
12
  import type * as t from '@/types';
13
13
  import { ModelEndHandler, ToolEndHandler } from '@/events';
14
- import { capitalizeFirstLetter } from './spec.utils';
14
+ import { capitalizeFirstLetter, hasEnv, hasEveryEnv } from './spec.utils';
15
15
  import { createContentAggregator } from '@/stream';
16
16
  import { GraphEvents, Providers } from '@/common';
17
17
  import { getLLMConfig } from '@/utils/llmConfig';
@@ -82,7 +82,20 @@ describe('Prompt Caching Integration Tests', () => {
82
82
  version: 'v2' as const,
83
83
  };
84
84
 
85
- describe('Anthropic Prompt Caching', () => {
85
+ const requiredBedrockEnv = [
86
+ 'BEDROCK_AWS_REGION',
87
+ 'BEDROCK_AWS_ACCESS_KEY_ID',
88
+ 'BEDROCK_AWS_SECRET_ACCESS_KEY',
89
+ ];
90
+ const hasAnthropicApiKey = hasEnv('ANTHROPIC_API_KEY');
91
+ const hasBedrockCredentials = hasEveryEnv(requiredBedrockEnv);
92
+
93
+ const describeIfAnthropic = hasAnthropicApiKey ? describe : describe.skip;
94
+ const describeIfBedrock = hasBedrockCredentials ? describe : describe.skip;
95
+ const describeIfAnthropicAndBedrock =
96
+ hasAnthropicApiKey && hasBedrockCredentials ? describe : describe.skip;
97
+
98
+ describeIfAnthropic('Anthropic Prompt Caching', () => {
86
99
  const provider = Providers.ANTHROPIC;
87
100
 
88
101
  test(`${capitalizeFirstLetter(provider)}: multi-turn conversation with caching should not corrupt messages`, async () => {
@@ -212,7 +225,7 @@ describe('Prompt Caching Integration Tests', () => {
212
225
  });
213
226
  });
214
227
 
215
- describe('Bedrock Prompt Caching', () => {
228
+ describeIfBedrock('Bedrock Prompt Caching', () => {
216
229
  const provider = Providers.BEDROCK;
217
230
 
218
231
  test(`${capitalizeFirstLetter(provider)}: multi-turn conversation with caching should not corrupt messages`, async () => {
@@ -340,7 +353,7 @@ describe('Prompt Caching Integration Tests', () => {
340
353
  });
341
354
  });
342
355
 
343
- describe('Cross-provider message isolation', () => {
356
+ describeIfAnthropicAndBedrock('Cross-provider message isolation', () => {
344
357
  test('Messages processed by Anthropic should not affect Bedrock processing', async () => {
345
358
  const anthropicConfig = getLLMConfig(Providers.ANTHROPIC);
346
359
  const bedrockConfig = getLLMConfig(Providers.BEDROCK);
@@ -1,7 +1,9 @@
1
1
  import { HumanMessage } from '@langchain/core/messages';
2
+ import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
2
3
  import { CallbackManager } from '@langchain/core/callbacks/manager';
3
4
  import { context as otelContext, trace as otelTrace } from '@opentelemetry/api';
4
5
  import type * as t from '@/types';
6
+ import { handleConverseStreamMetadata } from '@/llm/bedrock/utils/message_outputs';
5
7
  import { traceIdFromSeed } from '@/langfuseRuntimeContext';
6
8
  import { Providers } from '@/common';
7
9
  import { Run } from '@/run';
@@ -387,6 +389,65 @@ describe('Langfuse callback composition', () => {
387
389
  });
388
390
  });
389
391
 
392
+ it('exports Bedrock prompt-cache usage buckets to Langfuse', async () => {
393
+ const { createLangfuseHandler } = await import('@/langfuse');
394
+ const { initializeLangfuseTracing } = await import('@/instrumentation');
395
+ initializeLangfuseTracing({
396
+ publicKey: 'pk-test',
397
+ secretKey: 'sk-test',
398
+ });
399
+ const handler = createLangfuseHandler({
400
+ langfuse: {
401
+ publicKey: 'pk-test',
402
+ secretKey: 'sk-test',
403
+ },
404
+ });
405
+ const runId = 'test-langfuse-bedrock-cache-usage';
406
+
407
+ await handler?.handleChatModelStart(
408
+ {
409
+ lc: 1,
410
+ type: 'constructor',
411
+ id: ['LibreChatBedrockConverse'],
412
+ kwargs: {},
413
+ },
414
+ [[new HumanMessage('hello')]],
415
+ runId
416
+ );
417
+
418
+ const generation = handleConverseStreamMetadata(
419
+ {
420
+ usage: {
421
+ inputTokens: 13,
422
+ outputTokens: 5,
423
+ totalTokens: 20849,
424
+ cacheReadInputTokens: 10831,
425
+ cacheWriteInputTokens: 10000,
426
+ },
427
+ metrics: { latencyMs: 1000 },
428
+ },
429
+ { streamUsage: true }
430
+ );
431
+ await handler?.handleLLMEnd({ generations: [[generation]] }, runId);
432
+
433
+ const usageDetails = mockSpanAttributeSets
434
+ .map(
435
+ (attributes) =>
436
+ attributes[LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS]
437
+ )
438
+ .find((value): value is string => typeof value === 'string');
439
+
440
+ expect(usageDetails).toBe(
441
+ JSON.stringify({
442
+ input: 13,
443
+ output: 5,
444
+ total: 20849,
445
+ input_cache_read: 10831,
446
+ input_cache_creation: 10000,
447
+ })
448
+ );
449
+ });
450
+
390
451
  it('uses deterministic trace ids when tracing is configured from env only', async () => {
391
452
  process.env.LANGFUSE_PUBLIC_KEY = 'pk-env';
392
453
  process.env.LANGFUSE_SECRET_KEY = 'sk-env';
@@ -16,14 +16,16 @@ import {
16
16
  createMetadataAggregator,
17
17
  } from '@/events';
18
18
  import { ContentTypes, GraphEvents, Providers, TitleMethod } from '@/common';
19
- import { capitalizeFirstLetter } from './spec.utils';
19
+ import { capitalizeFirstLetter, hasEnv } from './spec.utils';
20
20
  import { createContentAggregator } from '@/stream';
21
21
  import { getLLMConfig } from '@/utils/llmConfig';
22
22
  import { getArgs } from '@/scripts/args';
23
23
  import { Run } from '@/run';
24
24
 
25
25
  const provider = Providers.OPENAI;
26
- describe(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
26
+ const describeIfOpenAI = hasEnv('OPENAI_API_KEY') ? describe : describe.skip;
27
+
28
+ describeIfOpenAI(`${capitalizeFirstLetter(provider)} Streaming Tests`, () => {
27
29
  jest.setTimeout(30000);
28
30
  let run: Run<t.IState>;
29
31
  let runningHistory: BaseMessage[];
@@ -1,3 +1,15 @@
1
1
  export function capitalizeFirstLetter(string: string): string {
2
2
  return string.charAt(0).toUpperCase() + string.slice(1);
3
3
  }
4
+
5
+ export function hasEnv(key: string): boolean {
6
+ return (process.env[key]?.trim() ?? '') !== '';
7
+ }
8
+
9
+ export function hasEveryEnv(keys: readonly string[]): boolean {
10
+ return keys.every(hasEnv);
11
+ }
12
+
13
+ export function hasAnyEnv(keys: readonly string[]): boolean {
14
+ return keys.some(hasEnv);
15
+ }
@@ -21,6 +21,7 @@ import { Run } from '@/run';
21
21
  import { formatAgentMessages } from '@/messages/format';
22
22
  import { FakeListChatModel } from '@langchain/core/utils/testing';
23
23
  import * as providers from '@/llm/providers';
24
+ import { hasAnyEnv, hasEnv, hasEveryEnv } from './spec.utils';
24
25
 
25
26
  const SUMMARY_WRAPPER_OVERHEAD_TOKENS = 33;
26
27
 
@@ -287,7 +288,7 @@ function logTurn(
287
288
  // Anthropic Summarization Tests
288
289
  // ---------------------------------------------------------------------------
289
290
 
290
- const hasAnthropic = process.env.ANTHROPIC_API_KEY != null;
291
+ const hasAnthropic = hasEnv('ANTHROPIC_API_KEY');
291
292
  (hasAnthropic ? describe : describe.skip)('Anthropic Summarization E2E', () => {
292
293
  jest.setTimeout(180_000);
293
294
 
@@ -599,7 +600,7 @@ const hasAnthropic = process.env.ANTHROPIC_API_KEY != null;
599
600
  }, 180_000);
600
601
 
601
602
  test('cross-provider summarization: Anthropic agent with OpenAI summarizer', async () => {
602
- const hasOpenAI = process.env.OPENAI_API_KEY != null;
603
+ const hasOpenAI = hasEnv('OPENAI_API_KEY');
603
604
  if (!hasOpenAI) {
604
605
  console.log(' Skipping cross-provider test (no OPENAI_API_KEY)');
605
606
  return;
@@ -1026,7 +1027,7 @@ const requiredBedrockEnv = [
1026
1027
  'BEDROCK_AWS_ACCESS_KEY_ID',
1027
1028
  'BEDROCK_AWS_SECRET_ACCESS_KEY',
1028
1029
  ];
1029
- const hasBedrock = requiredBedrockEnv.every((k) => process.env[k] != null);
1030
+ const hasBedrock = hasEveryEnv(requiredBedrockEnv);
1030
1031
 
1031
1032
  (hasBedrock ? describe : describe.skip)('Bedrock Summarization E2E', () => {
1032
1033
  jest.setTimeout(180_000);
@@ -1159,7 +1160,7 @@ const hasBedrock = requiredBedrockEnv.every((k) => process.env[k] != null);
1159
1160
  // OpenAI Summarization Tests
1160
1161
  // ---------------------------------------------------------------------------
1161
1162
 
1162
- const hasOpenAI = process.env.OPENAI_API_KEY != null;
1163
+ const hasOpenAI = hasEnv('OPENAI_API_KEY');
1163
1164
  (hasOpenAI ? describe : describe.skip)('OpenAI Summarization E2E', () => {
1164
1165
  jest.setTimeout(120_000);
1165
1166
 
@@ -2111,7 +2112,6 @@ describe('Tight context with oversized tool results (no API keys)', () => {
2111
2112
  constructor(_options: any) {
2112
2113
  super({ responses: [SUMMARY_RESPONSE] });
2113
2114
  }
2114
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
2115
2115
  async *_streamResponseChunks(
2116
2116
  messages: any[],
2117
2117
  options: any,
@@ -2419,17 +2419,14 @@ describe('Tight context with oversized tool results (no API keys)', () => {
2419
2419
  // Token accounting audit (requires API keys)
2420
2420
  // ---------------------------------------------------------------------------
2421
2421
 
2422
- const hasAnyApiKey =
2423
- process.env.ANTHROPIC_API_KEY != null || process.env.OPENAI_API_KEY != null;
2422
+ const hasAnyApiKey = hasAnyEnv(['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']);
2424
2423
 
2425
2424
  (hasAnyApiKey ? describe : describe.skip)('Token accounting audit', () => {
2426
2425
  jest.setTimeout(180_000);
2427
2426
 
2428
- const agentProvider =
2429
- process.env.ANTHROPIC_API_KEY != null &&
2430
- process.env.ANTHROPIC_API_KEY !== ''
2431
- ? Providers.ANTHROPIC
2432
- : Providers.OPENAI;
2427
+ const agentProvider = hasEnv('ANTHROPIC_API_KEY')
2428
+ ? Providers.ANTHROPIC
2429
+ : Providers.OPENAI;
2433
2430
  const summarizationProvider = agentProvider;
2434
2431
  const summarizationModel =
2435
2432
  agentProvider === Providers.ANTHROPIC ? 'claude-haiku-4-5' : 'gpt-4.1-mini';
@@ -2960,7 +2957,6 @@ describe('Summarization deduplication correctness (no API keys)', () => {
2960
2957
  chunkCallCount++;
2961
2958
  super({ responses: [response] });
2962
2959
  }
2963
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
2964
2960
  async *_streamResponseChunks(
2965
2961
  messages: any[],
2966
2962
  options: any,
@@ -727,7 +727,7 @@ describe('Token accounting pipeline — end-to-end multi-turn with calibration',
727
727
  });
728
728
 
729
729
  describe('Token accounting pipeline — Anthropic vs OpenAI cache semantics', () => {
730
- it('Anthropic additive cache inflates calibration input correctly', () => {
730
+ it('Anthropic normalized cache input calibrates from input_tokens only', () => {
731
731
  const messages = [new HumanMessage('Hello'), new AIMessage('Hi')];
732
732
 
733
733
  const indexTokenCountMap: Record<string, number | undefined> = {
@@ -744,12 +744,12 @@ describe('Token accounting pipeline — Anthropic vs OpenAI cache semantics', ()
744
744
  reserveRatio: 0,
745
745
  });
746
746
 
747
- // Anthropic: input_tokens=30, cache_read=60, cache_creation=20
748
- // cacheSum=80 > baseInput=30 additive totalInput=30+80=110
747
+ // Anthropic output normalization stores cache-inclusive prompt tokens in
748
+ // input_tokens and repeats the cache components in input_token_details.
749
749
  const result = pruneMessages({
750
750
  messages,
751
751
  usageMetadata: {
752
- input_tokens: 30,
752
+ input_tokens: 110,
753
753
  output_tokens: 15,
754
754
  input_token_details: { cache_read: 60, cache_creation: 20 },
755
755
  },
@@ -761,6 +761,132 @@ describe('Token accounting pipeline — Anthropic vs OpenAI cache semantics', ()
761
761
  expect(result.calibrationRatio).toBeCloseTo(1.1, 1);
762
762
  });
763
763
 
764
+ it('skips Bedrock cache-artifact turns instead of poisoning instruction overhead', () => {
765
+ const messages = [
766
+ new SystemMessage('system instructions'),
767
+ new HumanMessage('first user turn'),
768
+ new AIMessage('first response'),
769
+ new HumanMessage('second user turn'),
770
+ new AIMessage('second response'),
771
+ ];
772
+ const indexTokenCountMap: Record<string, number | undefined> = {
773
+ 0: 3197,
774
+ 1: 8000,
775
+ 2: 7000,
776
+ 3: 9506,
777
+ 4: 1,
778
+ };
779
+ const instructionTokens = 5450;
780
+ const logs: Array<{
781
+ level: string;
782
+ message: string;
783
+ data?: Record<string, unknown>;
784
+ }> = [];
785
+
786
+ const pruneMessages = createPruneMessages({
787
+ provider: Providers.BEDROCK,
788
+ maxTokens: 126935,
789
+ startIndex: messages.length,
790
+ tokenCounter: charCounter,
791
+ indexTokenCountMap,
792
+ reserveRatio: 0.05,
793
+ getInstructionTokens: () => instructionTokens,
794
+ log: (level, message, data) => logs.push({ level, message, data }),
795
+ });
796
+
797
+ const result = pruneMessages({
798
+ messages,
799
+ usageMetadata: {
800
+ input_tokens: 6,
801
+ output_tokens: 1,
802
+ input_token_details: {
803
+ cache_creation: 380974,
804
+ cache_read: 13165,
805
+ },
806
+ },
807
+ });
808
+
809
+ expect(result.calibrationRatio).toBe(1);
810
+ expect(result.resolvedInstructionOverhead).toBeUndefined();
811
+ expect(result.remainingContextTokens).toBeGreaterThan(0);
812
+ expect(
813
+ logs.find((entry) => entry.message === 'Calibration skipped')?.data
814
+ ).toMatchObject({
815
+ reason: 'input_below_instruction_overhead',
816
+ providerInputTokens: 6,
817
+ instructionOverhead: instructionTokens,
818
+ });
819
+ expect(
820
+ logs.find((entry) => entry.message === 'Budget')?.data
821
+ ).toMatchObject({
822
+ instructionTokens,
823
+ });
824
+ });
825
+
826
+ it('accepts provider usage above the application budget and prunes', () => {
827
+ // maxTokens is an application budget, not the provider's context window.
828
+ // The provider accepts requests past the budget and reports true usage —
829
+ // exactly the turns where calibration must feed pruning/summarization.
830
+ const messages = [
831
+ new SystemMessage('system instructions'),
832
+ new HumanMessage('first user turn'),
833
+ new AIMessage('first response'),
834
+ new HumanMessage('second user turn'),
835
+ new AIMessage('second response'),
836
+ ];
837
+ const indexTokenCountMap: Record<string, number | undefined> = {
838
+ 0: 40,
839
+ 1: 120,
840
+ 2: 110,
841
+ 3: 120,
842
+ 4: 80,
843
+ };
844
+ const rawTotal = 470;
845
+ const providerInputTokens = 620;
846
+ const logs: Array<{
847
+ level: string;
848
+ message: string;
849
+ data?: Record<string, unknown>;
850
+ }> = [];
851
+
852
+ const pruneMessages = createPruneMessages({
853
+ provider: Providers.ANTHROPIC,
854
+ maxTokens: 500,
855
+ startIndex: messages.length,
856
+ tokenCounter: charCounter,
857
+ indexTokenCountMap,
858
+ summarizationEnabled: true,
859
+ reserveRatio: 0,
860
+ log: (level, message, data) => logs.push({ level, message, data }),
861
+ });
862
+
863
+ const result = pruneMessages({
864
+ messages,
865
+ usageMetadata: {
866
+ input_tokens: providerInputTokens,
867
+ output_tokens: 25,
868
+ },
869
+ });
870
+
871
+ // The above-budget measurement calibrates instead of being discarded.
872
+ expect(
873
+ logs.find((entry) => entry.message === 'Calibration skipped')
874
+ ).toBeUndefined();
875
+ expect(
876
+ logs.find((entry) => entry.message === 'Calibration observed')?.data
877
+ ).toMatchObject({ providerInputTokens });
878
+ expect(result.calibrationRatio).toBeCloseTo(
879
+ providerInputTokens / rawTotal,
880
+ 2
881
+ );
882
+
883
+ // Calibrated context (620) exceeds the budget (500), so messages are
884
+ // yielded for summarization instead of returning the full context.
885
+ expect(result.messagesToRefine).toBeDefined();
886
+ expect(result.messagesToRefine!.length).toBeGreaterThan(0);
887
+ expect(result.context.length).toBeLessThan(messages.length);
888
+ });
889
+
764
890
  it('OpenAI inclusive cache does NOT inflate calibration input', () => {
765
891
  const messages = [new HumanMessage('Hello'), new AIMessage('Hi')];
766
892
 
@@ -3,8 +3,27 @@ import {
3
3
  encodingForModel,
4
4
  createTokenCounter,
5
5
  TokenEncoderManager,
6
+ estimateImageBlockTokens,
7
+ estimateDocumentBlockTokens,
8
+ estimateTimedMediaBlockTokens,
6
9
  } from '@/utils/tokens';
7
10
 
11
+ /** Builds a minimal PNG data URI whose IHDR encodes the given dimensions. */
12
+ function pngDataUri(width: number, height: number): string {
13
+ const buf = Buffer.alloc(48);
14
+ buf[0] = 0x89;
15
+ buf[1] = 0x50;
16
+ buf[2] = 0x4e;
17
+ buf[3] = 0x47;
18
+ buf[4] = 0x0d;
19
+ buf[5] = 0x0a;
20
+ buf[6] = 0x1a;
21
+ buf[7] = 0x0a;
22
+ buf.writeUInt32BE(width, 16);
23
+ buf.writeUInt32BE(height, 20);
24
+ return `data:image/png;base64,${buf.toString('base64')}`;
25
+ }
26
+
8
27
  describe('encodingForModel', () => {
9
28
  test('returns claude for Claude model strings', () => {
10
29
  expect(encodingForModel('claude-3-5-sonnet-20241022')).toBe('claude');
@@ -62,3 +81,198 @@ describe('createTokenCounter with different encodings', () => {
62
81
  expect(o200kCounter(msg)).toBeGreaterThan(0);
63
82
  });
64
83
  });
84
+
85
+ describe('estimateImageBlockTokens', () => {
86
+ test('claude: tokens = ceil(w*h/750), floored at 1024', () => {
87
+ const block = { type: 'image_url', image_url: { url: pngDataUri(1024, 768) } };
88
+ // 1024*768/750 = 1048.58 -> ceil 1049 (> 1024 floor)
89
+ expect(estimateImageBlockTokens(block, 'claude')).toBe(1049);
90
+ });
91
+
92
+ test('openai: tokens = 85 + tiles*170 (512px tiles)', () => {
93
+ const block = { type: 'image_url', image_url: { url: pngDataUri(1024, 768) } };
94
+ // ceil(1024/512)*ceil(768/512) = 2*2 = 4 tiles -> 85 + 680 = 765
95
+ expect(estimateImageBlockTokens(block, 'o200k_base')).toBe(765);
96
+ });
97
+
98
+ test('falls back to the Anthropic minimum (1024) without base64 data', () => {
99
+ expect(
100
+ estimateImageBlockTokens(
101
+ { type: 'image_url', image_url: { url: 'https://example.com/a.png' } },
102
+ 'claude'
103
+ )
104
+ ).toBe(1024);
105
+ });
106
+ });
107
+
108
+ describe('estimateDocumentBlockTokens', () => {
109
+ const countChars = (text: string): number => text.length;
110
+
111
+ test('text document is tokenized directly via getTokenCount', () => {
112
+ const block = { type: 'file', source_type: 'text', text: 'hello world' };
113
+ expect(estimateDocumentBlockTokens(block, 'o200k_base', countChars)).toBe(11);
114
+ });
115
+
116
+ test('base64 PDF is priced per estimated page', () => {
117
+ const block = {
118
+ type: 'file',
119
+ source_type: 'base64',
120
+ mime_type: 'application/pdf',
121
+ data: 'x'.repeat(150_000),
122
+ };
123
+ // ceil(150000/75000) = 2 pages
124
+ expect(estimateDocumentBlockTokens(block, 'claude', countChars)).toBe(4000); // 2 * 2000
125
+ expect(estimateDocumentBlockTokens(block, 'o200k_base', countChars)).toBe(3000); // 2 * 1500
126
+ });
127
+
128
+ test('url-referenced document uses the conservative fallback', () => {
129
+ const block = { type: 'file', source_type: 'url', url: 'https://x/y.pdf' };
130
+ expect(estimateDocumentBlockTokens(block, 'o200k_base', countChars)).toBe(2000);
131
+ });
132
+ });
133
+
134
+ describe('estimateTimedMediaBlockTokens', () => {
135
+ const B64 = (chars: number): string => 'A'.repeat(chars);
136
+
137
+ test('Google video (type=media, video/*): duration from size at ~300 tok/s', () => {
138
+ // 1,000,000 b64 -> 750,000 bytes / 250,000 Bps = 3s * 300 = 900
139
+ expect(
140
+ estimateTimedMediaBlockTokens({ type: 'media', mimeType: 'video/mp4', data: B64(1_000_000) }),
141
+ ).toBe(900);
142
+ });
143
+
144
+ test('Google audio (type=media, audio/*): 32 tok/s at ~16KB/s', () => {
145
+ // 320,000 b64 -> 240,000 bytes / 16,000 Bps = 15s * 32 = 480
146
+ expect(
147
+ estimateTimedMediaBlockTokens({ type: 'media', mimeType: 'audio/mp3', data: B64(320_000) }),
148
+ ).toBe(480);
149
+ });
150
+
151
+ test('OpenRouter input_audio: estimates from base64 data', () => {
152
+ expect(
153
+ estimateTimedMediaBlockTokens({ type: 'input_audio', input_audio: { data: B64(320_000) } }),
154
+ ).toBe(480);
155
+ });
156
+
157
+ test('OpenRouter video_url with a data: URL estimates from size', () => {
158
+ const url = `data:video/mp4;base64,${B64(1_000_000)}`;
159
+ expect(estimateTimedMediaBlockTokens({ type: 'video_url', video_url: { url } })).toBe(900);
160
+ });
161
+
162
+ test('bare remote URL falls back to the flat ~30s estimate', () => {
163
+ expect(
164
+ estimateTimedMediaBlockTokens({ type: 'video_url', video_url: { url: 'https://x/v.mp4' } }),
165
+ ).toBe(9000);
166
+ expect(estimateTimedMediaBlockTokens({ type: 'input_audio', input_audio: {} })).toBe(960);
167
+ });
168
+
169
+ test('clamps to at least one second of tokens for tiny payloads', () => {
170
+ expect(
171
+ estimateTimedMediaBlockTokens({ type: 'media', mimeType: 'audio/wav', data: 'AAAA' }),
172
+ ).toBe(32);
173
+ });
174
+
175
+ test('standard type=video / type=audio blocks (Bedrock converter shape)', () => {
176
+ // 750,000 bytes video / 250,000 = 3s * 300 = 900
177
+ expect(
178
+ estimateTimedMediaBlockTokens({ type: 'video', mimeType: 'video/mp4', data: B64(1_000_000) }),
179
+ ).toBe(900);
180
+ // 240,000 bytes audio / 16,000 = 15s * 32 = 480
181
+ expect(
182
+ estimateTimedMediaBlockTokens({ type: 'audio', mimeType: 'audio/mpeg', data: B64(320_000) }),
183
+ ).toBe(480);
184
+ });
185
+
186
+ test('reads Uint8Array data and base64 url payloads', () => {
187
+ // 240,000-byte Uint8Array audio -> 15s * 32 = 480
188
+ expect(
189
+ estimateTimedMediaBlockTokens({
190
+ type: 'audio',
191
+ mimeType: 'audio/wav',
192
+ data: new Uint8Array(240_000),
193
+ }),
194
+ ).toBe(480);
195
+ // base64 data url on a bare video block
196
+ expect(
197
+ estimateTimedMediaBlockTokens({
198
+ type: 'video',
199
+ mimeType: 'video/mp4',
200
+ url: `data:video/mp4;base64,${B64(1_000_000)}`,
201
+ }),
202
+ ).toBe(900);
203
+ });
204
+
205
+ test('non-video/audio media (image/document MIME) is NOT priced as video', () => {
206
+ expect(
207
+ estimateTimedMediaBlockTokens({ type: 'media', mimeType: 'image/png', fileUri: 's3://x' }),
208
+ ).toBe(0);
209
+ expect(
210
+ estimateTimedMediaBlockTokens({ type: 'media', mimeType: 'image/png', data: B64(400_000) }),
211
+ ).toBe(0);
212
+ expect(
213
+ estimateTimedMediaBlockTokens({ type: 'media', mimeType: 'application/pdf', data: B64(400) }),
214
+ ).toBe(0);
215
+ });
216
+
217
+ test('fileId / bare URL with no size falls back to the ~30s estimate', () => {
218
+ expect(
219
+ estimateTimedMediaBlockTokens({ type: 'video', mimeType: 'video/mp4', fileId: 's3://v' }),
220
+ ).toBe(9000);
221
+ expect(
222
+ estimateTimedMediaBlockTokens({ type: 'audio', mimeType: 'audio/mp3', fileId: 's3://a' }),
223
+ ).toBe(960);
224
+ });
225
+
226
+ test('reads native Bedrock nested source.bytes (video/audio)', () => {
227
+ expect(
228
+ estimateTimedMediaBlockTokens({ type: 'video', video: { source: { bytes: new Uint8Array(750_000) } } }),
229
+ ).toBe(900); // 750,000 / 250,000 = 3s * 300
230
+ expect(
231
+ estimateTimedMediaBlockTokens({ type: 'audio', audio: { source: { bytes: new Uint8Array(240_000) } } }),
232
+ ).toBe(480); // 240,000 / 16,000 = 15s * 32
233
+ });
234
+
235
+ test('reads nested video/audio data-URL and data payloads, remote stays fallback', () => {
236
+ // nested data URL sized by its bytes (750,000 -> 3s * 300)
237
+ expect(
238
+ estimateTimedMediaBlockTokens({
239
+ type: 'video',
240
+ video: { url: `data:video/mp4;base64,${B64(1_000_000)}` },
241
+ }),
242
+ ).toBe(900);
243
+ // nested base64 data (240,000 -> 15s * 32)
244
+ expect(
245
+ estimateTimedMediaBlockTokens({ type: 'audio', audio: { data: B64(320_000) } }),
246
+ ).toBe(480);
247
+ // nested REMOTE url has no size -> ~30s fallback
248
+ expect(
249
+ estimateTimedMediaBlockTokens({ type: 'video', video: { url: 'https://x/v.mp4' } }),
250
+ ).toBe(9000);
251
+ });
252
+
253
+ test('non-data URI schemes (gs://, s3://) are treated as remote, not base64', () => {
254
+ // gs:// audio must hit the ~30s remote fallback, not clamp to 32
255
+ expect(
256
+ estimateTimedMediaBlockTokens({ type: 'audio', mimeType: 'audio/mp3', url: 'gs://bucket/a.mp3' }),
257
+ ).toBe(960);
258
+ expect(
259
+ estimateTimedMediaBlockTokens({ type: 'media', mimeType: 'video/mp4', data: 's3://bucket/v.mp4' }),
260
+ ).toBe(9000);
261
+ });
262
+
263
+ test('classifies Google MIME-as-type blocks (type is the mime string)', () => {
264
+ // { type: 'audio/wav', data } -> 240,000 bytes / 16,000 = 15s * 32 = 480
265
+ expect(
266
+ estimateTimedMediaBlockTokens({ type: 'audio/wav', data: B64(320_000) }),
267
+ ).toBe(480);
268
+ // { type: 'video/mp4', data } -> 750,000 / 250,000 = 3s * 300 = 900
269
+ expect(
270
+ estimateTimedMediaBlockTokens({ type: 'video/mp4', data: B64(1_000_000) }),
271
+ ).toBe(900);
272
+ });
273
+
274
+ test('returns 0 for non-timed-media blocks', () => {
275
+ expect(estimateTimedMediaBlockTokens({ type: 'text' })).toBe(0);
276
+ expect(estimateTimedMediaBlockTokens({ type: 'image/png', data: B64(400) })).toBe(0);
277
+ });
278
+ });
package/src/stream.ts CHANGED
@@ -108,8 +108,27 @@ function getNonEmptyValue(possibleValues: string[]): string | undefined {
108
108
  return undefined;
109
109
  }
110
110
 
111
- function isBatchSensitiveToolExecution(graph: StandardGraph): boolean {
112
- return graph.hookRegistry != null || graph.humanInTheLoop?.enabled === true;
111
+ function isBatchSensitiveToolExecution(
112
+ graph: StandardGraph,
113
+ metadata?: Record<string, unknown>
114
+ ): boolean {
115
+ /**
116
+ * Resolve the hook-session id exactly the way ToolNode will: its hook
117
+ * lookups read `config.configurable.run_id` ONLY, so that source wins here
118
+ * too (a subagent child graph carries its own `graph.runId`, but its
119
+ * ToolNode executes hooks under the PARENT's inherited configurable run
120
+ * id). The metadata / graph.runId fallbacks only apply when no
121
+ * configurable id exists and can only be conservative — a false positive
122
+ * merely skips eager prestart.
123
+ */
124
+ const runId =
125
+ (graph.config?.configurable?.run_id as string | undefined) ??
126
+ (metadata?.run_id as string | undefined) ??
127
+ graph.runId;
128
+ return (
129
+ graph.hookRegistry?.hasResultAlteringHooks(runId) === true ||
130
+ graph.humanInTheLoop?.enabled === true
131
+ );
113
132
  }
114
133
 
115
134
  function hasToolOutputReference(value: unknown): boolean {
@@ -246,7 +265,7 @@ function isEagerToolExecutionEnabledForBatch(args: {
246
265
  if ((agentContext?.toolDefinitions?.length ?? 0) === 0) {
247
266
  return false;
248
267
  }
249
- if (isBatchSensitiveToolExecution(graph)) {
268
+ if (isBatchSensitiveToolExecution(graph, metadata)) {
250
269
  return false;
251
270
  }
252
271
  if (