@librechat/agents 3.2.61 → 3.2.62
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.
- package/dist/cjs/common/enum.cjs +2 -0
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +32 -0
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/executeHooks.cjs +6 -0
- package/dist/cjs/hooks/executeHooks.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +12 -0
- package/dist/cjs/hooks/index.cjs.map +1 -0
- package/dist/cjs/hooks/types.cjs.map +1 -1
- package/dist/cjs/langfuse.cjs +62 -1
- package/dist/cjs/langfuse.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/index.cjs +266 -43
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/index.cjs +274 -7
- package/dist/cjs/llm/openai/index.cjs.map +1 -1
- package/dist/cjs/llm/openai/streamMetadata.cjs +69 -0
- package/dist/cjs/llm/openai/streamMetadata.cjs.map +1 -0
- package/dist/cjs/llm/openrouter/index.cjs +5 -6
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/cjs/main.cjs +6 -1
- package/dist/cjs/messages/format.cjs +61 -0
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/messages/prune.cjs +31 -19
- package/dist/cjs/messages/prune.cjs.map +1 -1
- package/dist/cjs/stream.cjs +13 -3
- package/dist/cjs/stream.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +75 -10
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/utils/tokens.cjs +111 -0
- package/dist/cjs/utils/tokens.cjs.map +1 -1
- package/dist/esm/common/enum.mjs +2 -0
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +32 -0
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/executeHooks.mjs +6 -0
- package/dist/esm/hooks/executeHooks.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +12 -1
- package/dist/esm/hooks/index.mjs.map +1 -0
- package/dist/esm/hooks/types.mjs.map +1 -1
- package/dist/esm/langfuse.mjs +62 -1
- package/dist/esm/langfuse.mjs.map +1 -1
- package/dist/esm/llm/bedrock/index.mjs +266 -43
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/openai/index.mjs +274 -7
- package/dist/esm/llm/openai/index.mjs.map +1 -1
- package/dist/esm/llm/openai/streamMetadata.mjs +69 -0
- package/dist/esm/llm/openai/streamMetadata.mjs.map +1 -0
- package/dist/esm/llm/openrouter/index.mjs +5 -6
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/esm/main.mjs +3 -3
- package/dist/esm/messages/format.mjs +61 -0
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/messages/prune.mjs +31 -19
- package/dist/esm/messages/prune.mjs.map +1 -1
- package/dist/esm/stream.mjs +13 -3
- package/dist/esm/stream.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +75 -10
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/utils/tokens.mjs +108 -1
- package/dist/esm/utils/tokens.mjs.map +1 -1
- package/dist/types/common/enum.d.ts +3 -1
- package/dist/types/hooks/HookRegistry.d.ts +10 -0
- package/dist/types/hooks/index.d.ts +7 -0
- package/dist/types/hooks/types.d.ts +18 -1
- package/dist/types/llm/bedrock/index.d.ts +5 -0
- package/dist/types/llm/openai/index.d.ts +19 -0
- package/dist/types/llm/openai/streamMetadata.d.ts +16 -0
- package/dist/types/messages/prune.d.ts +4 -3
- package/dist/types/tools/ToolNode.d.ts +1 -0
- package/dist/types/types/llm.d.ts +12 -2
- package/dist/types/types/tools.d.ts +1 -1
- package/dist/types/utils/tokens.d.ts +30 -0
- package/package.json +6 -6
- package/src/__tests__/stream.eagerEventExecution.test.ts +100 -2
- package/src/common/enum.ts +2 -0
- package/src/hooks/HookRegistry.ts +45 -0
- package/src/hooks/__tests__/HookRegistry.test.ts +48 -0
- package/src/hooks/__tests__/executeHooks.test.ts +85 -2
- package/src/hooks/executeHooks.ts +15 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/types.ts +18 -1
- package/src/langfuse.ts +134 -1
- package/src/llm/bedrock/index.ts +434 -83
- package/src/llm/bedrock/streamSealDispatch.test.ts +97 -0
- package/src/llm/custom-chat-models.smoke.test.ts +7 -0
- package/src/llm/openai/index.ts +604 -6
- package/src/llm/openai/managedRequests.test.ts +182 -0
- package/src/llm/openai/streamMetadata.spec.ts +86 -0
- package/src/llm/openai/streamMetadata.ts +95 -0
- package/src/llm/openai/streamMetadataDedup.spec.ts +166 -0
- package/src/llm/openrouter/index.ts +9 -5
- package/src/messages/format.ts +96 -3
- package/src/messages/formatAgentMessages.steer.test.ts +326 -0
- package/src/messages/labelContentByAgent.test.ts +75 -0
- package/src/messages/prune.ts +56 -30
- package/src/specs/anthropic.simple.test.ts +4 -2
- package/src/specs/cache.simple.test.ts +17 -4
- package/src/specs/langfuse-callbacks.test.ts +61 -0
- package/src/specs/openai.simple.test.ts +4 -2
- package/src/specs/spec.utils.ts +12 -0
- package/src/specs/summarization.test.ts +9 -13
- package/src/specs/token-accounting-pipeline.test.ts +130 -4
- package/src/specs/tokens.test.ts +214 -0
- package/src/stream.ts +22 -3
- package/src/tools/ToolNode.ts +107 -12
- package/src/tools/__tests__/ToolNode.eagerEventExecution.test.ts +554 -0
- package/src/tools/__tests__/ToolNode.onResultCompletion.test.ts +49 -2
- package/src/tools/__tests__/hitl.test.ts +112 -0
- package/src/tools/subagent/SubagentExecutor.ts +2 -0
- package/src/types/llm.ts +12 -2
- package/src/types/tools.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[];
|
package/src/specs/spec.utils.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
-
|
|
2430
|
-
|
|
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
|
|
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
|
|
748
|
-
//
|
|
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:
|
|
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
|
|
package/src/specs/tokens.test.ts
CHANGED
|
@@ -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(
|
|
112
|
-
|
|
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 (
|