@librechat/agents 3.2.46 → 3.2.52

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.
Files changed (57) hide show
  1. package/dist/cjs/graphs/MultiAgentGraph.cjs +6 -6
  2. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  3. package/dist/cjs/llm/anthropic/index.cjs +4 -3
  4. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  5. package/dist/cjs/llm/anthropic/utils/tools.cjs +2 -1
  6. package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
  7. package/dist/cjs/llm/bedrock/cachePoints.cjs +28 -0
  8. package/dist/cjs/llm/bedrock/cachePoints.cjs.map +1 -0
  9. package/dist/cjs/llm/bedrock/index.cjs +10 -1
  10. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  11. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  12. package/dist/cjs/run.cjs +21 -3
  13. package/dist/cjs/run.cjs.map +1 -1
  14. package/dist/cjs/tools/ToolNode.cjs +26 -5
  15. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  16. package/dist/esm/graphs/MultiAgentGraph.mjs +7 -7
  17. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  18. package/dist/esm/llm/anthropic/index.mjs +4 -3
  19. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  20. package/dist/esm/llm/anthropic/utils/tools.mjs +2 -1
  21. package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
  22. package/dist/esm/llm/bedrock/cachePoints.mjs +28 -0
  23. package/dist/esm/llm/bedrock/cachePoints.mjs.map +1 -0
  24. package/dist/esm/llm/bedrock/index.mjs +10 -1
  25. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  26. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  27. package/dist/esm/run.mjs +21 -3
  28. package/dist/esm/run.mjs.map +1 -1
  29. package/dist/esm/tools/ToolNode.mjs +26 -5
  30. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  31. package/dist/types/llm/anthropic/utils/tools.d.ts +1 -1
  32. package/dist/types/llm/bedrock/cachePoints.d.ts +9 -0
  33. package/dist/types/run.d.ts +15 -1
  34. package/dist/types/tools/ToolNode.d.ts +9 -2
  35. package/package.json +16 -21
  36. package/src/graphs/MultiAgentGraph.ts +7 -12
  37. package/src/llm/anthropic/index.ts +13 -2
  38. package/src/llm/anthropic/inherited-content-utils.spec.ts +244 -0
  39. package/src/llm/anthropic/inherited-stream-events.spec.ts +752 -0
  40. package/src/llm/anthropic/inherited-strict.spec.ts +302 -0
  41. package/src/llm/anthropic/llm.spec.ts +65 -0
  42. package/src/llm/anthropic/utils/tools.ts +7 -1
  43. package/src/llm/bedrock/cachePoints.ts +86 -0
  44. package/src/llm/bedrock/index.ts +9 -0
  45. package/src/llm/bedrock/inherited-cache.spec.ts +144 -0
  46. package/src/llm/bedrock/inherited.spec.ts +724 -0
  47. package/src/llm/google/inherited-stream-events.spec.ts +350 -0
  48. package/src/llm/openai/inherited-deepseek.spec.ts +347 -0
  49. package/src/llm/openai/inherited-xai.spec.ts +416 -0
  50. package/src/llm/openai/llm.spec.ts +1568 -0
  51. package/src/llm/openrouter/index.ts +1 -3
  52. package/src/llm/vertexai/inherited-stream-events.spec.ts +271 -0
  53. package/src/run.ts +31 -3
  54. package/src/scripts/handoff-test.ts +5 -6
  55. package/src/tools/ToolNode.ts +43 -5
  56. package/src/tools/__tests__/ToolNode.runtimeState.test.ts +120 -0
  57. package/src/tools/__tests__/hitl.test.ts +162 -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
+ });