@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
@@ -1566,6 +1566,118 @@ describe('ToolNode HITL — PostToolBatch hook', () => {
1566
1566
  expect(injected).toBeDefined();
1567
1567
  expect(String(injected!.content)).toContain('format the response as JSON');
1568
1568
  });
1569
+
1570
+ it('PostToolBatch injectedMessages land as individual HumanMessages after the consolidated context', async () => {
1571
+ mockEventDispatch([
1572
+ { toolCallId: 'call_1', content: 'ok', status: 'success' },
1573
+ ]);
1574
+
1575
+ const registry = new HookRegistry();
1576
+ registry.register('PostToolBatch', {
1577
+ hooks: [
1578
+ async (): Promise<PostToolBatchHookOutput> => ({
1579
+ additionalContext: 'batch convention',
1580
+ injectedMessages: [
1581
+ { role: 'user', content: 'steer one', source: 'steer' },
1582
+ { role: 'user', content: 'steer two', source: 'steer' },
1583
+ ],
1584
+ }),
1585
+ ],
1586
+ });
1587
+
1588
+ const node = new ToolNode({
1589
+ tools: [createSchemaStub('echo')],
1590
+ eventDrivenMode: true,
1591
+ agentId: 'agent-x',
1592
+ toolCallStepIds: new Map([['call_1', 'step_1']]),
1593
+ hookRegistry: registry,
1594
+ humanInTheLoop: { enabled: false },
1595
+ });
1596
+
1597
+ const graph = buildHITLGraph(node, [
1598
+ { id: 'call_1', name: 'echo', args: { command: 'a' } },
1599
+ ]);
1600
+ const result = (await graph.invoke(
1601
+ { messages: [] },
1602
+ { configurable: { thread_id: 'batch-steer-thread' } }
1603
+ )) as { messages: BaseMessage[] };
1604
+
1605
+ type KwargMessage = {
1606
+ additional_kwargs?: { source?: string; role?: string };
1607
+ };
1608
+ const humanMessages = result.messages.filter(
1609
+ (m) => m._getType() === 'human'
1610
+ );
1611
+ const contextIndex = humanMessages.findIndex(
1612
+ (m) => (m as KwargMessage).additional_kwargs?.source === 'hook'
1613
+ );
1614
+ const steerMessages = humanMessages.filter(
1615
+ (m) => (m as KwargMessage).additional_kwargs?.source === 'steer'
1616
+ );
1617
+
1618
+ expect(contextIndex).toBeGreaterThanOrEqual(0);
1619
+ expect(steerMessages).toHaveLength(2);
1620
+ expect(String(steerMessages[0].content)).toBe('steer one');
1621
+ expect(String(steerMessages[1].content)).toBe('steer two');
1622
+ for (const steer of steerMessages) {
1623
+ expect((steer as KwargMessage).additional_kwargs?.role).toBe('user');
1624
+ expect(humanMessages.indexOf(steer)).toBeGreaterThan(contextIndex);
1625
+ }
1626
+ const toolIndex = result.messages.findIndex((m) => m._getType() === 'tool');
1627
+ const firstSteerIndex = result.messages.indexOf(steerMessages[0]);
1628
+ expect(firstSteerIndex).toBeGreaterThan(toolIndex);
1629
+ });
1630
+
1631
+ it('PostToolBatch injectedMessages work without additionalContext', async () => {
1632
+ mockEventDispatch([
1633
+ { toolCallId: 'call_1', content: 'ok', status: 'success' },
1634
+ ]);
1635
+
1636
+ const registry = new HookRegistry();
1637
+ registry.register('PostToolBatch', {
1638
+ hooks: [
1639
+ async (): Promise<PostToolBatchHookOutput> => ({
1640
+ injectedMessages: [
1641
+ { role: 'user', content: 'solo steer', source: 'steer' },
1642
+ ],
1643
+ }),
1644
+ ],
1645
+ });
1646
+
1647
+ const node = new ToolNode({
1648
+ tools: [createSchemaStub('echo')],
1649
+ eventDrivenMode: true,
1650
+ agentId: 'agent-x',
1651
+ toolCallStepIds: new Map([['call_1', 'step_1']]),
1652
+ hookRegistry: registry,
1653
+ humanInTheLoop: { enabled: false },
1654
+ });
1655
+
1656
+ const graph = buildHITLGraph(node, [
1657
+ { id: 'call_1', name: 'echo', args: { command: 'a' } },
1658
+ ]);
1659
+ const result = (await graph.invoke(
1660
+ { messages: [] },
1661
+ { configurable: { thread_id: 'solo-steer-thread' } }
1662
+ )) as { messages: BaseMessage[] };
1663
+
1664
+ type KwargMessage = {
1665
+ additional_kwargs?: { source?: string; role?: string };
1666
+ };
1667
+ const consolidated = result.messages.find(
1668
+ (m) =>
1669
+ m._getType() === 'human' &&
1670
+ (m as KwargMessage).additional_kwargs?.source === 'hook'
1671
+ );
1672
+ const steer = result.messages.find(
1673
+ (m) =>
1674
+ m._getType() === 'human' &&
1675
+ (m as KwargMessage).additional_kwargs?.source === 'steer'
1676
+ );
1677
+ expect(consolidated).toBeUndefined();
1678
+ expect(steer).toBeDefined();
1679
+ expect(String(steer!.content)).toBe('solo steer');
1680
+ });
1569
1681
  });
