@librechat/agents 3.1.80-dev.3 → 3.1.81

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.
@@ -3,6 +3,7 @@
3
3
  /**
4
4
  * This util file contains functions for converting LangChain messages to Anthropic messages.
5
5
  */
6
+ import { createHash } from 'node:crypto';
6
7
  import {
7
8
  type BaseMessage,
8
9
  type SystemMessage,
@@ -92,6 +93,49 @@ function _formatImage(imageUrl: string) {
92
93
  );
93
94
  }
94
95
 
96
+ const ANTHROPIC_TOOL_USE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
97
+ const ANTHROPIC_TOOL_USE_ID_MAX_LENGTH = 64;
98
+ const ANTHROPIC_TOOL_USE_ID_HASH_LENGTH = 10;
99
+
100
+ /**
101
+ * Normalize a tool-call ID to satisfy Anthropic's `^[a-zA-Z0-9_-]+$` and 64-char
102
+ * constraints. Pure and deterministic — same input always yields the same output,
103
+ * so paired `tool_use.id` and `tool_result.tool_use_id` stay matched without
104
+ * needing a session map. IDs that already comply pass through unchanged.
105
+ *
106
+ * For non-compliant inputs we sanitize then append a short SHA-256 prefix of
107
+ * the original ID to preserve uniqueness when truncation would otherwise
108
+ * collapse distinct IDs to the same value (e.g. two long Responses-style IDs
109
+ * sharing a 64-char prefix). The hash is computed against the raw input so
110
+ * inputs that differ only after the truncation cutoff still produce distinct
111
+ * outputs.
112
+ */
113
+ export function normalizeAnthropicToolCallId(id: string): string;
114
+ export function normalizeAnthropicToolCallId(
115
+ id: string | undefined
116
+ ): string | undefined;
117
+ export function normalizeAnthropicToolCallId(
118
+ id: string | undefined
119
+ ): string | undefined {
120
+ if (id == null) {
121
+ return id;
122
+ }
123
+ if (
124
+ id.length <= ANTHROPIC_TOOL_USE_ID_MAX_LENGTH &&
125
+ ANTHROPIC_TOOL_USE_ID_PATTERN.test(id)
126
+ ) {
127
+ return id;
128
+ }
129
+ const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, '_');
130
+ const hash = createHash('sha256')
131
+ .update(id)
132
+ .digest('hex')
133
+ .slice(0, ANTHROPIC_TOOL_USE_ID_HASH_LENGTH);
134
+ const prefixMaxLength =
135
+ ANTHROPIC_TOOL_USE_ID_MAX_LENGTH - ANTHROPIC_TOOL_USE_ID_HASH_LENGTH - 1;
136
+ return `${sanitized.slice(0, prefixMaxLength)}_${hash}`;
137
+ }
138
+
95
139
  function _ensureMessageContents(
96
140
  messages: BaseMessage[]
97
141
  ): (SystemMessage | HumanMessage | AIMessage)[] {
@@ -111,7 +155,9 @@ function _ensureMessageContents(
111
155
  (previousMessage.content as MessageContentComplex[]).push({
112
156
  type: 'tool_result',
113
157
  content: message.content,
114
- tool_use_id: (message as ToolMessage).tool_call_id,
158
+ tool_use_id: normalizeAnthropicToolCallId(
159
+ (message as ToolMessage).tool_call_id
160
+ ),
115
161
  });
116
162
  } else {
117
163
  // If not, we create a new human message with the tool result.
@@ -121,7 +167,9 @@ function _ensureMessageContents(
121
167
  {
122
168
  type: 'tool_result',
123
169
  content: message.content,
124
- tool_use_id: (message as ToolMessage).tool_call_id,
170
+ tool_use_id: normalizeAnthropicToolCallId(
171
+ (message as ToolMessage).tool_call_id
172
+ ),
125
173
  },
126
174
  ],
127
175
  })
