@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
|
@@ -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', () => {
|
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/src/types/tools.ts
CHANGED
|
@@ -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
|
};
|
package/src/utils/tokens.ts
CHANGED
|
@@ -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) {
|