@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.
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs +27 -7
- package/dist/cjs/llm/anthropic/utils/message_inputs.cjs.map +1 -1
- package/dist/cjs/llm/vertexai/index.cjs +67 -15
- package/dist/cjs/llm/vertexai/index.cjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs +27 -8
- package/dist/esm/llm/anthropic/utils/message_inputs.mjs.map +1 -1
- package/dist/esm/llm/vertexai/index.mjs +67 -17
- package/dist/esm/llm/vertexai/index.mjs.map +1 -1
- package/dist/types/llm/anthropic/utils/message_inputs.d.ts +15 -3
- package/dist/types/llm/vertexai/index.d.ts +47 -1
- package/package.json +10 -5
- package/src/llm/anthropic/utils/message_inputs.ts +58 -7
- package/src/llm/anthropic/utils/tool-id-normalization.test.ts +178 -0
- package/src/llm/vertexai/fixThoughtSignatures.test.ts +154 -0
- package/src/llm/vertexai/index.ts +85 -24
- package/src/llm/vertexai/llm.spec.ts +18 -0
- package/src/llm/vertexai/repairUsageMetadata.test.ts +54 -0
|
@@ -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: (
|
|
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: (
|
|
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: (
|
|
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:
|
|
163
|
-
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
84
|
+
const signatures = (aiMessages[i].additional_kwargs as AdditionalKwargs)
|
|
85
|
+
?.signatures;
|
|
86
|
+
if (!Array.isArray(signatures) || signatures.length === 0) continue;
|
|
54
87
|
|
|
55
|
-
|
|
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
|
|
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 <
|
|
103
|
+
sigIdx < availableSignatures.length
|
|
72
104
|
) {
|
|
73
|
-
part.thoughtSignature = availableSignatures
|
|
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
|
+
});
|