@@ -139,7 +187,9 @@ function _ensureMessageContents(
139
187
  ...(toolMessageContent != null
140
188
  ? { content: _formatContent(message) }
141
189
  : {}),
142
- tool_use_id: (message as ToolMessage).tool_call_id,
190
+ tool_use_id: normalizeAnthropicToolCallId(
191
+ (message as ToolMessage).tool_call_id
192
+ ),
143
193
  },
144
194
  ],
145
195
  })
@@ -158,11 +208,12 @@ export function _convertLangChainToolCallToAnthropic(
158
208
  if (toolCall.id === undefined) {
159
209
  throw new Error('Anthropic requires all tool calls to have an "id".');
160
210
  }
211
+ const isServerTool = toolCall.id.startsWith(
212
+ Constants.ANTHROPIC_SERVER_TOOL_PREFIX
213
+ );
161
214
  return {
162
- type: toolCall.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
163
- ? 'server_tool_use'
164
- : 'tool_use',
165
- id: toolCall.id,
215
+ type: isServerTool ? 'server_tool_use' : 'tool_use',
216
+ id: isServerTool ? toolCall.id : normalizeAnthropicToolCallId(toolCall.id),
166
217
  name: toolCall.name,
167
218
  input: toolCall.args,
168
219
  };
@@ -0,0 +1,178 @@
1
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
2
+ import {
3
+ _convertLangChainToolCallToAnthropic,
4
+ _convertMessagesToAnthropicPayload,
5
+ normalizeAnthropicToolCallId,
6
+ } from './message_inputs';
7
+
8
+ describe('normalizeAnthropicToolCallId', () => {
9
+ it('returns valid IDs unchanged', () => {
10
+ expect(normalizeAnthropicToolCallId('toolu_01ABcdEFgh')).toBe(
11
+ 'toolu_01ABcdEFgh'
12
+ );
13
+ expect(normalizeAnthropicToolCallId('call_abc123XYZ')).toBe(
14
+ 'call_abc123XYZ'
15
+ );
16
+ expect(normalizeAnthropicToolCallId('a-b_c-d')).toBe('a-b_c-d');
17
+ });
18
+
19
+ it('sanitizes invalid characters and appends a hash suffix', () => {
20
+ const out = normalizeAnthropicToolCallId(
21
+ 'fc_67abc1234def567|call_abc123def456ghi789jkl0mnopqrs'
22
+ );
23
+ expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
24
+ expect(out.length).toBeLessThanOrEqual(64);
25
+ expect(
26
+ out.startsWith('fc_67abc1234def567_call_abc123def456ghi789jkl0mn')
27
+ ).toBe(true);
28
+ // Suffix is `_<10-hex-char hash>`
29
+ expect(out).toMatch(/_[0-9a-f]{10}$/);
30
+ });
31
+
32
+ it('produces compliant output for IDs of any length', () => {
33
+ const long = 'fc_' + 'a'.repeat(80);
34
+ const out = normalizeAnthropicToolCallId(long);
35
+ expect(out).toHaveLength(64);
36
+ expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
37
+ });
38
+
39
+ it('produces uniquely distinguishable outputs for IDs that share a 64-char prefix', () => {
40
+ const sharedPrefix = 'fc_' + 'a'.repeat(80);
41
+ const idA = sharedPrefix + '|call_unique_A';
42
+ const idB = sharedPrefix + '|call_unique_B';
43
+
44
+ const outA = normalizeAnthropicToolCallId(idA);
45
+ const outB = normalizeAnthropicToolCallId(idB);
46
+
47
+ expect(outA).not.toBe(outB);
48
+ expect(outA).toHaveLength(64);
49
+ expect(outB).toHaveLength(64);
50
+ expect(/^[a-zA-Z0-9_-]+$/.test(outA)).toBe(true);
51
+ expect(/^[a-zA-Z0-9_-]+$/.test(outB)).toBe(true);
52
+ });
53
+
54
+ it('disambiguates short IDs that sanitize to the same value', () => {
55
+ expect(normalizeAnthropicToolCallId('a|b')).not.toBe(
56
+ normalizeAnthropicToolCallId('a.b')
57
+ );
58
+ });
59
+
60
+ it('handles combined length and character violations', () => {
61
+ const id = 'fc_' + 'x|'.repeat(100);
62
+ const out = normalizeAnthropicToolCallId(id);
63
+ expect(out).toHaveLength(64);
64
+ expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
65
+ });
66
+
67
+ it('is deterministic — same input always yields same output', () => {
68
+ const id = 'fc_a|b|c';
69
+ expect(normalizeAnthropicToolCallId(id)).toBe(
70
+ normalizeAnthropicToolCallId(id)
71
+ );
72
+ });
73
+
74
+ it('passes through undefined for the optional overload', () => {
75
+ expect(normalizeAnthropicToolCallId(undefined)).toBeUndefined();
76
+ });
77
+
78
+ it('handles empty string by producing a deterministic compliant output', () => {
79
+ const out = normalizeAnthropicToolCallId('');
80
+ expect(/^[a-zA-Z0-9_-]+$/.test(out)).toBe(true);
81
+ expect(out.length).toBeLessThanOrEqual(64);
82
+ expect(out).toBe(normalizeAnthropicToolCallId(''));
83
+ });
84
+ });
85
+
86
+ describe('_convertMessagesToAnthropicPayload — cross-provider ID normalization', () => {
87
+ it('normalizes Responses-style IDs on tool_use AND matching tool_result', () => {
88
+ const responsesId = 'fc_67abc1234def567|call_abc123def456ghi789jkl0mnopqrs';
89
+
90
+ const payload = _convertMessagesToAnthropicPayload([
91
+ new HumanMessage('weather?'),
92
+ new AIMessage({
93
+ content: '',
94
+ tool_calls: [
95
+ {
96
+ id: responsesId,
97
+ name: 'get_weather',
98
+ args: { location: 'Tokyo' },
99
+ type: 'tool_call',
100
+ },
101
+ ],
102
+ }),
103
+ new ToolMessage({
104
+ tool_call_id: responsesId,
105
+ content: '{"temp": 21}',
106
+ }),
107
+ ]);
108
+
109
+ const assistantMsg = payload.messages.find((m) => m.role === 'assistant')!;
110
+ const userToolResultMsg = payload.messages.find(
111
+ (m) =>
112
+ m.role === 'user' &&
113
+ Array.isArray(m.content) &&
114
+ (m.content as Array<{ type: string }>)[0]?.type === 'tool_result'
115
+ )!;
116
+
117
+ const toolUseBlock = (
118
+ assistantMsg.content as Array<{ type: string; id?: string }>
119
+ ).find((b) => b.type === 'tool_use')!;
120
+ const toolResultBlock = (
121
+ userToolResultMsg.content as Array<{
122
+ type: string;
123
+ tool_use_id?: string;
124
+ }>
125
+ ).find((b) => b.type === 'tool_result')!;
126
+
127
+ const expected = normalizeAnthropicToolCallId(responsesId);
128
+ expect(toolUseBlock.id).toBe(expected);
129
+ expect(toolResultBlock.tool_use_id).toBe(expected);
130
+ expect(toolUseBlock.id).toBe(toolResultBlock.tool_use_id);
131
+ expect(/^[a-zA-Z0-9_-]+$/.test(toolUseBlock.id!)).toBe(true);
132
+ expect(toolUseBlock.id!.length).toBeLessThanOrEqual(64);
133
+ });
134
+
135
+ it('passes through Anthropic-native IDs unchanged', () => {
136
+ const nativeId = 'toolu_01ABcdEFgh23ijKL';
137
+
138
+ const payload = _convertMessagesToAnthropicPayload([
139
+ new HumanMessage('hi'),
140
+ new AIMessage({
141
+ content: '',
142
+ tool_calls: [
143
+ {
144
+ id: nativeId,
145
+ name: 'noop',
146
+ args: {},
147
+ type: 'tool_call',
148
+ },
149
+ ],
150
+ }),
151
+ new ToolMessage({
152
+ tool_call_id: nativeId,
153
+ content: 'ok',
154
+ }),
155
+ ]);
156
+
157
+ const assistantMsg = payload.messages.find((m) => m.role === 'assistant')!;
158
+ const toolUseBlock = (
159
+ assistantMsg.content as Array<{ type: string; id?: string }>
160
+ ).find((b) => b.type === 'tool_use')!;
161
+
162
+ expect(toolUseBlock.id).toBe(nativeId);
163
+ });
164
+
165
+ it('does not normalize server tool IDs (srvtoolu_ prefix)', () => {
166
+ const serverId = 'srvtoolu_01abcXYZ';
167
+
168
+ const block = _convertLangChainToolCallToAnthropic({
169
+ id: serverId,
170
+ name: 'web_search',
171
+ args: { query: 'x' },
172
+ type: 'tool_call',
173
+ });
174
+
175
+ expect(block.type).toBe('server_tool_use');
176
+ expect(block.id).toBe(serverId);
177
+ });
178
+ });
@@ -0,0 +1,154 @@
1
+ import { expect, test, describe } from '@jest/globals';
2
+ import type { GeminiContent } from '@langchain/google-common';
3
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
4
+ import { fixThoughtSignatures } from './index';
5
+
6
+ const SIG_A = 'AY89a1/sigA==';
7
+ const SIG_B = 'AY89a1/sigB==';
8
+
9
+ const buildContents = (
10
+ blocks: Array<['user' | 'model' | 'function', GeminiContent['parts']]>
11
+ ): GeminiContent[] =>
12
+ blocks.map(([role, parts]) => ({ role, parts }) as GeminiContent);
13
+
14
+ describe('fixThoughtSignatures', () => {
15
+ test('attaches signature to functionCall part when prior turn is a plain-text AI message (issue LibreChat#13006-followup)', () => {
16
+ // Reproduces the live failure from the issue: a Gemini 3 conversation
17
+ // where turn 1 was plain text ("Hello!") and turn 2 emitted a tool call
18
+ // with a thought signature. The plain-text AI message has no signatures,
19
+ // so the old position-by-filter code matched the toolcall AIMessage with
20
+ // the WRONG model content.
21
+ const helloAi = new AIMessage('Hello! How can I help you today?');
22
+ const toolcallAi = new AIMessage({
23
+ content: '',
24
+ tool_calls: [
25
+ { name: 'bash_tool', args: { command: 'echo hi' }, id: 'tc1' },
26
+ ],
27
+ additional_kwargs: { signatures: [SIG_A, ''] },
28
+ });
29
+ const input = [
30
+ new HumanMessage('hi there'),
31
+ helloAi,
32
+ new HumanMessage('run something'),
33
+ toolcallAi,
34
+ new ToolMessage({ content: 'ok', tool_call_id: 'tc1' }),
35
+ ];
36
+
37
+ const contents = buildContents([
38
+ ['user', [{ text: 'hi there' }]],
39
+ ['model', [{ text: 'Hello! How can I help you today?' }]],
40
+ ['user', [{ text: 'run something' }]],
41
+ [
42
+ 'model',
43
+ [{ functionCall: { name: 'bash_tool', args: { command: 'echo hi' } } }],
44
+ ],
45
+ [
46
+ 'user',
47
+ [
48
+ {
49
+ functionResponse: {
50
+ name: 'bash_tool',
51
+ response: { content: 'ok' },
52
+ },
53
+ },
54
+ ],
55
+ ],
56
+ ]);
57
+
58
+ fixThoughtSignatures(contents, input);
59
+
60
+ expect(contents[1].parts[0].thoughtSignature).toBeUndefined();
61
+ expect(contents[3].parts[0]).toMatchObject({
62
+ functionCall: { name: 'bash_tool' },
63
+ thoughtSignature: SIG_A,
64
+ });
65
+ });
66
+
67
+ test('attaches signatures across multiple tool-call turns by position', () => {
68
+ const turn1 = new AIMessage({
69
+ content: '',
70
+ tool_calls: [{ name: 'a', args: {}, id: 't1' }],
71
+ additional_kwargs: { signatures: [SIG_A, ''] },
72
+ });
73
+ const turn2 = new AIMessage({
74
+ content: '',
75
+ tool_calls: [{ name: 'b', args: {}, id: 't2' }],
76
+ additional_kwargs: { signatures: [SIG_B, ''] },
77
+ });
78
+
79
+ const input = [
80
+ new HumanMessage('q1'),
81
+ turn1,
82
+ new ToolMessage({ content: '1', tool_call_id: 't1' }),
83
+ new HumanMessage('q2'),
84
+ turn2,
85
+ new ToolMessage({ content: '2', tool_call_id: 't2' }),
86
+ ];
87
+ const contents = buildContents([
88
+ ['user', [{ text: 'q1' }]],
89
+ ['model', [{ functionCall: { name: 'a', args: {} } }]],
90
+ ['user', [{ functionResponse: { name: 'a', response: {} } }]],
91
+ ['user', [{ text: 'q2' }]],
92
+ ['model', [{ functionCall: { name: 'b', args: {} } }]],
93
+ ['user', [{ functionResponse: { name: 'b', response: {} } }]],
94
+ ]);
95
+
96
+ fixThoughtSignatures(contents, input);
97
+
98
+ expect(contents[1].parts[0].thoughtSignature).toBe(SIG_A);
99
+ expect(contents[4].parts[0].thoughtSignature).toBe(SIG_B);
100
+ });
101
+
102
+ test('does not overwrite signatures already attached by the library', () => {
103
+ const ai = new AIMessage({
104
+ content: '',
105
+ tool_calls: [{ name: 'a', args: {}, id: 't1' }],
106
+ additional_kwargs: { signatures: [SIG_A] },
107
+ });
108
+ const input = [new HumanMessage('q'), ai];
109
+ const contents = buildContents([
110
+ ['user', [{ text: 'q' }]],
111
+ [
112
+ 'model',
113
+ [{ functionCall: { name: 'a', args: {} }, thoughtSignature: SIG_B }],
114
+ ],
115
+ ]);
116
+
117
+ fixThoughtSignatures(contents, input);
118
+
119
+ expect(contents[1].parts[0].thoughtSignature).toBe(SIG_B);
120
+ });
121
+
122
+ test('no-op when AI message has no signatures', () => {
123
+ const ai = new AIMessage({
124
+ content: '',
125
+ tool_calls: [{ name: 'a', args: {}, id: 't1' }],
126
+ });
127
+ const input = [new HumanMessage('q'), ai];
128
+ const contents = buildContents([
129
+ ['user', [{ text: 'q' }]],
130
+ ['model', [{ functionCall: { name: 'a', args: {} } }]],
131
+ ]);
132
+
133
+ fixThoughtSignatures(contents, input);
134
+
135
+ expect(contents[1].parts[0].thoughtSignature).toBeUndefined();
136
+ });
137
+
138
+ test('skips empty-string signatures', () => {
139
+ const ai = new AIMessage({
140
+ content: '',
141
+ tool_calls: [{ name: 'a', args: {}, id: 't1' }],
142
+ additional_kwargs: { signatures: ['', '', ''] },
143
+ });
144
+ const input = [new HumanMessage('q'), ai];
145
+ const contents = buildContents([
146
+ ['user', [{ text: 'q' }]],
147
+ ['model', [{ functionCall: { name: 'a', args: {} } }]],
148
+ ]);
149
+
150
+ fixThoughtSignatures(contents, input);
151
+
152
+ expect(contents[1].parts[0].thoughtSignature).toBeUndefined();
153
+ });
154
+ });
@@ -6,10 +6,48 @@ import type {
6
6
  GoogleAIModelRequestParams,
7
7
  GoogleAbstractedClient,
8
8
  } from '@langchain/google-common';
9
- import type { BaseMessage } from '@langchain/core/messages';
10
- import { isAIMessage } from '@langchain/core/messages';
9
+ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
10
+ import type { BaseMessage, UsageMetadata } from '@langchain/core/messages';
11
+ import { AIMessageChunk, isAIMessage } from '@langchain/core/messages';
12
+ import type { ChatGenerationChunk } from '@langchain/core/outputs';
11
13
  import type { GoogleThinkingConfig, VertexAIClientOptions } from '@/types';
12
14
 
15
+ /**
16
+ * `@langchain/google-common`'s `_streamResponseChunks` emits usage on TWO
17
+ * different paths within the same stream:
18
+ *
19
+ * - Streaming chunks set `chunk.generationInfo.usage_metadata` via
20
+ * `responseToUsageMetadata`, which correctly sums
21
+ * `candidatesTokenCount + thoughtsTokenCount` and includes
22
+ * `output_token_details.reasoning`.
23
+ * - The trailing fallback chunk (emitted after the API stream exhausts)
24
+ * attaches its own `chunk.message.usage_metadata` built inline as
25
+ * `output_tokens = candidatesTokenCount` only — dropping
26
+ * `thoughtsTokenCount` and `output_token_details` entirely.
27
+ *
28
+ * After `AIMessageChunk.concat`, only `message.usage_metadata` survives —
29
+ * which is the buggy fallback value. This breaks the documented
30
+ * `total_tokens === input_tokens + output_tokens` invariant and silently
31
+ * undercharges thinking models for reasoning tokens.
32
+ *
33
+ * The repair: track the last `generationInfo.usage_metadata` we see, and
34
+ * when the fallback chunk arrives with its buggy `message.usage_metadata`,
35
+ * replace it with the tracked good value. `CustomChatGoogleGenerativeAI`
36
+ * solves the same problem for the Google API path differently — by
37
+ * overriding `_convertToUsageMetadata`.
38
+ */
39
+ export function repairStreamUsageMetadata(
40
+ current: UsageMetadata | undefined,
41
+ generationInfoUsage: UsageMetadata | undefined
42
+ ): UsageMetadata | undefined {
43
+ if (!current) return current;
44
+ if (!generationInfoUsage) return current;
45
+ if (generationInfoUsage.total_tokens !== current.total_tokens) return current;
46
+ if (generationInfoUsage.output_tokens <= current.output_tokens)
47
+ return current;
48
+ return generationInfoUsage;
49
+ }
50
+
13
51
  type AdditionalKwargs =
14
52
  | undefined
15
53
  | (BaseMessage['additional_kwargs'] & {
@@ -27,50 +65,44 @@ type AdditionalKwargs =
27
65
  * - The signature for a functionCall part is an empty string
28
66
  *
29
67
  * This function correlates each "model" content block in the formatted request
30
- * back to its originating AI message, then re-attaches non-empty signatures
31
- * that the library failed to apply.
68
+ * back to its originating AI message by *position*, then re-attaches non-empty
69
+ * signatures that the library failed to apply. AI messages without signatures
70
+ * still consume their slot — filtering them out shifted later messages onto
71
+ * the wrong content block and dropped real signatures on the floor.
32
72
  */
33
- function fixThoughtSignatures(
73
+ export function fixThoughtSignatures(
34
74
  contents: GeminiContent[],
35
75
  input: BaseMessage[]
36
76
  ): void {
37
- // Collect AI messages that have signatures, in order
38
- const aiMessages = input.filter(
39
- (msg) =>
40
- isAIMessage(msg) &&
41
- Array.isArray((msg.additional_kwargs as AdditionalKwargs)?.signatures) &&
42
- (msg.additional_kwargs.signatures as string[]).length > 0
43
- );
44
-
45
- // Collect "model" content blocks from the formatted request, in order
77
+ // All AI messages, in order non-signature ones still consume positional
78
+ // slots so later messages line up with their model content blocks.
79
+ const aiMessages = input.filter(isAIMessage);
46
80
  const modelContents = contents.filter((c) => c.role === 'model');
47
81
 
48
- // They should correspond 1:1 in order (both derived from the same input sequence)
49
82
  const count = Math.min(aiMessages.length, modelContents.length);
50
83
  for (let i = 0; i < count; i++) {
51
- const msg = aiMessages[i];
52
- const content = modelContents[i];
53
- const signatures = (msg.additional_kwargs as AdditionalKwargs)?.signatures;
84
+ const signatures = (aiMessages[i].additional_kwargs as AdditionalKwargs)
85
+ ?.signatures;
86
+ if (!Array.isArray(signatures) || signatures.length === 0) continue;
54
87
 
55
- // Collect non-empty signatures that aren't already attached to any part
88
+ const content = modelContents[i];
56
89
  const attachedSignatures = new Set(
57
90
  content.parts
58
91
  .map((p) => p.thoughtSignature)
59
92
  .filter((s): s is string => s != null && s !== '')
60
93
  );
61
- const availableSignatures = signatures?.filter(
62
- (s) => s != null && s !== '' && !attachedSignatures.has(s)
94
+ const availableSignatures = signatures.filter(
95
+ (s): s is string => s != null && s !== '' && !attachedSignatures.has(s)
63
96
  );
64
97
 
65
- // Assign available signatures to functionCall parts missing one, in order
66
98
  let sigIdx = 0;
67
99
  for (const part of content.parts) {
68
100
  if (
69
101
  'functionCall' in part &&
70
102
  (part.thoughtSignature == null || part.thoughtSignature === '') &&
71
- sigIdx < (availableSignatures?.length ?? 0)
103
+ sigIdx < availableSignatures.length
72
104
  ) {
73
- part.thoughtSignature = availableSignatures?.[sigIdx];
105
+ part.thoughtSignature = availableSignatures[sigIdx];
74
106
  sigIdx++;
75
107
  }
76
108
  }
@@ -446,6 +478,35 @@ export class ChatVertexAI extends ChatGoogle {
446
478
  }
447
479
  return params;
448
480
  }
481
+ async *_streamResponseChunks(
482
+ messages: BaseMessage[],
483
+ options: this['ParsedCallOptions'],
484
+ runManager?: CallbackManagerForLLMRun
485
+ ): AsyncGenerator<ChatGenerationChunk> {
486
+ let lastGoodUsage: UsageMetadata | undefined;
487
+ for await (const chunk of super._streamResponseChunks(
488
+ messages,
489
+ options,
490
+ runManager
491
+ )) {
492
+ const genUsage = (
493
+ chunk.generationInfo as { usage_metadata?: UsageMetadata } | undefined
494
+ )?.usage_metadata;
495
+ if (genUsage) {
496
+ lastGoodUsage = genUsage;
497
+ }
498
+ if (chunk.message instanceof AIMessageChunk) {
499
+ const repaired = repairStreamUsageMetadata(
500
+ chunk.message.usage_metadata,
501
+ lastGoodUsage
502
+ );
503
+ if (repaired !== chunk.message.usage_metadata) {
504
+ chunk.message.usage_metadata = repaired;
505
+ }
506
+ }
507
+ yield chunk;
508
+ }
509
+ }
449
510
  buildConnection(
450
511
  fields: VertexAIClientOptions | undefined,
451
512
  client: GoogleAbstractedClient
@@ -76,6 +76,24 @@ describe.each(gemini3Models)(
76
76
  (reasoningTokens as Record<string, number>)?.reasoning
77
77
  ).toBeGreaterThan(0);
78
78
  });
79
+
80
+ test('stream: usage_metadata includes reasoning in output_tokens (issue LibreChat#13006)', async () => {
81
+ let finalChunk: AIMessageChunk | undefined;
82
+ for await (const chunk of await model.stream(
83
+ 'What is 2+2? Think step by step.'
84
+ )) {
85
+ finalChunk = finalChunk ? finalChunk.concat(chunk) : chunk;
86
+ }
87
+ const usage = finalChunk?.usage_metadata;
88
+ expect(usage).toBeDefined();
89
+ const reasoning = (
90
+ usage as { output_token_details?: { reasoning?: number } }
91
+ )?.output_token_details?.reasoning;
92
+ expect(reasoning).toBeGreaterThan(0);
93
+ expect(usage!.total_tokens).toBe(
94
+ usage!.input_tokens + usage!.output_tokens
95
+ );
96
+ });
79
97
  }
80
98
  );
81
99
 
@@ -0,0 +1,54 @@
1
+ import { expect, test, describe } from '@jest/globals';
2
+ import type { UsageMetadata } from '@langchain/core/messages';
3
+ import { repairStreamUsageMetadata } from './index';
4
+
5
+ const goodUsage: UsageMetadata = {
6
+ input_tokens: 80657,
7
+ output_tokens: 2608,
8
+ total_tokens: 83265,
9
+ output_token_details: { reasoning: 1842 },
10
+ };
11
+
12
+ const buggyFallbackUsage: UsageMetadata = {
13
+ input_tokens: 80657,
14
+ output_tokens: 766,
15
+ total_tokens: 83265,
16
+ };
17
+
18
+ describe('repairStreamUsageMetadata', () => {
19
+ test('replaces buggy fallback usage with tracked good usage from generationInfo', () => {
20
+ const result = repairStreamUsageMetadata(buggyFallbackUsage, goodUsage);
21
+ expect(result).toBe(goodUsage);
22
+ });
23
+
24
+ test('returns current unchanged when no generationInfo usage was tracked', () => {
25
+ const result = repairStreamUsageMetadata(buggyFallbackUsage, undefined);
26
+ expect(result).toBe(buggyFallbackUsage);
27
+ });
28
+
29
+ test('returns undefined unchanged', () => {
30
+ const result = repairStreamUsageMetadata(undefined, goodUsage);
31
+ expect(result).toBeUndefined();
32
+ });
33
+
34
+ test('does not replace when total_tokens differ (different request)', () => {
35
+ const stale: UsageMetadata = { ...goodUsage, total_tokens: 100 };
36
+ const result = repairStreamUsageMetadata(buggyFallbackUsage, stale);
37
+ expect(result).toBe(buggyFallbackUsage);
38
+ });
39
+
40
+ test('does not replace when generationInfo output_tokens is not larger (already correct)', () => {
41
+ const equivalent: UsageMetadata = {
42
+ ...buggyFallbackUsage,
43
+ output_tokens: buggyFallbackUsage.output_tokens,
44
+ };
45
+ const result = repairStreamUsageMetadata(buggyFallbackUsage, equivalent);
46
+ expect(result).toBe(buggyFallbackUsage);
47
+ });
48
+
49
+ test('does not replace when generationInfo output_tokens is smaller', () => {
50
+ const smaller: UsageMetadata = { ...goodUsage, output_tokens: 100 };
51
+ const result = repairStreamUsageMetadata(buggyFallbackUsage, smaller);
52
+ expect(result).toBe(buggyFallbackUsage);
53
+ });
54
+ });