@librechat/agents 3.2.46 → 3.2.51
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/index.cjs +4 -3
- package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
- package/dist/cjs/llm/anthropic/utils/tools.cjs +2 -1
- package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
- package/dist/cjs/llm/bedrock/cachePoints.cjs +28 -0
- package/dist/cjs/llm/bedrock/cachePoints.cjs.map +1 -0
- package/dist/cjs/llm/bedrock/index.cjs +10 -1
- package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
- package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
- package/dist/esm/llm/anthropic/index.mjs +4 -3
- package/dist/esm/llm/anthropic/index.mjs.map +1 -1
- package/dist/esm/llm/anthropic/utils/tools.mjs +2 -1
- package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
- package/dist/esm/llm/bedrock/cachePoints.mjs +28 -0
- package/dist/esm/llm/bedrock/cachePoints.mjs.map +1 -0
- package/dist/esm/llm/bedrock/index.mjs +10 -1
- package/dist/esm/llm/bedrock/index.mjs.map +1 -1
- package/dist/esm/llm/openrouter/index.mjs.map +1 -1
- package/dist/types/llm/anthropic/utils/tools.d.ts +1 -1
- package/dist/types/llm/bedrock/cachePoints.d.ts +9 -0
- package/package.json +16 -21
- package/src/llm/anthropic/index.ts +13 -2
- package/src/llm/anthropic/inherited-content-utils.spec.ts +244 -0
- package/src/llm/anthropic/inherited-stream-events.spec.ts +752 -0
- package/src/llm/anthropic/inherited-strict.spec.ts +302 -0
- package/src/llm/anthropic/llm.spec.ts +65 -0
- package/src/llm/anthropic/utils/tools.ts +7 -1
- package/src/llm/bedrock/cachePoints.ts +86 -0
- package/src/llm/bedrock/index.ts +9 -0
- package/src/llm/bedrock/inherited-cache.spec.ts +144 -0
- package/src/llm/bedrock/inherited.spec.ts +724 -0
- package/src/llm/google/inherited-stream-events.spec.ts +350 -0
- package/src/llm/openai/inherited-deepseek.spec.ts +347 -0
- package/src/llm/openai/inherited-xai.spec.ts +416 -0
- package/src/llm/openai/llm.spec.ts +1568 -0
- package/src/llm/openrouter/index.ts +1 -3
- package/src/llm/vertexai/inherited-stream-events.spec.ts +271 -0
|
@@ -0,0 +1,1568 @@
|
|
|
1
|
+
// Consolidated inherited specs for the LibreChat OpenAI fork, merged from three
|
|
2
|
+
// upstream-derived suites (each adapted to the fork's actual surface):
|
|
3
|
+
//
|
|
4
|
+
// 1. converters — Inherited from @langchain/openai@1.5.3
|
|
5
|
+
// converters/tests/completions.test.ts.
|
|
6
|
+
// Our fork does NOT export the standalone converter functions
|
|
7
|
+
// (convertCompletionsMessageToBaseMessage, convertCompletionsDeltaToBaseMessageChunk,
|
|
8
|
+
// convertMessagesToCompletionsMessageParams, completionsApiContentBlockConverter,
|
|
9
|
+
// convertStandardContentBlockToCompletionsContentPart). The upstream fixtures are kept,
|
|
10
|
+
// but each case is routed through our fork's actual surface:
|
|
11
|
+
// - convertMessagesToCompletionsMessageParams -> _convertMessagesToOpenAIParams (@/llm/openai/utils)
|
|
12
|
+
// - convertCompletionsMessageToBaseMessage / ...DeltaToBaseMessageChunk -> the protected
|
|
13
|
+
// delegate methods on a ChatOpenAI instance's `.completions` (wrapping upstream + attachLibreChat*)
|
|
14
|
+
// - completionsApiContentBlockConverter is module-private; it is exercised indirectly via
|
|
15
|
+
// _convertMessagesToOpenAIParams, which runs data content blocks through it.
|
|
16
|
+
// Where our fork intentionally diverges from upstream, we assert OUR behavior and note the diff inline.
|
|
17
|
+
//
|
|
18
|
+
// 2. completions — Inherited from @langchain/openai@1.5.3
|
|
19
|
+
// chat_models/tests/completions.test.ts.
|
|
20
|
+
// Our ChatOpenAI wraps the upstream completions class via a `completions`
|
|
21
|
+
// delegate; the completions-path behavior tested upstream lives on that
|
|
22
|
+
// delegate, so we reach it the way the smoke-test harness does.
|
|
23
|
+
//
|
|
24
|
+
// 3. stream events — Inherited from @langchain/openai@1.5.3
|
|
25
|
+
// chat_models/tests/chat_models_stream_events.test.ts.
|
|
26
|
+
// Upstream extends ChatOpenAICompletions directly and calls
|
|
27
|
+
// `model._streamChatModelEvents(...)`. Our fork's `ChatOpenAI` (from `@/llm/openai`)
|
|
28
|
+
// extends upstream's top-level `ChatOpenAI`, which inherits
|
|
29
|
+
// `_streamChatModelEvents` and delegates it to its `completions` sub-model. The
|
|
30
|
+
// native `ChatModelStreamEvent` protocol (`convertOpenAICompletionsStream`) is
|
|
31
|
+
// therefore fully supported by the fork: it is a separate code path from the
|
|
32
|
+
// legacy `_streamResponseChunks` override and from
|
|
33
|
+
// `_convertCompletionsDeltaToBaseMessageChunk`.
|
|
34
|
+
//
|
|
35
|
+
// Adaptation: construct the top-level fork `ChatOpenAI`, feed raw chunks by
|
|
36
|
+
// overriding `completions.completionWithRetry` (mirroring
|
|
37
|
+
// custom-chat-models.smoke.test.ts), and drive the inherited
|
|
38
|
+
// `_streamChatModelEvents` / `streamEvents`.
|
|
39
|
+
//
|
|
40
|
+
// The upstream `streamEvents` describe used vitest custom matchers
|
|
41
|
+
// (`toHaveStreamText` / `toHaveStreamToolCalls` / `toHaveStreamReasoning`),
|
|
42
|
+
// which do not exist in jest. They are re-expressed here against the public
|
|
43
|
+
// `ChatModelStream` sub-streams (`.text` / `.toolCalls` / `.reasoning`) that
|
|
44
|
+
// those matchers wrap.
|
|
45
|
+
|
|
46
|
+
import { z } from 'zod';
|
|
47
|
+
import { describe, it, test, expect, jest } from '@jest/globals';
|
|
48
|
+
import { toJsonSchema } from '@langchain/core/utils/json_schema';
|
|
49
|
+
import { ChatModelStream } from '@langchain/core/language_models/stream';
|
|
50
|
+
import {
|
|
51
|
+
AIMessage,
|
|
52
|
+
AIMessageChunk,
|
|
53
|
+
HumanMessage,
|
|
54
|
+
} from '@langchain/core/messages';
|
|
55
|
+
import type { BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
|
|
56
|
+
import type { ChatModelStreamEvent } from '@langchain/core/language_models/event';
|
|
57
|
+
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
|
58
|
+
import type { BaseMessage, BaseMessageChunk } from '@langchain/core/messages';
|
|
59
|
+
import type { ChatGenerationChunk } from '@langchain/core/outputs';
|
|
60
|
+
import type { OpenAIClient } from '@langchain/openai';
|
|
61
|
+
import { _convertMessagesToOpenAIParams } from '@/llm/openai/utils';
|
|
62
|
+
import { ChatOpenAI } from '@/llm/openai';
|
|
63
|
+
|
|
64
|
+
type CompletionsDelegate = {
|
|
65
|
+
_convertCompletionsMessageToBaseMessage(
|
|
66
|
+
message: Record<string, unknown>,
|
|
67
|
+
rawResponse: Record<string, unknown>
|
|
68
|
+
): BaseMessage;
|
|
69
|
+
_convertCompletionsDeltaToBaseMessageChunk(
|
|
70
|
+
delta: Record<string, unknown>,
|
|
71
|
+
rawResponse: Record<string, unknown>,
|
|
72
|
+
defaultRole?: string
|
|
73
|
+
): BaseMessageChunk;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type CompletionsStreamDelegate = {
|
|
77
|
+
completionWithRetry: (request: unknown) => Promise<AsyncIterable<unknown>>;
|
|
78
|
+
_streamResponseChunks: (
|
|
79
|
+
messages: BaseMessage[],
|
|
80
|
+
options: Record<string, unknown>,
|
|
81
|
+
runManager?: CallbackManagerForLLMRun
|
|
82
|
+
) => AsyncGenerator<ChatGenerationChunk>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type InvocationParamsDelegate = {
|
|
86
|
+
invocationParams: (
|
|
87
|
+
options: Record<string, unknown>,
|
|
88
|
+
extra?: { streaming?: boolean }
|
|
89
|
+
) => { tools?: { function: { strict?: boolean } }[] };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type RawChunk = OpenAIClient.Chat.Completions.ChatCompletionChunk;
|
|
93
|
+
|
|
94
|
+
type StreamEventsModel = {
|
|
95
|
+
completions: {
|
|
96
|
+
streamUsage: boolean;
|
|
97
|
+
completionWithRetry: (request: unknown) => Promise<AsyncIterable<RawChunk>>;
|
|
98
|
+
};
|
|
99
|
+
_streamChatModelEvents: (
|
|
100
|
+
messages: unknown[],
|
|
101
|
+
options: BaseChatModelCallOptions
|
|
102
|
+
) => AsyncGenerator<ChatModelStreamEvent>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function completionsDelegateOf(model: ChatOpenAI): CompletionsDelegate {
|
|
106
|
+
return (model as unknown as { completions: CompletionsDelegate }).completions;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const completionsOf = <T>(model: ChatOpenAI): T =>
|
|
110
|
+
(model as unknown as { completions: T }).completions;
|
|
111
|
+
|
|
112
|
+
function newOpenAI(): ChatOpenAI {
|
|
113
|
+
return new ChatOpenAI({ model: 'gpt-4o-mini', apiKey: 'test-key' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createStreamModel(mockChunks: RawChunk[]): ChatOpenAI {
|
|
117
|
+
const model = new ChatOpenAI({ model: 'gpt-4o-mini', apiKey: 'test-key' });
|
|
118
|
+
(model as unknown as StreamEventsModel).completions.completionWithRetry =
|
|
119
|
+
async (): Promise<AsyncIterable<RawChunk>> => ({
|
|
120
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<RawChunk> {
|
|
121
|
+
for (const chunk of mockChunks) {
|
|
122
|
+
yield chunk;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
return model;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function streamEvents(
|
|
130
|
+
model: ChatOpenAI,
|
|
131
|
+
options: BaseChatModelCallOptions = {} as BaseChatModelCallOptions
|
|
132
|
+
): AsyncGenerator<ChatModelStreamEvent> {
|
|
133
|
+
return (model as unknown as StreamEventsModel)._streamChatModelEvents(
|
|
134
|
+
[],
|
|
135
|
+
options
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function collectEvents(
|
|
140
|
+
model: ChatOpenAI,
|
|
141
|
+
options?: BaseChatModelCallOptions
|
|
142
|
+
): Promise<ChatModelStreamEvent[]> {
|
|
143
|
+
const events: ChatModelStreamEvent[] = [];
|
|
144
|
+
for await (const event of streamEvents(model, options)) {
|
|
145
|
+
events.push(event);
|
|
146
|
+
}
|
|
147
|
+
return events;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function textOnlyChunks(): RawChunk[] {
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
id: 'chatcmpl-abc',
|
|
154
|
+
object: 'chat.completion.chunk',
|
|
155
|
+
created: 0,
|
|
156
|
+
model: 'gpt-4o-mini',
|
|
157
|
+
service_tier: null,
|
|
158
|
+
system_fingerprint: null,
|
|
159
|
+
choices: [
|
|
160
|
+
{
|
|
161
|
+
index: 0,
|
|
162
|
+
delta: { role: 'assistant', content: 'Hello' },
|
|
163
|
+
finish_reason: null,
|
|
164
|
+
logprobs: null,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'chatcmpl-abc',
|
|
170
|
+
object: 'chat.completion.chunk',
|
|
171
|
+
created: 0,
|
|
172
|
+
model: 'gpt-4o-mini',
|
|
173
|
+
service_tier: null,
|
|
174
|
+
system_fingerprint: null,
|
|
175
|
+
choices: [
|
|
176
|
+
{
|
|
177
|
+
index: 0,
|
|
178
|
+
delta: { content: ' world' },
|
|
179
|
+
finish_reason: null,
|
|
180
|
+
logprobs: null,
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'chatcmpl-abc',
|
|
186
|
+
object: 'chat.completion.chunk',
|
|
187
|
+
created: 0,
|
|
188
|
+
model: 'gpt-4o-mini',
|
|
189
|
+
service_tier: null,
|
|
190
|
+
system_fingerprint: 'fp_abc',
|
|
191
|
+
choices: [
|
|
192
|
+
{
|
|
193
|
+
index: 0,
|
|
194
|
+
delta: {},
|
|
195
|
+
finish_reason: 'stop',
|
|
196
|
+
logprobs: null,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
] as unknown as RawChunk[];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function reasoningPlusTextChunks(): RawChunk[] {
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
id: 'chatcmpl-reason',
|
|
207
|
+
object: 'chat.completion.chunk',
|
|
208
|
+
created: 0,
|
|
209
|
+
model: 'gpt-5.4',
|
|
210
|
+
service_tier: null,
|
|
211
|
+
system_fingerprint: null,
|
|
212
|
+
choices: [
|
|
213
|
+
{
|
|
214
|
+
index: 0,
|
|
215
|
+
delta: {
|
|
216
|
+
role: 'assistant',
|
|
217
|
+
content: '',
|
|
218
|
+
reasoning_content: 'Let me',
|
|
219
|
+
},
|
|
220
|
+
finish_reason: null,
|
|
221
|
+
logprobs: null,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'chatcmpl-reason',
|
|
227
|
+
object: 'chat.completion.chunk',
|
|
228
|
+
created: 0,
|
|
229
|
+
model: 'gpt-5.4',
|
|
230
|
+
service_tier: null,
|
|
231
|
+
system_fingerprint: null,
|
|
232
|
+
choices: [
|
|
233
|
+
{
|
|
234
|
+
index: 0,
|
|
235
|
+
delta: { reasoning_content: ' reason...' },
|
|
236
|
+
finish_reason: null,
|
|
237
|
+
logprobs: null,
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: 'chatcmpl-reason',
|
|
243
|
+
object: 'chat.completion.chunk',
|
|
244
|
+
created: 0,
|
|
245
|
+
model: 'gpt-5.4',
|
|
246
|
+
service_tier: null,
|
|
247
|
+
system_fingerprint: null,
|
|
248
|
+
choices: [
|
|
249
|
+
{
|
|
250
|
+
index: 0,
|
|
251
|
+
delta: { content: 'The answer is 42.' },
|
|
252
|
+
finish_reason: null,
|
|
253
|
+
logprobs: null,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: 'chatcmpl-reason',
|
|
259
|
+
object: 'chat.completion.chunk',
|
|
260
|
+
created: 0,
|
|
261
|
+
model: 'gpt-5.4',
|
|
262
|
+
service_tier: null,
|
|
263
|
+
system_fingerprint: null,
|
|
264
|
+
choices: [
|
|
265
|
+
{
|
|
266
|
+
index: 0,
|
|
267
|
+
delta: {},
|
|
268
|
+
finish_reason: 'stop',
|
|
269
|
+
logprobs: null,
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
},
|
|
273
|
+
] as unknown as RawChunk[];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toolCallChunks(): RawChunk[] {
|
|
277
|
+
return [
|
|
278
|
+
{
|
|
279
|
+
id: 'chatcmpl-tools',
|
|
280
|
+
object: 'chat.completion.chunk',
|
|
281
|
+
created: 0,
|
|
282
|
+
model: 'gpt-4o-mini',
|
|
283
|
+
service_tier: null,
|
|
284
|
+
system_fingerprint: null,
|
|
285
|
+
choices: [
|
|
286
|
+
{
|
|
287
|
+
index: 0,
|
|
288
|
+
delta: { role: 'assistant', content: 'Let me search.' },
|
|
289
|
+
finish_reason: null,
|
|
290
|
+
logprobs: null,
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
id: 'chatcmpl-tools',
|
|
296
|
+
object: 'chat.completion.chunk',
|
|
297
|
+
created: 0,
|
|
298
|
+
model: 'gpt-4o-mini',
|
|
299
|
+
service_tier: null,
|
|
300
|
+
system_fingerprint: null,
|
|
301
|
+
choices: [
|
|
302
|
+
{
|
|
303
|
+
index: 0,
|
|
304
|
+
delta: {
|
|
305
|
+
tool_calls: [
|
|
306
|
+
{
|
|
307
|
+
index: 0,
|
|
308
|
+
id: 'call_abc',
|
|
309
|
+
type: 'function',
|
|
310
|
+
function: { name: 'web_search', arguments: '{"query"' },
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
finish_reason: null,
|
|
315
|
+
logprobs: null,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
id: 'chatcmpl-tools',
|
|
321
|
+
object: 'chat.completion.chunk',
|
|
322
|
+
created: 0,
|
|
323
|
+
model: 'gpt-4o-mini',
|
|
324
|
+
service_tier: null,
|
|
325
|
+
system_fingerprint: null,
|
|
326
|
+
choices: [
|
|
327
|
+
{
|
|
328
|
+
index: 0,
|
|
329
|
+
delta: {
|
|
330
|
+
tool_calls: [
|
|
331
|
+
{
|
|
332
|
+
index: 0,
|
|
333
|
+
function: { arguments: ':"weather"}' },
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
finish_reason: null,
|
|
338
|
+
logprobs: null,
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
id: 'chatcmpl-tools',
|
|
344
|
+
object: 'chat.completion.chunk',
|
|
345
|
+
created: 0,
|
|
346
|
+
model: 'gpt-4o-mini',
|
|
347
|
+
service_tier: null,
|
|
348
|
+
system_fingerprint: null,
|
|
349
|
+
choices: [
|
|
350
|
+
{
|
|
351
|
+
index: 0,
|
|
352
|
+
delta: {},
|
|
353
|
+
finish_reason: 'tool_calls',
|
|
354
|
+
logprobs: null,
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
] as unknown as RawChunk[];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function invalidToolCallChunks(): RawChunk[] {
|
|
362
|
+
return [
|
|
363
|
+
{
|
|
364
|
+
id: 'chatcmpl-bad',
|
|
365
|
+
object: 'chat.completion.chunk',
|
|
366
|
+
created: 0,
|
|
367
|
+
model: 'gpt-4o-mini',
|
|
368
|
+
service_tier: null,
|
|
369
|
+
system_fingerprint: null,
|
|
370
|
+
choices: [
|
|
371
|
+
{
|
|
372
|
+
index: 0,
|
|
373
|
+
delta: {
|
|
374
|
+
role: 'assistant',
|
|
375
|
+
tool_calls: [
|
|
376
|
+
{
|
|
377
|
+
index: 0,
|
|
378
|
+
id: 'call_bad',
|
|
379
|
+
type: 'function',
|
|
380
|
+
function: { name: 'broken', arguments: 'not-json' },
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
finish_reason: null,
|
|
385
|
+
logprobs: null,
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: 'chatcmpl-bad',
|
|
391
|
+
object: 'chat.completion.chunk',
|
|
392
|
+
created: 0,
|
|
393
|
+
model: 'gpt-4o-mini',
|
|
394
|
+
service_tier: null,
|
|
395
|
+
system_fingerprint: null,
|
|
396
|
+
choices: [
|
|
397
|
+
{
|
|
398
|
+
index: 0,
|
|
399
|
+
delta: {},
|
|
400
|
+
finish_reason: 'tool_calls',
|
|
401
|
+
logprobs: null,
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
},
|
|
405
|
+
] as unknown as RawChunk[];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function usageChunks(): RawChunk[] {
|
|
409
|
+
return [
|
|
410
|
+
{
|
|
411
|
+
id: 'chatcmpl-usage',
|
|
412
|
+
object: 'chat.completion.chunk',
|
|
413
|
+
created: 0,
|
|
414
|
+
model: 'gpt-4o-mini',
|
|
415
|
+
service_tier: null,
|
|
416
|
+
system_fingerprint: null,
|
|
417
|
+
choices: [
|
|
418
|
+
{
|
|
419
|
+
index: 0,
|
|
420
|
+
delta: { role: 'assistant', content: 'Cached response' },
|
|
421
|
+
finish_reason: null,
|
|
422
|
+
logprobs: null,
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
id: 'chatcmpl-usage',
|
|
428
|
+
object: 'chat.completion.chunk',
|
|
429
|
+
created: 0,
|
|
430
|
+
model: 'gpt-4o-mini',
|
|
431
|
+
service_tier: null,
|
|
432
|
+
system_fingerprint: null,
|
|
433
|
+
choices: [
|
|
434
|
+
{
|
|
435
|
+
index: 0,
|
|
436
|
+
delta: {},
|
|
437
|
+
finish_reason: 'stop',
|
|
438
|
+
logprobs: null,
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
id: 'chatcmpl-usage',
|
|
444
|
+
object: 'chat.completion.chunk',
|
|
445
|
+
created: 0,
|
|
446
|
+
model: 'gpt-4o-mini',
|
|
447
|
+
service_tier: null,
|
|
448
|
+
system_fingerprint: null,
|
|
449
|
+
choices: [],
|
|
450
|
+
usage: {
|
|
451
|
+
prompt_tokens: 100,
|
|
452
|
+
completion_tokens: 3,
|
|
453
|
+
total_tokens: 103,
|
|
454
|
+
prompt_tokens_details: { cached_tokens: 50, audio_tokens: null },
|
|
455
|
+
completion_tokens_details: {
|
|
456
|
+
reasoning_tokens: 2,
|
|
457
|
+
audio_tokens: null,
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
] as unknown as RawChunk[];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function parallelToolCallChunks(): RawChunk[] {
|
|
465
|
+
return [
|
|
466
|
+
{
|
|
467
|
+
id: 'chatcmpl-parallel',
|
|
468
|
+
object: 'chat.completion.chunk',
|
|
469
|
+
created: 0,
|
|
470
|
+
model: 'gpt-4o-mini',
|
|
471
|
+
service_tier: null,
|
|
472
|
+
system_fingerprint: null,
|
|
473
|
+
choices: [
|
|
474
|
+
{
|
|
475
|
+
index: 0,
|
|
476
|
+
delta: {
|
|
477
|
+
role: 'assistant',
|
|
478
|
+
tool_calls: [
|
|
479
|
+
{
|
|
480
|
+
index: 0,
|
|
481
|
+
id: 'call_1',
|
|
482
|
+
type: 'function',
|
|
483
|
+
function: { name: 'tool_a', arguments: '{}' },
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
index: 1,
|
|
487
|
+
id: 'call_2',
|
|
488
|
+
type: 'function',
|
|
489
|
+
function: { name: 'tool_b', arguments: '{}' },
|
|
490
|
+
},
|
|
491
|
+
],
|
|
492
|
+
},
|
|
493
|
+
finish_reason: null,
|
|
494
|
+
logprobs: null,
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
id: 'chatcmpl-parallel',
|
|
500
|
+
object: 'chat.completion.chunk',
|
|
501
|
+
created: 0,
|
|
502
|
+
model: 'gpt-4o-mini',
|
|
503
|
+
service_tier: null,
|
|
504
|
+
system_fingerprint: null,
|
|
505
|
+
choices: [
|
|
506
|
+
{
|
|
507
|
+
index: 0,
|
|
508
|
+
delta: {},
|
|
509
|
+
finish_reason: 'tool_calls',
|
|
510
|
+
logprobs: null,
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
},
|
|
514
|
+
] as unknown as RawChunk[];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
describe('convertCompletionsMessageToBaseMessage (fork delegate)', () => {
|
|
518
|
+
it('preserves assistant reasoning_content in additional_kwargs', () => {
|
|
519
|
+
const mockMessage = {
|
|
520
|
+
role: 'assistant' as const,
|
|
521
|
+
content: '2',
|
|
522
|
+
reasoning_content: 'The user asked 1+1.',
|
|
523
|
+
};
|
|
524
|
+
const mockRawResponse = {
|
|
525
|
+
id: 'chatcmpl-reasoning',
|
|
526
|
+
model: 'gpt-5.4',
|
|
527
|
+
choices: [{ index: 0, message: mockMessage }],
|
|
528
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const result = completionsDelegateOf(
|
|
532
|
+
newOpenAI()
|
|
533
|
+
)._convertCompletionsMessageToBaseMessage(
|
|
534
|
+
mockMessage,
|
|
535
|
+
mockRawResponse
|
|
536
|
+
) as AIMessage;
|
|
537
|
+
|
|
538
|
+
expect(result.additional_kwargs.reasoning_content).toBe(
|
|
539
|
+
'The user asked 1+1.'
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('preserves delta reasoning_content in streaming chunks', () => {
|
|
544
|
+
const delta = {
|
|
545
|
+
role: 'assistant' as const,
|
|
546
|
+
content: '',
|
|
547
|
+
reasoning_content: 'The user',
|
|
548
|
+
};
|
|
549
|
+
const rawResponse = {
|
|
550
|
+
id: 'chatcmpl-reasoning-stream',
|
|
551
|
+
choices: [{ index: 0, delta, finish_reason: null }],
|
|
552
|
+
usage: { total_tokens: 0, total_characters: 0 },
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const result = completionsDelegateOf(
|
|
556
|
+
newOpenAI()
|
|
557
|
+
)._convertCompletionsDeltaToBaseMessageChunk(
|
|
558
|
+
delta,
|
|
559
|
+
rawResponse
|
|
560
|
+
) as AIMessageChunk;
|
|
561
|
+
|
|
562
|
+
expect(result.additional_kwargs.reasoning_content).toBe('The user');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe('OpenRouter image response handling', () => {
|
|
566
|
+
it('Should correctly parse OpenRouter-style image responses', () => {
|
|
567
|
+
const mockMessage = {
|
|
568
|
+
role: 'assistant' as const,
|
|
569
|
+
content: 'Here is your image of a cute cat:',
|
|
570
|
+
};
|
|
571
|
+
const mockRawResponse = {
|
|
572
|
+
id: 'chatcmpl-12345',
|
|
573
|
+
object: 'chat.completion',
|
|
574
|
+
created: 1234567890,
|
|
575
|
+
model: 'google/gemini-2.5-flash-image-preview',
|
|
576
|
+
choices: [
|
|
577
|
+
{
|
|
578
|
+
index: 0,
|
|
579
|
+
message: {
|
|
580
|
+
...mockMessage,
|
|
581
|
+
images: [
|
|
582
|
+
{
|
|
583
|
+
type: 'image_url',
|
|
584
|
+
image_url: {
|
|
585
|
+
url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
},
|
|
590
|
+
finish_reason: 'stop',
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const result = completionsDelegateOf(
|
|
597
|
+
newOpenAI()
|
|
598
|
+
)._convertCompletionsMessageToBaseMessage(mockMessage, mockRawResponse);
|
|
599
|
+
|
|
600
|
+
expect(result.constructor.name).toBe('AIMessage');
|
|
601
|
+
expect(result.content).toEqual([
|
|
602
|
+
{ type: 'text', text: 'Here is your image of a cute cat:' },
|
|
603
|
+
{
|
|
604
|
+
type: 'image',
|
|
605
|
+
url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==',
|
|
606
|
+
},
|
|
607
|
+
]);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('Should handle OpenRouter responses with multiple images', () => {
|
|
611
|
+
const mockMessage = {
|
|
612
|
+
role: 'assistant' as const,
|
|
613
|
+
content: 'Here are multiple images:',
|
|
614
|
+
};
|
|
615
|
+
const mockRawResponse = {
|
|
616
|
+
id: 'chatcmpl-12345',
|
|
617
|
+
object: 'chat.completion',
|
|
618
|
+
created: 1234567890,
|
|
619
|
+
model: 'google/gemini-2.5-flash-image-preview',
|
|
620
|
+
choices: [
|
|
621
|
+
{
|
|
622
|
+
index: 0,
|
|
623
|
+
message: {
|
|
624
|
+
...mockMessage,
|
|
625
|
+
images: [
|
|
626
|
+
{
|
|
627
|
+
type: 'image_url',
|
|
628
|
+
image_url: { url: 'data:image/png;base64,image1' },
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
type: 'image_url',
|
|
632
|
+
image_url: { url: 'data:image/png;base64,image2' },
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
},
|
|
636
|
+
finish_reason: 'stop',
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const result = completionsDelegateOf(
|
|
643
|
+
newOpenAI()
|
|
644
|
+
)._convertCompletionsMessageToBaseMessage(mockMessage, mockRawResponse);
|
|
645
|
+
|
|
646
|
+
expect(result.content).toEqual([
|
|
647
|
+
{ type: 'text', text: 'Here are multiple images:' },
|
|
648
|
+
{ type: 'image', url: 'data:image/png;base64,image1' },
|
|
649
|
+
{ type: 'image', url: 'data:image/png;base64,image2' },
|
|
650
|
+
]);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// Dropped (inherited): the `convertStandardContentBlockToCompletionsContentPart`
|
|
656
|
+
// describe block tested an upstream-only standalone function (input shape
|
|
657
|
+
// { type, data, mimeType }) that our fork does not expose. Our fork's equivalent is the
|
|
658
|
+
// module-private `completionsApiContentBlockConverter` (StandardContentBlockConverter,
|
|
659
|
+
// source_type/mime_type shape), exercised below through _convertMessagesToOpenAIParams.
|
|
660
|
+
|
|
661
|
+
describe('completionsApiContentBlockConverter via _convertMessagesToOpenAIParams', () => {
|
|
662
|
+
const partsOf = (block: Record<string, unknown>): unknown[] => {
|
|
663
|
+
const [param] = _convertMessagesToOpenAIParams([
|
|
664
|
+
new HumanMessage({
|
|
665
|
+
content: [block as never],
|
|
666
|
+
}),
|
|
667
|
+
]);
|
|
668
|
+
return param.content as unknown[];
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
it('converts image block with base64 data to image_url data URL', () => {
|
|
672
|
+
expect(
|
|
673
|
+
partsOf({
|
|
674
|
+
type: 'image',
|
|
675
|
+
source_type: 'base64',
|
|
676
|
+
mime_type: 'image/png',
|
|
677
|
+
data: 'iVBORw0KGgoAAAANSUhEUgAAAAE',
|
|
678
|
+
})
|
|
679
|
+
).toEqual([
|
|
680
|
+
{
|
|
681
|
+
type: 'image_url',
|
|
682
|
+
image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE' },
|
|
683
|
+
},
|
|
684
|
+
]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('converts image block with url to image_url', () => {
|
|
688
|
+
expect(
|
|
689
|
+
partsOf({
|
|
690
|
+
type: 'image',
|
|
691
|
+
source_type: 'url',
|
|
692
|
+
url: 'https://example.com/cat.png',
|
|
693
|
+
})
|
|
694
|
+
).toEqual([
|
|
695
|
+
{ type: 'image_url', image_url: { url: 'https://example.com/cat.png' } },
|
|
696
|
+
]);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('omits filename for a base64 file block missing filename metadata', () => {
|
|
700
|
+
// FORK DIFFERS: upstream substitutes a "LC_AUTOGENERATED" placeholder filename
|
|
701
|
+
// and console.warns. Our fork has no such placeholder/warning and simply omits
|
|
702
|
+
// the filename key when no filename/name/title metadata is present.
|
|
703
|
+
expect(
|
|
704
|
+
partsOf({
|
|
705
|
+
type: 'file',
|
|
706
|
+
source_type: 'base64',
|
|
707
|
+
mime_type: 'application/pdf',
|
|
708
|
+
data: 'iVBORw0KGgoAAAANSUhEUgAAAAE',
|
|
709
|
+
})
|
|
710
|
+
).toEqual([
|
|
711
|
+
{
|
|
712
|
+
type: 'file',
|
|
713
|
+
file: {
|
|
714
|
+
file_data: 'data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAAAAE',
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
]);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('converts a base64 file block to an openai file payload when a filename is provided', () => {
|
|
721
|
+
expect(
|
|
722
|
+
partsOf({
|
|
723
|
+
type: 'file',
|
|
724
|
+
source_type: 'base64',
|
|
725
|
+
mime_type: 'application/pdf',
|
|
726
|
+
data: 'iVBORw0KGgoAAAANSUhEUgAAAAE',
|
|
727
|
+
metadata: { filename: 'cat.pdf' },
|
|
728
|
+
})
|
|
729
|
+
).toEqual([
|
|
730
|
+
{
|
|
731
|
+
type: 'file',
|
|
732
|
+
file: {
|
|
733
|
+
file_data: 'data:application/pdf;base64,iVBORw0KGgoAAAANSUhEUgAAAAE',
|
|
734
|
+
filename: 'cat.pdf',
|
|
735
|
+
},
|
|
736
|
+
},
|
|
737
|
+
]);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('converts a url data-url file block with filename from metadata.name', () => {
|
|
741
|
+
const dataUrl = 'data:application/pdf;base64,AAABBB';
|
|
742
|
+
expect(
|
|
743
|
+
partsOf({
|
|
744
|
+
type: 'file',
|
|
745
|
+
source_type: 'url',
|
|
746
|
+
url: dataUrl,
|
|
747
|
+
metadata: { name: 'report.pdf' },
|
|
748
|
+
})
|
|
749
|
+
).toEqual([
|
|
750
|
+
{ type: 'file', file: { file_data: dataUrl, filename: 'report.pdf' } },
|
|
751
|
+
]);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('returns file_id for id source_type', () => {
|
|
755
|
+
expect(
|
|
756
|
+
partsOf({
|
|
757
|
+
type: 'file',
|
|
758
|
+
source_type: 'id',
|
|
759
|
+
id: 'file_123',
|
|
760
|
+
})
|
|
761
|
+
).toEqual([{ type: 'file', file: { file_id: 'file_123' } }]);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('throws when a file url is not a data URL', () => {
|
|
765
|
+
expect(() =>
|
|
766
|
+
partsOf({
|
|
767
|
+
type: 'file',
|
|
768
|
+
source_type: 'url',
|
|
769
|
+
url: 'https://example.com/file.pdf',
|
|
770
|
+
metadata: { filename: 'file.pdf' },
|
|
771
|
+
})
|
|
772
|
+
).toThrow(
|
|
773
|
+
'URL file blocks with source_type url must be formatted as a data URL for ChatOpenAI'
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
describe('convertMessagesToCompletionsMessageParams (_convertMessagesToOpenAIParams)', () => {
|
|
779
|
+
it('wipes string content to empty string when tool_calls are present', () => {
|
|
780
|
+
// FORK DIFFERS: upstream@1.5.3 preserves string content alongside tool_calls.
|
|
781
|
+
// Our fork sets content to '' for AI messages carrying tool_calls (unless an
|
|
782
|
+
// Anthropic thinking block is present).
|
|
783
|
+
const message = new AIMessage({
|
|
784
|
+
content:
|
|
785
|
+
"I'll check the status of item 730 for identifier X1110 to find out why it's not active.",
|
|
786
|
+
tool_calls: [
|
|
787
|
+
{
|
|
788
|
+
id: 'call_zGKlzVl2Ee3Lyob4AsyqfGXb',
|
|
789
|
+
name: 'getStatus',
|
|
790
|
+
args: { identifier: 'X1110', itemId: '730' },
|
|
791
|
+
},
|
|
792
|
+
],
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const result = _convertMessagesToOpenAIParams([message]);
|
|
796
|
+
|
|
797
|
+
expect(result).toHaveLength(1);
|
|
798
|
+
expect(result[0]).toEqual({
|
|
799
|
+
role: 'assistant',
|
|
800
|
+
content: '',
|
|
801
|
+
tool_calls: [
|
|
802
|
+
{
|
|
803
|
+
id: 'call_zGKlzVl2Ee3Lyob4AsyqfGXb',
|
|
804
|
+
type: 'function',
|
|
805
|
+
function: {
|
|
806
|
+
name: 'getStatus',
|
|
807
|
+
arguments: '{"identifier":"X1110","itemId":"730"}',
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
],
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('handles AIMessage with empty content and tool_calls', () => {
|
|
815
|
+
const message = new AIMessage({
|
|
816
|
+
content: '',
|
|
817
|
+
tool_calls: [
|
|
818
|
+
{ id: 'call_123', name: 'someFunction', args: { key: 'value' } },
|
|
819
|
+
],
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
const result = _convertMessagesToOpenAIParams([message]);
|
|
823
|
+
|
|
824
|
+
expect(result).toHaveLength(1);
|
|
825
|
+
expect(result[0]).toEqual({
|
|
826
|
+
role: 'assistant',
|
|
827
|
+
content: '',
|
|
828
|
+
tool_calls: [
|
|
829
|
+
{
|
|
830
|
+
id: 'call_123',
|
|
831
|
+
type: 'function',
|
|
832
|
+
function: { name: 'someFunction', arguments: '{"key":"value"}' },
|
|
833
|
+
},
|
|
834
|
+
],
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('emits empty-string content for output_version v1 assistant messages', () => {
|
|
839
|
+
// FORK DIFFERS: upstream@1.5.3 emits content: [] for v1 array content with tool_calls.
|
|
840
|
+
// Our fork emits content: '' (the tool_call content array is wiped, not normalized to []).
|
|
841
|
+
const message = new AIMessage({
|
|
842
|
+
content: [
|
|
843
|
+
{
|
|
844
|
+
type: 'tool_call',
|
|
845
|
+
id: 'call_123',
|
|
846
|
+
name: 'someFunction',
|
|
847
|
+
args: { key: 'value' },
|
|
848
|
+
} as never,
|
|
849
|
+
],
|
|
850
|
+
tool_calls: [
|
|
851
|
+
{ id: 'call_123', name: 'someFunction', args: { key: 'value' } },
|
|
852
|
+
],
|
|
853
|
+
response_metadata: { output_version: 'v1' },
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
const result = _convertMessagesToOpenAIParams([message]);
|
|
857
|
+
|
|
858
|
+
expect(result).toHaveLength(1);
|
|
859
|
+
expect(result[0]).toEqual({
|
|
860
|
+
role: 'assistant',
|
|
861
|
+
content: '',
|
|
862
|
+
tool_calls: [
|
|
863
|
+
{
|
|
864
|
+
id: 'call_123',
|
|
865
|
+
type: 'function',
|
|
866
|
+
function: { name: 'someFunction', arguments: '{"key":"value"}' },
|
|
867
|
+
},
|
|
868
|
+
],
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('wipes content to empty string when function_call is in additional_kwargs', () => {
|
|
873
|
+
// FORK DIFFERS: upstream@1.5.3 preserves the string content next to function_call.
|
|
874
|
+
// Our fork clears content to '' whenever additional_kwargs.function_call is present.
|
|
875
|
+
const message = new AIMessage({
|
|
876
|
+
content: 'Let me call a function for you.',
|
|
877
|
+
additional_kwargs: {
|
|
878
|
+
function_call: { name: 'myFunction', arguments: '{"arg":"value"}' },
|
|
879
|
+
},
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const result = _convertMessagesToOpenAIParams([message]);
|
|
883
|
+
|
|
884
|
+
expect(result).toHaveLength(1);
|
|
885
|
+
expect(result[0]).toEqual({
|
|
886
|
+
role: 'assistant',
|
|
887
|
+
content: '',
|
|
888
|
+
function_call: { name: 'myFunction', arguments: '{"arg":"value"}' },
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
describe('Anthropic cross-provider compatibility', () => {
|
|
894
|
+
it('wipes content (incl. tool_use/text) to empty string when tool_calls present', () => {
|
|
895
|
+
// FORK DIFFERS: upstream@1.5.3 drops the tool_use block but keeps the text block,
|
|
896
|
+
// yielding content: [{ type: 'text', ... }]. Our fork wipes the whole array to ''
|
|
897
|
+
// because there is no Anthropic thinking block to trigger pass-through.
|
|
898
|
+
const message = new AIMessage({
|
|
899
|
+
content: [
|
|
900
|
+
{ type: 'text', text: 'I will search for that.' },
|
|
901
|
+
{
|
|
902
|
+
type: 'tool_use',
|
|
903
|
+
id: 'toolu_abc123',
|
|
904
|
+
name: 'get_weather',
|
|
905
|
+
input: { location: 'SF' },
|
|
906
|
+
} as never,
|
|
907
|
+
],
|
|
908
|
+
tool_calls: [
|
|
909
|
+
{ id: 'toolu_abc123', name: 'get_weather', args: { location: 'SF' } },
|
|
910
|
+
],
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
const result = _convertMessagesToOpenAIParams([message]);
|
|
914
|
+
|
|
915
|
+
expect(result).toHaveLength(1);
|
|
916
|
+
expect(result[0]).toEqual({
|
|
917
|
+
role: 'assistant',
|
|
918
|
+
content: '',
|
|
919
|
+
tool_calls: [
|
|
920
|
+
{
|
|
921
|
+
id: 'toolu_abc123',
|
|
922
|
+
type: 'function',
|
|
923
|
+
function: { name: 'get_weather', arguments: '{"location":"SF"}' },
|
|
924
|
+
},
|
|
925
|
+
],
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it('passes the full content array through when a thinking block is present', () => {
|
|
930
|
+
// FORK DIFFERS: upstream@1.5.3 drops tool_use while keeping thinking + text.
|
|
931
|
+
// Our fork passes the entire content array through unchanged (including the
|
|
932
|
+
// tool_use block) once any Anthropic thinking block is detected.
|
|
933
|
+
const message = new AIMessage({
|
|
934
|
+
content: [
|
|
935
|
+
{
|
|
936
|
+
type: 'thinking',
|
|
937
|
+
thinking: 'I need to consider...',
|
|
938
|
+
signature: 'sig123',
|
|
939
|
+
} as never,
|
|
940
|
+
{ type: 'text', text: 'Here is my answer.' },
|
|
941
|
+
{
|
|
942
|
+
type: 'tool_use',
|
|
943
|
+
id: 'toolu_1',
|
|
944
|
+
name: 'search',
|
|
945
|
+
input: { q: 'langchain' },
|
|
946
|
+
} as never,
|
|
947
|
+
],
|
|
948
|
+
tool_calls: [{ id: 'toolu_1', name: 'search', args: { q: 'langchain' } }],
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
const result = _convertMessagesToOpenAIParams([message]);
|
|
952
|
+
|
|
953
|
+
expect(result).toHaveLength(1);
|
|
954
|
+
const contentArr = result[0].content as Array<{ type: string }>;
|
|
955
|
+
expect(contentArr.some((c) => c.type === 'thinking')).toBe(true);
|
|
956
|
+
expect(contentArr.some((c) => c.type === 'text')).toBe(true);
|
|
957
|
+
// FORK DIFFERS: tool_use is NOT dropped under thinking-block pass-through.
|
|
958
|
+
expect(contentArr.some((c) => c.type === 'tool_use')).toBe(true);
|
|
959
|
+
expect((result[0] as { tool_calls?: unknown[] }).tool_calls).toHaveLength(
|
|
960
|
+
1
|
|
961
|
+
);
|
|
962
|
+
});
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
describe('ChatOpenAICompletions constructor', () => {
|
|
966
|
+
// Adapted: the string-model shorthand `new ChatOpenAICompletions("model", {...})`
|
|
967
|
+
// is a ChatOpenAICompletions-only constructor overload; our ChatOpenAI wrapper
|
|
968
|
+
// only accepts the object form, so we assert the same `model`/`temperature`
|
|
969
|
+
// outcome through it.
|
|
970
|
+
it('applies model and temperature from constructor fields', () => {
|
|
971
|
+
const model = new ChatOpenAI({
|
|
972
|
+
model: 'gpt-4o-mini',
|
|
973
|
+
temperature: 0.1,
|
|
974
|
+
});
|
|
975
|
+
expect(model.model).toBe('gpt-4o-mini');
|
|
976
|
+
expect(model.temperature).toBe(0.1);
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
describe('ChatOpenAICompletions streaming usage_metadata callback', () => {
|
|
981
|
+
it('should call handleLLMNewToken for the usage chunk', async () => {
|
|
982
|
+
const model = new ChatOpenAI({
|
|
983
|
+
model: 'gpt-4o-mini',
|
|
984
|
+
apiKey: 'test-key',
|
|
985
|
+
streaming: true,
|
|
986
|
+
streamUsage: true,
|
|
987
|
+
});
|
|
988
|
+
const completions = completionsOf<CompletionsStreamDelegate>(model);
|
|
989
|
+
|
|
990
|
+
// Mock completionWithRetry to return a fake async iterable
|
|
991
|
+
// that simulates: one content chunk, then a usage-only chunk
|
|
992
|
+
const fakeStream = (async function* () {
|
|
993
|
+
// Content chunk
|
|
994
|
+
yield {
|
|
995
|
+
choices: [
|
|
996
|
+
{
|
|
997
|
+
index: 0,
|
|
998
|
+
delta: { role: 'assistant' as const, content: 'Hello' },
|
|
999
|
+
finish_reason: null,
|
|
1000
|
+
logprobs: null,
|
|
1001
|
+
},
|
|
1002
|
+
],
|
|
1003
|
+
usage: null,
|
|
1004
|
+
system_fingerprint: null,
|
|
1005
|
+
model: 'gpt-4o-mini',
|
|
1006
|
+
service_tier: null,
|
|
1007
|
+
};
|
|
1008
|
+
// Final chunk with finish_reason
|
|
1009
|
+
yield {
|
|
1010
|
+
choices: [
|
|
1011
|
+
{
|
|
1012
|
+
index: 0,
|
|
1013
|
+
delta: { content: '' },
|
|
1014
|
+
finish_reason: 'stop',
|
|
1015
|
+
logprobs: null,
|
|
1016
|
+
},
|
|
1017
|
+
],
|
|
1018
|
+
usage: null,
|
|
1019
|
+
system_fingerprint: 'fp_abc123',
|
|
1020
|
+
model: 'gpt-4o-mini',
|
|
1021
|
+
service_tier: null,
|
|
1022
|
+
};
|
|
1023
|
+
// Usage-only chunk (no choices)
|
|
1024
|
+
yield {
|
|
1025
|
+
choices: [],
|
|
1026
|
+
usage: {
|
|
1027
|
+
prompt_tokens: 10,
|
|
1028
|
+
completion_tokens: 5,
|
|
1029
|
+
total_tokens: 15,
|
|
1030
|
+
prompt_tokens_details: null,
|
|
1031
|
+
completion_tokens_details: null,
|
|
1032
|
+
},
|
|
1033
|
+
system_fingerprint: null,
|
|
1034
|
+
model: 'gpt-4o-mini',
|
|
1035
|
+
service_tier: null,
|
|
1036
|
+
};
|
|
1037
|
+
})();
|
|
1038
|
+
|
|
1039
|
+
completions.completionWithRetry = async (): Promise<
|
|
1040
|
+
AsyncIterable<unknown>
|
|
1041
|
+
> => fakeStream;
|
|
1042
|
+
|
|
1043
|
+
// Create a mock runManager
|
|
1044
|
+
const handleLLMNewToken = jest.fn();
|
|
1045
|
+
const runManager = {
|
|
1046
|
+
handleLLMNewToken,
|
|
1047
|
+
} as unknown as CallbackManagerForLLMRun;
|
|
1048
|
+
|
|
1049
|
+
const chunks: ChatGenerationChunk[] = [];
|
|
1050
|
+
for await (const chunk of completions._streamResponseChunks(
|
|
1051
|
+
[new HumanMessage('test')],
|
|
1052
|
+
{},
|
|
1053
|
+
runManager
|
|
1054
|
+
)) {
|
|
1055
|
+
chunks.push(chunk);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Should have 3 chunks: content, finish, and usage
|
|
1059
|
+
expect(chunks.length).toBe(3);
|
|
1060
|
+
|
|
1061
|
+
// The last chunk should have usage_metadata
|
|
1062
|
+
const usageChunk = chunks[chunks.length - 1];
|
|
1063
|
+
const usageMessage = usageChunk.message as AIMessageChunk;
|
|
1064
|
+
expect(usageMessage.usage_metadata).toBeDefined();
|
|
1065
|
+
expect(usageMessage.usage_metadata?.input_tokens).toBe(10);
|
|
1066
|
+
expect(usageMessage.usage_metadata?.output_tokens).toBe(5);
|
|
1067
|
+
expect(usageMessage.usage_metadata?.total_tokens).toBe(15);
|
|
1068
|
+
|
|
1069
|
+
// handleLLMNewToken should have been called for EVERY chunk,
|
|
1070
|
+
// including the usage chunk (this is the bug fix)
|
|
1071
|
+
expect(handleLLMNewToken).toHaveBeenCalledTimes(3);
|
|
1072
|
+
|
|
1073
|
+
// Verify the last call includes the usage chunk
|
|
1074
|
+
const lastCall = handleLLMNewToken.mock.calls[2];
|
|
1075
|
+
const lastCallFields = lastCall[5] as {
|
|
1076
|
+
chunk: { message: AIMessageChunk };
|
|
1077
|
+
};
|
|
1078
|
+
expect(lastCallFields.chunk.message.usage_metadata).toBeDefined();
|
|
1079
|
+
expect(lastCallFields.chunk.message.usage_metadata?.input_tokens).toBe(10);
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
describe('ChatOpenAICompletions reasoning_content compatibility', () => {
|
|
1084
|
+
it('should preserve reasoning_content on streamed assistant chunks', async () => {
|
|
1085
|
+
const model = new ChatOpenAI({
|
|
1086
|
+
model: 'gpt-5.4',
|
|
1087
|
+
apiKey: 'test-key',
|
|
1088
|
+
streaming: true,
|
|
1089
|
+
});
|
|
1090
|
+
const completions = completionsOf<CompletionsStreamDelegate>(model);
|
|
1091
|
+
|
|
1092
|
+
const fakeStream = (async function* () {
|
|
1093
|
+
yield {
|
|
1094
|
+
choices: [
|
|
1095
|
+
{
|
|
1096
|
+
index: 0,
|
|
1097
|
+
delta: {
|
|
1098
|
+
role: 'assistant' as const,
|
|
1099
|
+
content: '',
|
|
1100
|
+
reasoning_content: 'The user',
|
|
1101
|
+
},
|
|
1102
|
+
finish_reason: null,
|
|
1103
|
+
logprobs: null,
|
|
1104
|
+
},
|
|
1105
|
+
],
|
|
1106
|
+
usage: null,
|
|
1107
|
+
system_fingerprint: null,
|
|
1108
|
+
model: 'gpt-5.4',
|
|
1109
|
+
service_tier: null,
|
|
1110
|
+
};
|
|
1111
|
+
yield {
|
|
1112
|
+
choices: [
|
|
1113
|
+
{
|
|
1114
|
+
index: 0,
|
|
1115
|
+
delta: { content: '' },
|
|
1116
|
+
finish_reason: 'stop',
|
|
1117
|
+
logprobs: null,
|
|
1118
|
+
},
|
|
1119
|
+
],
|
|
1120
|
+
usage: null,
|
|
1121
|
+
system_fingerprint: null,
|
|
1122
|
+
model: 'gpt-5.4',
|
|
1123
|
+
service_tier: null,
|
|
1124
|
+
};
|
|
1125
|
+
})();
|
|
1126
|
+
|
|
1127
|
+
completions.completionWithRetry = async (): Promise<
|
|
1128
|
+
AsyncIterable<unknown>
|
|
1129
|
+
> => fakeStream;
|
|
1130
|
+
|
|
1131
|
+
const chunks: ChatGenerationChunk[] = [];
|
|
1132
|
+
for await (const chunk of completions._streamResponseChunks(
|
|
1133
|
+
[new HumanMessage('1+1=?')],
|
|
1134
|
+
{}
|
|
1135
|
+
)) {
|
|
1136
|
+
chunks.push(chunk);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const firstChunk = chunks[0].message as AIMessageChunk;
|
|
1140
|
+
expect(firstChunk.additional_kwargs.reasoning_content).toBe('The user');
|
|
1141
|
+
});
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
describe('ChatOpenAICompletions strict tools for structured output', () => {
|
|
1145
|
+
const weatherTool = {
|
|
1146
|
+
type: 'function' as const,
|
|
1147
|
+
function: {
|
|
1148
|
+
name: 'get_current_weather',
|
|
1149
|
+
description: 'Get the current weather in a location',
|
|
1150
|
+
parameters: toJsonSchema(z.object({ location: z.string() })),
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
const jsonSchemaResponseFormat = {
|
|
1154
|
+
type: 'json_schema' as const,
|
|
1155
|
+
json_schema: {
|
|
1156
|
+
name: 'answer',
|
|
1157
|
+
schema: toJsonSchema(z.object({ answer: z.string() })),
|
|
1158
|
+
},
|
|
1159
|
+
};
|
|
1160
|
+
|
|
1161
|
+
/** Return the per-tool `strict` flag invocationParams produces for `options`. */
|
|
1162
|
+
function toolStrict(
|
|
1163
|
+
options: Record<string, unknown>,
|
|
1164
|
+
extra?: { streaming?: boolean }
|
|
1165
|
+
): boolean | undefined {
|
|
1166
|
+
const model = new ChatOpenAI({
|
|
1167
|
+
model: 'gpt-4',
|
|
1168
|
+
apiKey: 'test-key',
|
|
1169
|
+
});
|
|
1170
|
+
const completions = completionsOf<InvocationParamsDelegate>(model);
|
|
1171
|
+
const params = completions.invocationParams(
|
|
1172
|
+
{ tools: [weatherTool], ...options },
|
|
1173
|
+
extra
|
|
1174
|
+
);
|
|
1175
|
+
return params.tools?.[0]?.function?.strict;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
it('defaults strict to true when a json_schema response_format is requested', () => {
|
|
1179
|
+
expect(toolStrict({ response_format: jsonSchemaResponseFormat })).toBe(
|
|
1180
|
+
true
|
|
1181
|
+
);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
it('respects an explicit strict:false even with a json_schema response_format', () => {
|
|
1185
|
+
expect(
|
|
1186
|
+
toolStrict({ response_format: jsonSchemaResponseFormat, strict: false })
|
|
1187
|
+
).toBe(false);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it('does not set strict when no response_format is requested', () => {
|
|
1191
|
+
expect(toolStrict({})).toBeUndefined();
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it('does not set strict for a streaming json_schema request (create() path)', () => {
|
|
1195
|
+
// Streaming goes through create(), not .parse(), so strict isn't required.
|
|
1196
|
+
expect(
|
|
1197
|
+
toolStrict(
|
|
1198
|
+
{ response_format: jsonSchemaResponseFormat },
|
|
1199
|
+
{ streaming: true }
|
|
1200
|
+
)
|
|
1201
|
+
).toBeUndefined();
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
it('does not set strict for a json_object response_format (JSON mode)', () => {
|
|
1205
|
+
expect(
|
|
1206
|
+
toolStrict({ response_format: { type: 'json_object' } })
|
|
1207
|
+
).toBeUndefined();
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
describe('ChatOpenAI._streamChatModelEvents (native, fork)', () => {
|
|
1212
|
+
describe('text-only streaming', () => {
|
|
1213
|
+
test('emits correct lifecycle events', async () => {
|
|
1214
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1215
|
+
const eventNames = (await collectEvents(model)).map((e) => e.event);
|
|
1216
|
+
|
|
1217
|
+
expect(eventNames).toContain('message-start');
|
|
1218
|
+
expect(eventNames).toContain('content-block-start');
|
|
1219
|
+
expect(eventNames).toContain('content-block-delta');
|
|
1220
|
+
expect(eventNames).toContain('content-block-finish');
|
|
1221
|
+
expect(eventNames).toContain('message-finish');
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
test('message-start carries id', async () => {
|
|
1225
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1226
|
+
const events = await collectEvents(model);
|
|
1227
|
+
|
|
1228
|
+
const start = events.find((e) => e.event === 'message-start');
|
|
1229
|
+
expect(start).toBeDefined();
|
|
1230
|
+
expect((start as { id?: string }).id).toBe('chatcmpl-abc');
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
test('text deltas are incremental', async () => {
|
|
1234
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1235
|
+
const events = await collectEvents(model);
|
|
1236
|
+
|
|
1237
|
+
const textDeltas = events.filter(
|
|
1238
|
+
(e) =>
|
|
1239
|
+
e.event === 'content-block-delta' &&
|
|
1240
|
+
'delta' in e &&
|
|
1241
|
+
(e.delta as { type: string }).type === 'text-delta'
|
|
1242
|
+
);
|
|
1243
|
+
expect(textDeltas.length).toBe(2);
|
|
1244
|
+
expect((textDeltas[0] as { delta: { text: string } }).delta.text).toBe(
|
|
1245
|
+
'Hello'
|
|
1246
|
+
);
|
|
1247
|
+
expect((textDeltas[1] as { delta: { text: string } }).delta.text).toBe(
|
|
1248
|
+
' world'
|
|
1249
|
+
);
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
test('content-block-finish carries finalized text', async () => {
|
|
1253
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1254
|
+
const events = await collectEvents(model);
|
|
1255
|
+
|
|
1256
|
+
expect(
|
|
1257
|
+
events.find((e) => e.event === 'content-block-finish')
|
|
1258
|
+
).toMatchObject({
|
|
1259
|
+
content: { type: 'text', text: 'Hello world' },
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
test('message-finish carries stop reason', async () => {
|
|
1264
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1265
|
+
const events = await collectEvents(model);
|
|
1266
|
+
|
|
1267
|
+
const finish = events.find((e) => e.event === 'message-finish') as {
|
|
1268
|
+
reason: string;
|
|
1269
|
+
};
|
|
1270
|
+
expect(finish.reason).toBe('stop');
|
|
1271
|
+
});
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
describe('reasoning + text streaming', () => {
|
|
1275
|
+
test('reasoning block accumulates correctly', async () => {
|
|
1276
|
+
const model = createStreamModel(reasoningPlusTextChunks());
|
|
1277
|
+
const events = await collectEvents(model);
|
|
1278
|
+
|
|
1279
|
+
const reasoningDeltas = events.filter(
|
|
1280
|
+
(e) =>
|
|
1281
|
+
e.event === 'content-block-delta' &&
|
|
1282
|
+
'delta' in e &&
|
|
1283
|
+
(e.delta as { type: string }).type === 'reasoning-delta'
|
|
1284
|
+
);
|
|
1285
|
+
expect(reasoningDeltas.length).toBe(2);
|
|
1286
|
+
expect(
|
|
1287
|
+
(reasoningDeltas[0] as { delta: { reasoning: string } }).delta.reasoning
|
|
1288
|
+
).toBe('Let me');
|
|
1289
|
+
expect(
|
|
1290
|
+
(reasoningDeltas[1] as { delta: { reasoning: string } }).delta.reasoning
|
|
1291
|
+
).toBe(' reason...');
|
|
1292
|
+
|
|
1293
|
+
expect(
|
|
1294
|
+
events.find(
|
|
1295
|
+
(e) =>
|
|
1296
|
+
e.event === 'content-block-finish' && e.content.type === 'reasoning'
|
|
1297
|
+
)
|
|
1298
|
+
).toMatchObject({
|
|
1299
|
+
content: { reasoning: 'Let me reason...' },
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
test('text block uses separate index from reasoning', async () => {
|
|
1304
|
+
const model = createStreamModel(reasoningPlusTextChunks());
|
|
1305
|
+
const events = await collectEvents(model);
|
|
1306
|
+
|
|
1307
|
+
expect(
|
|
1308
|
+
events.find(
|
|
1309
|
+
(e) => e.event === 'content-block-finish' && e.content.type === 'text'
|
|
1310
|
+
)
|
|
1311
|
+
).toMatchObject({
|
|
1312
|
+
content: { text: 'The answer is 42.' },
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
describe('tool call streaming', () => {
|
|
1318
|
+
test('tool call args accumulate correctly', async () => {
|
|
1319
|
+
const model = createStreamModel(toolCallChunks());
|
|
1320
|
+
const events = await collectEvents(model);
|
|
1321
|
+
|
|
1322
|
+
const toolDeltas = events.filter(
|
|
1323
|
+
(e) =>
|
|
1324
|
+
e.event === 'content-block-delta' &&
|
|
1325
|
+
'delta' in e &&
|
|
1326
|
+
(e.delta as { type: string }).type === 'block-delta'
|
|
1327
|
+
);
|
|
1328
|
+
const toolArgDeltas = toolDeltas.filter(
|
|
1329
|
+
(e) =>
|
|
1330
|
+
(e as unknown as { delta: { fields?: { args?: string } } }).delta
|
|
1331
|
+
.fields?.args != null
|
|
1332
|
+
);
|
|
1333
|
+
expect(toolArgDeltas.length).toBe(2);
|
|
1334
|
+
expect(
|
|
1335
|
+
(toolArgDeltas[0] as unknown as { delta: { fields: { args: string } } })
|
|
1336
|
+
.delta.fields.args
|
|
1337
|
+
).toBe('{"query"');
|
|
1338
|
+
expect(
|
|
1339
|
+
(toolArgDeltas[1] as unknown as { delta: { fields: { args: string } } })
|
|
1340
|
+
.delta.fields.args
|
|
1341
|
+
).toBe('{"query":"weather"}');
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
test('tool call finish has parsed args', async () => {
|
|
1345
|
+
const model = createStreamModel(toolCallChunks());
|
|
1346
|
+
const events = await collectEvents(model);
|
|
1347
|
+
|
|
1348
|
+
expect(
|
|
1349
|
+
events.find(
|
|
1350
|
+
(e) =>
|
|
1351
|
+
e.event === 'content-block-finish' && e.content.type === 'tool_call'
|
|
1352
|
+
)
|
|
1353
|
+
).toMatchObject({
|
|
1354
|
+
content: {
|
|
1355
|
+
name: 'web_search',
|
|
1356
|
+
id: 'call_abc',
|
|
1357
|
+
args: { query: 'weather' },
|
|
1358
|
+
},
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
test('invalid tool call JSON becomes invalid_tool_call', async () => {
|
|
1363
|
+
const model = createStreamModel(invalidToolCallChunks());
|
|
1364
|
+
const events = await collectEvents(model);
|
|
1365
|
+
|
|
1366
|
+
expect(
|
|
1367
|
+
events.find(
|
|
1368
|
+
(e) =>
|
|
1369
|
+
e.event === 'content-block-finish' &&
|
|
1370
|
+
e.content.type === 'invalid_tool_call'
|
|
1371
|
+
)
|
|
1372
|
+
).toMatchObject({
|
|
1373
|
+
content: {
|
|
1374
|
+
name: 'broken',
|
|
1375
|
+
error: expect.stringContaining('JSON'),
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
test('message-finish has tool_use reason', async () => {
|
|
1381
|
+
const model = createStreamModel(toolCallChunks());
|
|
1382
|
+
const events = await collectEvents(model);
|
|
1383
|
+
|
|
1384
|
+
const finish = events.find((e) => e.event === 'message-finish') as {
|
|
1385
|
+
reason: string;
|
|
1386
|
+
};
|
|
1387
|
+
expect(finish.reason).toBe('tool_use');
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
test('parallel tool calls get distinct block indices', async () => {
|
|
1391
|
+
const model = createStreamModel(parallelToolCallChunks());
|
|
1392
|
+
const events = await collectEvents(model);
|
|
1393
|
+
|
|
1394
|
+
const toolStarts = events.filter(
|
|
1395
|
+
(e) =>
|
|
1396
|
+
e.event === 'content-block-start' &&
|
|
1397
|
+
'content' in e &&
|
|
1398
|
+
(e.content as { type: string }).type === 'tool_call_chunk'
|
|
1399
|
+
);
|
|
1400
|
+
expect(toolStarts.length).toBe(2);
|
|
1401
|
+
const indices = toolStarts.map((e) => (e as { index: number }).index);
|
|
1402
|
+
expect(new Set(indices).size).toBe(2);
|
|
1403
|
+
});
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
describe('usage streaming', () => {
|
|
1407
|
+
test('usage snapshot with cache details', async () => {
|
|
1408
|
+
const model = createStreamModel(usageChunks());
|
|
1409
|
+
const events = await collectEvents(model, {
|
|
1410
|
+
streamUsage: true,
|
|
1411
|
+
} as BaseChatModelCallOptions);
|
|
1412
|
+
|
|
1413
|
+
const usageEvents = events.filter((e) => e.event === 'usage');
|
|
1414
|
+
expect(usageEvents.length).toBe(1);
|
|
1415
|
+
|
|
1416
|
+
const usage = (
|
|
1417
|
+
usageEvents[0] as {
|
|
1418
|
+
usage: {
|
|
1419
|
+
input_tokens: number;
|
|
1420
|
+
input_token_details: { cache_read: number };
|
|
1421
|
+
output_token_details: { reasoning: number };
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
).usage;
|
|
1425
|
+
expect(usage.input_tokens).toBe(100);
|
|
1426
|
+
expect(usage.input_token_details.cache_read).toBe(50);
|
|
1427
|
+
expect(usage.output_token_details.reasoning).toBe(2);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
test('message-finish carries final usage', async () => {
|
|
1431
|
+
const model = createStreamModel(usageChunks());
|
|
1432
|
+
const events = await collectEvents(model, {
|
|
1433
|
+
streamUsage: true,
|
|
1434
|
+
} as BaseChatModelCallOptions);
|
|
1435
|
+
|
|
1436
|
+
const finish = events.find((e) => e.event === 'message-finish') as {
|
|
1437
|
+
usage: { total_tokens: number };
|
|
1438
|
+
};
|
|
1439
|
+
expect(finish.usage.total_tokens).toBe(103);
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
test('no usage events when streamUsage is false', async () => {
|
|
1443
|
+
const model = createStreamModel(usageChunks());
|
|
1444
|
+
// Upstream extends the completions class, so `model.streamUsage` is the
|
|
1445
|
+
// completions delegate's field. Our fork's top-level model delegates to
|
|
1446
|
+
// its `completions` sub-model, whose `streamUsage` is what gates emission.
|
|
1447
|
+
(model as unknown as StreamEventsModel).completions.streamUsage = false;
|
|
1448
|
+
const events = await collectEvents(model, {
|
|
1449
|
+
streamUsage: false,
|
|
1450
|
+
} as BaseChatModelCallOptions);
|
|
1451
|
+
|
|
1452
|
+
expect(events.filter((e) => e.event === 'usage').length).toBe(0);
|
|
1453
|
+
const finish = events.find((e) => e.event === 'message-finish') as {
|
|
1454
|
+
usage?: unknown;
|
|
1455
|
+
};
|
|
1456
|
+
expect(finish.usage).toBeUndefined();
|
|
1457
|
+
});
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
describe('provider passthrough', () => {
|
|
1461
|
+
test('stream metadata is forwarded as provider event', async () => {
|
|
1462
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1463
|
+
const events = await collectEvents(model);
|
|
1464
|
+
|
|
1465
|
+
const meta = events.find(
|
|
1466
|
+
(e) =>
|
|
1467
|
+
e.event === 'provider' &&
|
|
1468
|
+
(e as { name: string }).name === 'stream_metadata'
|
|
1469
|
+
) as { provider: string; payload: { model: string } };
|
|
1470
|
+
expect(meta.provider).toBe('openai');
|
|
1471
|
+
expect(meta.payload.model).toBe('gpt-4o-mini');
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
describe('integration with ChatModelStream', () => {
|
|
1476
|
+
test('text sub-stream works end-to-end', async () => {
|
|
1477
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1478
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
1479
|
+
expect(await stream.text).toBe('Hello world');
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
test('toolCalls sub-stream works end-to-end', async () => {
|
|
1483
|
+
const model = createStreamModel(toolCallChunks());
|
|
1484
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
1485
|
+
const calls = await stream.toolCalls;
|
|
1486
|
+
expect(calls.length).toBe(1);
|
|
1487
|
+
expect(calls[0]!.name).toBe('web_search');
|
|
1488
|
+
expect(calls[0]!.args).toEqual({ query: 'weather' });
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
test('reasoning sub-stream works end-to-end', async () => {
|
|
1492
|
+
const model = createStreamModel(reasoningPlusTextChunks());
|
|
1493
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
1494
|
+
expect(await stream.reasoning).toBe('Let me reason...');
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
test('usage sub-stream works end-to-end', async () => {
|
|
1498
|
+
const model = createStreamModel(usageChunks());
|
|
1499
|
+
const stream = new ChatModelStream(
|
|
1500
|
+
streamEvents(model, { streamUsage: true } as BaseChatModelCallOptions)
|
|
1501
|
+
);
|
|
1502
|
+
expect(await stream.usage).toMatchObject({
|
|
1503
|
+
input_tokens: 100,
|
|
1504
|
+
output_tokens: 3,
|
|
1505
|
+
total_tokens: 103,
|
|
1506
|
+
});
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
test('output assembles correct AIMessage', async () => {
|
|
1510
|
+
const model = createStreamModel(toolCallChunks());
|
|
1511
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
1512
|
+
const message = await stream.output;
|
|
1513
|
+
|
|
1514
|
+
expect(message.id).toBe('chatcmpl-tools');
|
|
1515
|
+
expect(message._getType()).toBe('ai');
|
|
1516
|
+
|
|
1517
|
+
const content = message.content as Array<{
|
|
1518
|
+
type: string;
|
|
1519
|
+
text?: string;
|
|
1520
|
+
name?: string;
|
|
1521
|
+
args?: unknown;
|
|
1522
|
+
}>;
|
|
1523
|
+
expect(content.length).toBe(2);
|
|
1524
|
+
expect(content[0]!.type).toBe('text');
|
|
1525
|
+
expect(content[0]!.text).toBe('Let me search.');
|
|
1526
|
+
expect(content[1]!.type).toBe('tool_call');
|
|
1527
|
+
expect(content[1]!.name).toBe('web_search');
|
|
1528
|
+
expect(content[1]!.args).toEqual({ query: 'weather' });
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
test('await stream returns AIMessage directly', async () => {
|
|
1532
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1533
|
+
const message = await new ChatModelStream(streamEvents(model));
|
|
1534
|
+
expect(message._getType()).toBe('ai');
|
|
1535
|
+
expect(message.id).toBe('chatcmpl-abc');
|
|
1536
|
+
});
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
// Re-expressed from upstream's `streaming events` describe, which used vitest
|
|
1540
|
+
// custom matchers (`toHaveStreamText` / `toHaveStreamToolCalls` /
|
|
1541
|
+
// `toHaveStreamReasoning`) unavailable in jest. The matchers wrap the
|
|
1542
|
+
// `ChatModelStream` sub-streams asserted here directly.
|
|
1543
|
+
describe('streaming events (sub-stream assertions)', () => {
|
|
1544
|
+
test('streams text', async () => {
|
|
1545
|
+
const model = createStreamModel(textOnlyChunks());
|
|
1546
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
1547
|
+
expect(await stream.text).toBe('Hello world');
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
test('streams tool calls', async () => {
|
|
1551
|
+
const model = createStreamModel(toolCallChunks());
|
|
1552
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
1553
|
+
const calls = await stream.toolCalls;
|
|
1554
|
+
expect(calls).toEqual([
|
|
1555
|
+
expect.objectContaining({
|
|
1556
|
+
name: 'web_search',
|
|
1557
|
+
args: { query: 'weather' },
|
|
1558
|
+
}),
|
|
1559
|
+
]);
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
test('streams reasoning', async () => {
|
|
1563
|
+
const model = createStreamModel(reasoningPlusTextChunks());
|
|
1564
|
+
const stream = new ChatModelStream(streamEvents(model));
|
|
1565
|
+
expect(await stream.reasoning).toBe('Let me reason...');
|
|
1566
|
+
});
|
|
1567
|
+
});
|
|
1568
|
+
});
|