@librechat/agents 3.1.80-dev.3 → 3.1.80

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.
@@ -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
+ });
@@ -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'] & {
@@ -446,6 +484,35 @@ export class ChatVertexAI extends ChatGoogle {
446
484
  }
447
485
  return params;
448
486
  }
487
+ async *_streamResponseChunks(
488
+ messages: BaseMessage[],
489
+ options: this['ParsedCallOptions'],
490
+ runManager?: CallbackManagerForLLMRun
491
+ ): AsyncGenerator<ChatGenerationChunk> {
492
+ let lastGoodUsage: UsageMetadata | undefined;
493
+ for await (const chunk of super._streamResponseChunks(
494
+ messages,
495
+ options,
496
+ runManager
497
+ )) {
498
+ const genUsage = (
499
+ chunk.generationInfo as { usage_metadata?: UsageMetadata } | undefined
500
+ )?.usage_metadata;
501
+ if (genUsage) {
502
+ lastGoodUsage = genUsage;
503
+ }
504
+ if (chunk.message instanceof AIMessageChunk) {
505
+ const repaired = repairStreamUsageMetadata(
506
+ chunk.message.usage_metadata,
507
+ lastGoodUsage
508
+ );
509
+ if (repaired !== chunk.message.usage_metadata) {
510
+ chunk.message.usage_metadata = repaired;
511
+ }
512
+ }
513
+ yield chunk;
514
+ }
515
+ }
449
516
  buildConnection(
450
517
  fields: VertexAIClientOptions | undefined,
451
518
  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
+ });