1570
1682
 
1571
1683
  describe('ToolNode HITL — per-hook allowedDecisions override', () => {
@@ -4,6 +4,7 @@ import type { ToolCall } from '@langchain/core/messages/tool';
4
4
  import type {
5
5
  HookCallback,
6
6
  PermissionDeniedHookOutput,
7
+ PostToolBatchHookOutput,
7
8
  PostToolUseHookOutput,
8
9
  PreToolUseHookOutput,
9
10
  SubagentStartHookInput,
@@ -365,6 +366,129 @@ describe('Subagent hook integration (end-to-end via Run)', () => {
365
366
  expect(postExecEvents).toContain('researcher-child:calculator');
366
367
  });
367
368
 
369
+ it('top-level event-driven dispatches leave agentId unset (subagent-scope marker)', async () => {
370
+ getChatModelClassSpy.mockImplementation(((provider: Providers) => {
371
+ if (provider === Providers.OPENAI) {
372
+ return class extends FakeChatModel {
373
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
374
+ constructor(_options: any) {
375
+ super({
376
+ responses: ['Calculating.', 'All done.'],
377
+ sleep: 1,
378
+ toolCalls: [createCalculatorToolCall()],
379
+ });
380
+ }
381
+ bindTools(tools: unknown): ReturnType<FakeChatModel['withConfig']> {
382
+ const config = {
383
+ tools,
384
+ } as Parameters<FakeChatModel['withConfig']>[0];
385
+ return this.withConfig(config);
386
+ }
387
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
388
+ } as any;
389
+ }
390
+ return originalGetChatModelClass(provider);
391
+ }) as typeof providers.getChatModelClass);
392
+
393
+ const registry = new HookRegistry();
394
+ const scopeEvents: string[] = [];
395
+
396
+ const preHook: HookCallback<'PreToolUse'> = async (
397
+ input
398
+ ): Promise<PreToolUseHookOutput> => {
399
+ scopeEvents.push(
400
+ `pre:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
401
+ );
402
+ return { decision: 'allow' };
403
+ };
404
+ registry.register('PreToolUse', { hooks: [preHook] });
405
+
406
+ const postHook: HookCallback<'PostToolUse'> = async (
407
+ input
408
+ ): Promise<PostToolUseHookOutput> => {
409
+ scopeEvents.push(
410
+ `post:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
411
+ );
412
+ return {};
413
+ };
414
+ registry.register('PostToolUse', { hooks: [postHook] });
415
+
416
+ const batchHook: HookCallback<'PostToolBatch'> = async (
417
+ input
418
+ ): Promise<PostToolBatchHookOutput> => {
419
+ scopeEvents.push(
420
+ `batch:${input.agentId ?? '-'}:${input.executingAgentId ?? '-'}`
421
+ );
422
+ return {};
423
+ };
424
+ registry.register('PostToolBatch', { hooks: [batchHook] });
425
+
426
+ const dispatchAgentIds: Array<string | undefined> = [];
427
+ const customHandlers: Record<string, t.EventHandler> = {
428
+ [GraphEvents.TOOL_END]: new ToolEndHandler(),
429
+ [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(),
430
+ [GraphEvents.ON_TOOL_EXECUTE]: {
431
+ handle: (_event, rawData): void => {
432
+ const request = rawData as t.ToolExecuteBatchRequest;
433
+ dispatchAgentIds.push(request.agentId);
434
+ const results: t.ToolExecuteResult[] = request.toolCalls.map(
435
+ (call) => ({
436
+ toolCallId: call.id,
437
+ status: 'success',
438
+ content: '42',
439
+ })
440
+ );
441
+ request.resolve(results);
442
+ },
443
+ },
444
+ };
445
+
446
+ /**
447
+ * The LibreChat shape: a TOP-LEVEL agent whose tools ride
448
+ * `toolDefinitions` (event-driven ToolNode, host executes via
449
+ * ON_TOOL_EXECUTE). Hook inputs here must NOT carry the subagent-scope
450
+ * marker — a host hook keying on `agentId != null` (e.g. a steering
451
+ * drain that must never inject into child state) would otherwise skip
452
+ * every top-level batch. The DISPATCH payload is the opposite: hosts
453
+ * key tool/credential lookup on `request.agentId`, so it must keep
454
+ * identifying the owning agent.
455
+ */
456
+ const run = await Run.create<t.IState>({
457
+ runId: `toplevel-event-hook-${Date.now()}`,
458
+ graphConfig: {
459
+ type: 'standard',
460
+ agents: [
461
+ {
462
+ agentId: 'hook-parent',
463
+ provider: Providers.OPENAI,
464
+ clientOptions: { modelName: 'gpt-4o-mini', apiKey: 'test-key' },
465
+ instructions: 'Use the calculator.',
466
+ maxContextTokens: 8000,
467
+ toolDefinitions: [calculatorDef],
468
+ },
469
+ ],
470
+ },
471
+ returnContent: true,
472
+ skipCleanup: true,
473
+ customHandlers,
474
+ hooks: registry,
475
+ });
476
+
477
+ run.Graph!.overrideTestModel(['Calculating.', 'All done.'], 5, [
478
+ createCalculatorToolCall(),
479
+ ]);
480
+
481
+ await run.processStream(
482
+ { messages: [new HumanMessage('what is 21 * 2?')] },
483
+ callerConfig
484
+ );
485
+
486
+ expect(scopeEvents).toContain('pre:-:hook-parent');
487
+ expect(scopeEvents).toContain('post:-:hook-parent');
488
+ expect(scopeEvents).toContain('batch:-:hook-parent');
489
+ expect(dispatchAgentIds).toEqual(['hook-parent']);
490
+ });
491
+
368
492
  it('child subagent tool ask hooks fail closed instead of starting unsupported nested HITL', async () => {
369
493
  getChatModelClassSpy.mockImplementation(((provider: Providers) => {
370
494
  if (provider === Providers.OPENAI) {
@@ -6,6 +6,7 @@ import type { ChatGeneration, LLMResult } from '@langchain/core/outputs';
6
6
  import type { Callbacks } from '@langchain/core/callbacks/manager';
7
7
  import type {
8
8
  AgentInputs,
9
+ InjectedMessage,
9
10
  MessageDeltaEvent,
10
11
  ProcessedToolCall,
11
12
  ReasoningDeltaEvent,
@@ -36,6 +37,7 @@ const MAX_PENDING_SUBAGENT_UPDATES = 64;
36
37
 
37
38
  const HOOK_FALLBACK: AggregatedHookResult = Object.freeze({
38
39
  additionalContexts: [] as string[],
40
+ injectedMessages: [] as InjectedMessage[],
39
41
  errors: [] as string[],
40
42
  });
41
43
 
@@ -371,6 +373,7 @@ export class SubagentExecutor {
371
373
  agents: [childInputs],
372
374
  langfuse: this.langfuse,
373
375
  tokenCounter: this.tokenCounter,
376
+ subagentScope: true,
374
377
  /**
375
378
  * Forwarded so the child graph's own `SubagentExecutor` (created in
376
379
  * its `createAgentNode` when `allowNested` keeps subagentConfigs)
@@ -336,6 +336,16 @@ export type StandardGraphInput = {
336
336
  * they already flow through the registry's `CHAT_MODEL_END` handler.
337
337
  */
338
338
  subagentUsageSink?: SubagentUsageSink;
339
+ /**
340
+ * True when this graph IS a subagent child run (set by `SubagentExecutor`
341
+ * when it constructs the child graph). Drives the hook-input `agentId`
342
+ * subagent-scope marker: hook dispatches from this graph's tool nodes
343
+ * carry `agentId` so run-scoped host hooks — which fire for child scopes
344
+ * too, because children inherit the parent's `run_id` — can tell child
345
+ * scope from the top level. Top-level graphs leave this unset and their
346
+ * hook inputs carry only `executingAgentId`.
347
+ */
348
+ subagentScope?: boolean;
339
349
  };
340
350
 
341
351
  export type GraphEdge = {
package/src/types/llm.ts CHANGED
@@ -45,7 +45,7 @@ export type AzureClientOptions = Partial<OpenAIChatInput> &
45
45
  deploymentName?: string;
46
46
  } & BaseChatModelParams & {
47
47
  configuration?: OAIClientOptions;
48
- };
48
+ } & ManagedRequestOptions;
49
49
  /**
50
50
  * Controls whether Claude's reasoning content is returned in adaptive
51
51
  * thinking responses. Added for Claude Opus 4.7, which omits thinking by
@@ -73,7 +73,13 @@ export type GoogleThinkingConfig = {
73
73
  includeThoughts?: boolean;
74
74
  thinkingLevel?: 'THINKING_LEVEL_UNSPECIFIED' | 'LOW' | 'MEDIUM' | 'HIGH';
75
75
  };
76
- export type OpenAIClientOptions = ChatOpenAIFields;
76
+ /** GPT-5.6 managed-request passthrough fields, shared by the OpenAI and
77
+ * Azure wrappers that both read them. */
78
+ export type ManagedRequestOptions = {
79
+ promptCacheExplicit?: boolean;
80
+ safety_identifier?: string;
81
+ };
82
+ export type OpenAIClientOptions = ChatOpenAIFields & ManagedRequestOptions;
77
83
  export type AnthropicClientOptions = Omit<AnthropicInput, 'thinking'> & {
78
84
  thinking?: ThinkingConfig;
79
85
  promptCache?: boolean;
@@ -100,6 +106,10 @@ export type BedrockAnthropicInput = ChatBedrockConverseInput & {
100
106
  * server-side, so the default is safe to leave on.
101
107
  */
102
108
  promptCacheTtl?: PromptCacheTtl;
109
+ /**
110
+ * Minimum delay in milliseconds between visible streamed content deltas.
111
+ */
112
+ _lc_stream_delay?: number;
103
113
  };
104
114
  export type BedrockConverseClientOptions = BedrockAnthropicInput;
105
115
  export type BedrockAnthropicClientOptions = BedrockAnthropicInput;
@@ -516,7 +516,7 @@ export type InjectedMessage = {
516
516
  /** When true, the message is framework-internal: not shown in UI, not counted as a user turn */
517
517
  isMeta?: boolean;
518
518
  /** Origin tag for downstream consumers (UI, pruner, compaction) */
519
- source?: 'skill' | 'hook' | 'system';
519
+ source?: 'skill' | 'hook' | 'system' | 'steer';
520
520
  /** Only set when source is 'skill', for compaction preservation */
521
521
  skillName?: string;
522
522
  };
@@ -17,7 +17,7 @@ const OPENAI_IMAGE_TOKENS_PER_TILE = 170;
17
17
  /** Google Gemini fixed per-image cost. */
18
18
  const _GEMINI_IMAGE_TOKENS = 258;
19
19
  /** Safety margin for image and document token estimates (5% overestimate). */
20
- const IMAGE_TOKEN_SAFETY_MARGIN = 1.05;
20
+ export const IMAGE_TOKEN_SAFETY_MARGIN = 1.05;
21
21
 
22
22
  /**
23
23
  * Anthropic PDF: each page costs image tokens + text tokens.
@@ -33,6 +33,33 @@ const BASE64_BYTES_PER_PDF_PAGE = 75_000;
33
33
  /** Fallback token cost for URL-referenced documents without local data. */
34
34
  const URL_DOCUMENT_FALLBACK_TOKENS = 2000;
35
35
 
36
+ /**
37
+ * Timed media (video/audio) is priced by DURATION, not size, and the content
38
+ * carries no duration — so duration is estimated from encoded size at
39
+ * representative bitrates and priced at Gemini's rates (the dominant provider
40
+ * for native video/audio). Deliberately rough; superseded by real provider
41
+ * usage after the first turn.
42
+ */
43
+ /** Gemini video ≈ 258 tokens/frame (1 fps) + 32 tokens/s audio ≈ 300 tokens/s. */
44
+ const VIDEO_TOKENS_PER_SECOND = 300;
45
+ /** Gemini audio: 32 tokens/s (single channel). */
46
+ const AUDIO_TOKENS_PER_SECOND = 32;
47
+ /** Representative encoded byte rates for duration-from-size estimation. */
48
+ const VIDEO_BYTES_PER_SECOND = 250_000; // ~2 Mbps
49
+ const AUDIO_BYTES_PER_SECOND = 16_000; // ~128 kbps
50
+ /** Flat fallback when only a URL is present (no size to estimate from) — ~30s. */
51
+ const VIDEO_URL_FALLBACK_TOKENS = 9000;
52
+ const AUDIO_URL_FALLBACK_TOKENS = 960;
53
+ /** Content block types that can carry timed media (video/audio). `media` is
54
+ * generic (Google) so it is classified by MIME; the rest are unambiguous. */
55
+ const TIMED_MEDIA_TYPES = new Set([
56
+ 'media',
57
+ 'video',
58
+ 'audio',
59
+ 'video_url',
60
+ 'input_audio',
61
+ ]);
62
+
36
63
  /**
37
64
  * Extracts image dimensions from the first bytes of a base64-encoded
38
65
  * PNG, JPEG, GIF, or WebP without decoding the full image.
@@ -135,7 +162,7 @@ export function estimateOpenAIImageTokens(
135
162
  * Extracts dimensions from base64 header when available.
136
163
  * Falls back to Anthropic minimum (1024) when dimensions can't be determined.
137
164
  */
138
- function estimateImageBlockTokens(
165
+ export function estimateImageBlockTokens(
139
166
  block: Record<string, unknown>,
140
167
  encoding: EncodingName
141
168
  ): number {
@@ -180,7 +207,7 @@ function estimateImageBlockTokens(
180
207
  * - Base64 PDF: page count estimated from base64 length × per-page cost.
181
208
  * - URL reference: conservative flat estimate.
182
209
  */
183
- function estimateDocumentBlockTokens(
210
+ export function estimateDocumentBlockTokens(
184
211
  block: Record<string, unknown>,
185
212
  encoding: EncodingName,
186
213
  getTokenCount: (text: string) => number
@@ -287,6 +314,150 @@ function estimateDocumentBlockTokens(
287
314
  return URL_DOCUMENT_FALLBACK_TOKENS;
288
315
  }
289
316
 
317
+ /** Decoded byte length of base64 or a `data:` URL; 0 for any remote URI (http,
318
+ * gs, s3, file, …) or empty. Base64 never contains `:`, so any scheme prefix
319
+ * marks a remote reference with no local size. */
320
+ function base64ByteLength(value: string | undefined): number {
321
+ if (typeof value !== 'string' || value.length === 0) {
322
+ return 0;
323
+ }
324
+ if (value.startsWith('data:')) {
325
+ const comma = value.indexOf(',');
326
+ return comma < 0 ? 0 : Math.floor(((value.length - comma - 1) * 3) / 4);
327
+ }
328
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) {
329
+ return 0;
330
+ }
331
+ return Math.floor((value.length * 3) / 4);
332
+ }
333
+
334
+ function timedMediaTokens(
335
+ bytes: number,
336
+ bytesPerSecond: number,
337
+ tokensPerSecond: number,
338
+ urlFallback: number
339
+ ): number {
340
+ if (bytes <= 0) {
341
+ return urlFallback;
342
+ }
343
+ return Math.max(
344
+ tokensPerSecond,
345
+ Math.ceil((bytes / bytesPerSecond) * tokensPerSecond)
346
+ );
347
+ }
348
+
349
+ /** Decoded byte length of a media block payload — top-level `data` (base64
350
+ * string or `Uint8Array`) or base64 `url`, or the nested `video|audio.{data,
351
+ * url, source.bytes}` shapes (`formatMessage` media arrays / native Bedrock);
352
+ * 0 for a bare remote URL, `fileId`/`fileUri`, S3 location, or empty. */
353
+ function mediaBlockByteLength(block: Record<string, unknown>): number {
354
+ const data = block.data;
355
+ if (typeof data === 'string') {
356
+ return base64ByteLength(data);
357
+ }
358
+ if (data instanceof Uint8Array) {
359
+ return data.length;
360
+ }
361
+ if (typeof block.url === 'string') {
362
+ return base64ByteLength(block.url);
363
+ }
364
+ const nested = (block.video ?? block.audio) as
365
+ | { data?: unknown; url?: unknown; source?: { bytes?: unknown } }
366
+ | undefined;
367
+ if (nested != null) {
368
+ if (typeof nested.data === 'string') {
369
+ return base64ByteLength(nested.data);
370
+ }
371
+ if (typeof nested.url === 'string') {
372
+ return base64ByteLength(nested.url);
373
+ }
374
+ if (nested.source?.bytes instanceof Uint8Array) {
375
+ return nested.source.bytes.length;
376
+ }
377
+ }
378
+ return 0;
379
+ }
380
+
381
+ /** Classifies a block as timed media, or null when it is not video/audio — e.g.
382
+ * a generic Google `media` block carrying an image/document MIME, which must
383
+ * NOT be priced as video. Also handles the Google shape where `type` IS the
384
+ * MIME string (e.g. `{ type: 'audio/wav', data }`). */
385
+ function timedMediaKind(
386
+ block: Record<string, unknown>
387
+ ): 'video' | 'audio' | null {
388
+ const type = typeof block.type === 'string' ? block.type : '';
389
+ if (type === 'input_audio' || type === 'audio') {
390
+ return 'audio';
391
+ }
392
+ if (type === 'video_url' || type === 'video') {
393
+ return 'video';
394
+ }
395
+ const mime =
396
+ type === 'media' && typeof block.mimeType === 'string' ? block.mimeType : type;
397
+ if (mime.startsWith('audio/')) {
398
+ return 'audio';
399
+ }
400
+ if (mime.startsWith('video/')) {
401
+ return 'video';
402
+ }
403
+ return null;
404
+ }
405
+
406
+ /** Whether a content-block `type` can carry timed media — the fixed set plus the
407
+ * Google MIME-as-type shape (`audio/*` / `video/*`). Gates callers before they
408
+ * hand the block to {@link estimateTimedMediaBlockTokens}. */
409
+ function isTimedMediaType(type: string): boolean {
410
+ return (
411
+ TIMED_MEDIA_TYPES.has(type) ||
412
+ type.startsWith('audio/') ||
413
+ type.startsWith('video/')
414
+ );
415
+ }
416
+
417
+ /**
418
+ * Estimates token cost for a timed-media block (video/audio). Handles Google
419
+ * `{ type: 'media', mimeType, data|url|fileUri }`, OpenRouter
420
+ * `{ type: 'video_url' }` / `{ type: 'input_audio' }`, and standard
421
+ * `{ type: 'video' }` / `{ type: 'audio' }` blocks (payload as `data` base64 or
422
+ * `Uint8Array`, base64 `url`, or `fileId`). Duration is inferred from encoded
423
+ * size (providers price by duration, which the block does not carry) at Gemini's
424
+ * rates; a payload with no size (bare URL / file id) falls back to a ~30s
425
+ * estimate. Returns 0 for non-timed media (e.g. an image/document `media` block)
426
+ * so it is never mispriced as video.
427
+ */
428
+ export function estimateTimedMediaBlockTokens(
429
+ block: Record<string, unknown>
430
+ ): number {
431
+ const kind = timedMediaKind(block);
432
+ if (kind == null) {
433
+ return 0;
434
+ }
435
+ let bytes: number;
436
+ if (block.type === 'input_audio') {
437
+ const audio = block.input_audio as { data?: string } | undefined;
438
+ bytes = base64ByteLength(audio?.data);
439
+ } else if (block.type === 'video_url') {
440
+ const video = block.video_url as string | { url?: string } | undefined;
441
+ bytes = base64ByteLength(typeof video === 'string' ? video : video?.url);
442
+ } else {
443
+ bytes = mediaBlockByteLength(block);
444
+ }
445
+ if (kind === 'audio') {
446
+ return timedMediaTokens(
447
+ bytes,
448
+ AUDIO_BYTES_PER_SECOND,
449
+ AUDIO_TOKENS_PER_SECOND,
450
+ AUDIO_URL_FALLBACK_TOKENS
451
+ );
452
+ }
453
+ return timedMediaTokens(
454
+ bytes,
455
+ VIDEO_BYTES_PER_SECOND,
456
+ VIDEO_TOKENS_PER_SECOND,
457
+ VIDEO_URL_FALLBACK_TOKENS
458
+ );
459
+ }
460
+
290
461
  const tokenizers: Partial<Record<EncodingName, Tokenizer>> = {};
291
462
 
292
463
  async function getTokenizer(
@@ -358,6 +529,13 @@ export function getTokenCountForMessage(
358
529
  continue;
359
530
  }
360
531
 
532
+ if (isTimedMediaType(item.type)) {
533
+ numTokens += Math.ceil(
534
+ estimateTimedMediaBlockTokens(item) * IMAGE_TOKEN_SAFETY_MARGIN
535
+ );
536
+ continue;
537
+ }
538
+
361
539
  if (item.type === ContentTypes.TOOL_CALL && item.tool_call != null) {
362
540
  const toolName = item.tool_call.name;
363
541
  if (typeof toolName === 'string' && toolName.length > 0) {