@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.
Files changed (37) hide show
  1. package/dist/cjs/llm/anthropic/index.cjs +4 -3
  2. package/dist/cjs/llm/anthropic/index.cjs.map +1 -1
  3. package/dist/cjs/llm/anthropic/utils/tools.cjs +2 -1
  4. package/dist/cjs/llm/anthropic/utils/tools.cjs.map +1 -1
  5. package/dist/cjs/llm/bedrock/cachePoints.cjs +28 -0
  6. package/dist/cjs/llm/bedrock/cachePoints.cjs.map +1 -0
  7. package/dist/cjs/llm/bedrock/index.cjs +10 -1
  8. package/dist/cjs/llm/bedrock/index.cjs.map +1 -1
  9. package/dist/cjs/llm/openrouter/index.cjs.map +1 -1
  10. package/dist/esm/llm/anthropic/index.mjs +4 -3
  11. package/dist/esm/llm/anthropic/index.mjs.map +1 -1
  12. package/dist/esm/llm/anthropic/utils/tools.mjs +2 -1
  13. package/dist/esm/llm/anthropic/utils/tools.mjs.map +1 -1
  14. package/dist/esm/llm/bedrock/cachePoints.mjs +28 -0
  15. package/dist/esm/llm/bedrock/cachePoints.mjs.map +1 -0
  16. package/dist/esm/llm/bedrock/index.mjs +10 -1
  17. package/dist/esm/llm/bedrock/index.mjs.map +1 -1
  18. package/dist/esm/llm/openrouter/index.mjs.map +1 -1
  19. package/dist/types/llm/anthropic/utils/tools.d.ts +1 -1
  20. package/dist/types/llm/bedrock/cachePoints.d.ts +9 -0
  21. package/package.json +16 -21
  22. package/src/llm/anthropic/index.ts +13 -2
  23. package/src/llm/anthropic/inherited-content-utils.spec.ts +244 -0
  24. package/src/llm/anthropic/inherited-stream-events.spec.ts +752 -0
  25. package/src/llm/anthropic/inherited-strict.spec.ts +302 -0
  26. package/src/llm/anthropic/llm.spec.ts +65 -0
  27. package/src/llm/anthropic/utils/tools.ts +7 -1
  28. package/src/llm/bedrock/cachePoints.ts +86 -0
  29. package/src/llm/bedrock/index.ts +9 -0
  30. package/src/llm/bedrock/inherited-cache.spec.ts +144 -0
  31. package/src/llm/bedrock/inherited.spec.ts +724 -0
  32. package/src/llm/google/inherited-stream-events.spec.ts +350 -0
  33. package/src/llm/openai/inherited-deepseek.spec.ts +347 -0
  34. package/src/llm/openai/inherited-xai.spec.ts +416 -0
  35. package/src/llm/openai/llm.spec.ts +1568 -0
  36. package/src/llm/openrouter/index.ts +1 -3
  37. package/src/llm/vertexai/inherited-stream-events.spec.ts +271 -0
@@ -0,0 +1,752 @@
1
+ // Inherited from @langchain/anthropic@1.5.1
2
+ // tests/chat_models_stream_events.test.ts
3
+ //
4
+ // Exercises the native content-block streaming protocol (`ChatModelStreamEvent`)
5
+ // that `BaseChatModel.streamEvents()` / `_streamChatModelEvents()` expose as typed
6
+ // sub-streams (`.text` / `.reasoning` / `.toolCalls` / `.usage` / `.output`).
7
+ //
8
+ // Applicability to our fork:
9
+ // Our `CustomAnthropic` (imported here as `ChatAnthropic` from `@/llm/anthropic`)
10
+ // extends upstream `ChatAnthropicMessages` (1.5.1). It overrides the LEGACY
11
+ // `_streamResponseChunks` path (used by `.stream()` / `.invoke()`), but does NOT
12
+ // override `_streamChatModelEvents`. That native method is therefore inherited
13
+ // verbatim: it calls `invocationParams` -> `_convertMessagesToAnthropicPayload`
14
+ // -> `createStreamWithRetry` -> `convertAnthropicStream`. Our `createStreamWithRetry`
15
+ // override only strips unsupported assistant prefill before delegating to super, so
16
+ // mocking it (as upstream's `MockStreamChatAnthropic` does) drives the real native
17
+ // conversion path. A probe confirmed the lifecycle/sub-streams resolve correctly.
18
+ //
19
+ // Adaptation:
20
+ // - vitest -> jest (`@jest/globals`; `vi.*` -> `jest.*`).
21
+ // - Import the class from `@/llm/anthropic` (our fork), not `@langchain/anthropic`.
22
+ // - Feed canned SSE events by overriding `createStreamWithRetry` (mirrors
23
+ // `MockStreamingAnthropic` in llm.spec.ts), keeping these UNIT tests with a
24
+ // mocked transport — no live API call.
25
+ // - Upstream's final `streaming events` describe used vitest custom matchers
26
+ // (`toHaveStreamText` / `toHaveStreamReasoning` / `toHaveStreamToolCalls`), which
27
+ // do not exist in jest. They are re-expressed against the public `ChatModelStream`
28
+ // sub-streams those matchers wrap.
29
+
30
+ import { test, expect, describe } from '@jest/globals';
31
+ import { HumanMessage } from '@langchain/core/messages';
32
+ import { ChatModelStream } from '@langchain/core/language_models/stream';
33
+ import type { BaseChatModelCallOptions } from '@langchain/core/language_models/chat_models';
34
+ import type { ChatModelStreamEvent } from '@langchain/core/language_models/event';
35
+ import type { Stream } from '@anthropic-ai/sdk/streaming';
36
+ import type {
37
+ AnthropicMessageStreamEvent,
38
+ AnthropicRequestOptions,
39
+ AnthropicStreamingMessageCreateParams,
40
+ } from './types';
41
+ import type { CustomAnthropicCallOptions } from './index';
42
+ import { CustomAnthropic as ChatAnthropic } from './index';
43
+
44
+ // ─── Mock model ─────────────────────────────────────────────────
45
+ //
46
+ // Subclass that overrides `createStreamWithRetry` to yield a canned async
47
+ // iterable of native Anthropic SSE events, capturing the constructed request
48
+ // so we can assert on top-level request fields.
49
+
50
+ class MockStreamChatAnthropic extends ChatAnthropic {
51
+ private mockEvents: unknown[];
52
+
53
+ capturedRequest: AnthropicStreamingMessageCreateParams | undefined;
54
+
55
+ constructor(mockEvents: unknown[]) {
56
+ super({ apiKey: 'fake-key', model: 'claude-sonnet-4-20250514' });
57
+ this.mockEvents = mockEvents;
58
+ }
59
+
60
+ protected override async createStreamWithRetry(
61
+ request: AnthropicStreamingMessageCreateParams,
62
+ _options?: AnthropicRequestOptions
63
+ ): Promise<Stream<AnthropicMessageStreamEvent>> {
64
+ this.capturedRequest = request;
65
+ const events = this.mockEvents;
66
+ return {
67
+ controller: { abort: (): void => {} },
68
+ async *[Symbol.asyncIterator](): AsyncGenerator<AnthropicMessageStreamEvent> {
69
+ for (const event of events) {
70
+ yield event as AnthropicMessageStreamEvent;
71
+ }
72
+ },
73
+ } as unknown as Stream<AnthropicMessageStreamEvent>;
74
+ }
75
+ }
76
+
77
+ type StreamEventsModel = {
78
+ _streamChatModelEvents: (
79
+ messages: unknown[],
80
+ options: BaseChatModelCallOptions
81
+ ) => AsyncGenerator<ChatModelStreamEvent>;
82
+ };
83
+
84
+ function streamEvents(
85
+ model: ChatAnthropic,
86
+ options: BaseChatModelCallOptions = {} as BaseChatModelCallOptions
87
+ ): AsyncGenerator<ChatModelStreamEvent> {
88
+ return (model as unknown as StreamEventsModel)._streamChatModelEvents(
89
+ [],
90
+ options
91
+ );
92
+ }
93
+
94
+ async function collectEvents(
95
+ model: ChatAnthropic,
96
+ options?: BaseChatModelCallOptions
97
+ ): Promise<ChatModelStreamEvent[]> {
98
+ const events: ChatModelStreamEvent[] = [];
99
+ for await (const event of streamEvents(model, options)) {
100
+ events.push(event);
101
+ }
102
+ return events;
103
+ }
104
+
105
+ // ─── Fixtures (native Anthropic SSE events) ─────────────────────
106
+
107
+ function textOnlyEvents(): unknown[] {
108
+ return [
109
+ {
110
+ type: 'message_start',
111
+ message: {
112
+ id: 'msg_01ABC',
113
+ type: 'message',
114
+ role: 'assistant',
115
+ content: [],
116
+ model: 'claude-sonnet-4-20250514',
117
+ stop_reason: null,
118
+ stop_sequence: null,
119
+ usage: { input_tokens: 25, output_tokens: 0 },
120
+ },
121
+ },
122
+ {
123
+ type: 'content_block_start',
124
+ index: 0,
125
+ content_block: { type: 'text', text: '' },
126
+ },
127
+ {
128
+ type: 'content_block_delta',
129
+ index: 0,
130
+ delta: { type: 'text_delta', text: 'Hello' },
131
+ },
132
+ {
133
+ type: 'content_block_delta',
134
+ index: 0,
135
+ delta: { type: 'text_delta', text: ' world' },
136
+ },
137
+ { type: 'content_block_stop', index: 0 },
138
+ {
139
+ type: 'message_delta',
140
+ delta: { stop_reason: 'end_turn', stop_sequence: null },
141
+ usage: { output_tokens: 2 },
142
+ },
143
+ { type: 'message_stop' },
144
+ ];
145
+ }
146
+
147
+ function thinkingPlusTextEvents(): unknown[] {
148
+ return [
149
+ {
150
+ type: 'message_start',
151
+ message: {
152
+ id: 'msg_02DEF',
153
+ type: 'message',
154
+ role: 'assistant',
155
+ content: [],
156
+ model: 'claude-sonnet-4-20250514',
157
+ stop_reason: null,
158
+ stop_sequence: null,
159
+ usage: { input_tokens: 50, output_tokens: 0 },
160
+ },
161
+ },
162
+ {
163
+ type: 'content_block_start',
164
+ index: 0,
165
+ content_block: { type: 'thinking', thinking: '' },
166
+ },
167
+ {
168
+ type: 'content_block_delta',
169
+ index: 0,
170
+ delta: { type: 'thinking_delta', thinking: 'Let me' },
171
+ },
172
+ {
173
+ type: 'content_block_delta',
174
+ index: 0,
175
+ delta: { type: 'thinking_delta', thinking: ' reason...' },
176
+ },
177
+ {
178
+ type: 'content_block_delta',
179
+ index: 0,
180
+ delta: { type: 'signature_delta', signature: 'sig_abc' },
181
+ },
182
+ { type: 'content_block_stop', index: 0 },
183
+ {
184
+ type: 'content_block_start',
185
+ index: 1,
186
+ content_block: { type: 'text', text: '' },
187
+ },
188
+ {
189
+ type: 'content_block_delta',
190
+ index: 1,
191
+ delta: { type: 'text_delta', text: 'The answer is 42.' },
192
+ },
193
+ { type: 'content_block_stop', index: 1 },
194
+ {
195
+ type: 'message_delta',
196
+ delta: { stop_reason: 'end_turn', stop_sequence: null },
197
+ usage: { output_tokens: 20 },
198
+ },
199
+ { type: 'message_stop' },
200
+ ];
201
+ }
202
+
203
+ function toolCallEvents(): unknown[] {
204
+ return [
205
+ {
206
+ type: 'message_start',
207
+ message: {
208
+ id: 'msg_03GHI',
209
+ type: 'message',
210
+ role: 'assistant',
211
+ content: [],
212
+ model: 'claude-sonnet-4-20250514',
213
+ stop_reason: null,
214
+ stop_sequence: null,
215
+ usage: { input_tokens: 100, output_tokens: 0 },
216
+ },
217
+ },
218
+ {
219
+ type: 'content_block_start',
220
+ index: 0,
221
+ content_block: { type: 'text', text: '' },
222
+ },
223
+ {
224
+ type: 'content_block_delta',
225
+ index: 0,
226
+ delta: { type: 'text_delta', text: 'Let me search.' },
227
+ },
228
+ { type: 'content_block_stop', index: 0 },
229
+ {
230
+ type: 'content_block_start',
231
+ index: 1,
232
+ content_block: {
233
+ type: 'tool_use',
234
+ id: 'toolu_01ABC',
235
+ name: 'web_search',
236
+ },
237
+ },
238
+ {
239
+ type: 'content_block_delta',
240
+ index: 1,
241
+ delta: { type: 'input_json_delta', partial_json: '{"query"' },
242
+ },
243
+ {
244
+ type: 'content_block_delta',
245
+ index: 1,
246
+ delta: { type: 'input_json_delta', partial_json: ':"weather"}' },
247
+ },
248
+ { type: 'content_block_stop', index: 1 },
249
+ {
250
+ type: 'message_delta',
251
+ delta: { stop_reason: 'tool_use', stop_sequence: null },
252
+ usage: { output_tokens: 15 },
253
+ },
254
+ { type: 'message_stop' },
255
+ ];
256
+ }
257
+
258
+ function cacheUsageEvents(): unknown[] {
259
+ return [
260
+ {
261
+ type: 'message_start',
262
+ message: {
263
+ id: 'msg_04JKL',
264
+ type: 'message',
265
+ role: 'assistant',
266
+ content: [],
267
+ model: 'claude-sonnet-4-20250514',
268
+ stop_reason: null,
269
+ stop_sequence: null,
270
+ usage: {
271
+ input_tokens: 100,
272
+ output_tokens: 0,
273
+ cache_creation_input_tokens: 500,
274
+ cache_read_input_tokens: 200,
275
+ },
276
+ },
277
+ },
278
+ {
279
+ type: 'content_block_start',
280
+ index: 0,
281
+ content_block: { type: 'text', text: '' },
282
+ },
283
+ {
284
+ type: 'content_block_delta',
285
+ index: 0,
286
+ delta: { type: 'text_delta', text: 'Cached response' },
287
+ },
288
+ { type: 'content_block_stop', index: 0 },
289
+ {
290
+ type: 'message_delta',
291
+ delta: { stop_reason: 'end_turn', stop_sequence: null },
292
+ usage: { output_tokens: 3 },
293
+ },
294
+ { type: 'message_stop' },
295
+ ];
296
+ }
297
+
298
+ // ─── Tests ───────────────────────────────────────────────────────
299
+
300
+ describe('CustomAnthropic._streamChatModelEvents (inherited native)', () => {
301
+ describe('text-only streaming', () => {
302
+ test('passes invocation cache_control as a top-level request field', async () => {
303
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
304
+ const callOptions = {
305
+ cache_control: { type: 'ephemeral' as const, ttl: '1h' as const },
306
+ };
307
+ for await (const _event of (
308
+ model as unknown as StreamEventsModel
309
+ )._streamChatModelEvents(
310
+ [new HumanMessage('hello')],
311
+ callOptions as unknown as BaseChatModelCallOptions
312
+ )) {
313
+ // Consume stream to trigger request construction.
314
+ }
315
+
316
+ expect(model.capturedRequest).toBeDefined();
317
+ expect(model.capturedRequest?.cache_control).toEqual(
318
+ callOptions.cache_control
319
+ );
320
+ expect(JSON.stringify(model.capturedRequest?.messages)).not.toContain(
321
+ '"cache_control"'
322
+ );
323
+ });
324
+
325
+ test('emits correct lifecycle events', async () => {
326
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
327
+ const eventNames = (
328
+ await collectEvents(model, {
329
+ streamUsage: true,
330
+ } as BaseChatModelCallOptions)
331
+ ).map((e) => e.event);
332
+
333
+ expect(eventNames).toContain('message-start');
334
+ expect(eventNames).toContain('content-block-start');
335
+ expect(eventNames).toContain('content-block-delta');
336
+ expect(eventNames).toContain('content-block-finish');
337
+ expect(eventNames).toContain('message-finish');
338
+ });
339
+
340
+ test('message-start carries id and usage', async () => {
341
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
342
+ const events = await collectEvents(model, {
343
+ streamUsage: true,
344
+ } as BaseChatModelCallOptions);
345
+
346
+ const start = events.find((e) => e.event === 'message-start');
347
+ expect(start).toBeDefined();
348
+ expect((start as { id?: string }).id).toBe('msg_01ABC');
349
+ expect(
350
+ (start as { usage?: { input_tokens: number } }).usage?.input_tokens
351
+ ).toBe(25);
352
+ });
353
+
354
+ test('text deltas accumulate correctly', async () => {
355
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
356
+ const events = await collectEvents(model, {
357
+ streamUsage: true,
358
+ } as BaseChatModelCallOptions);
359
+
360
+ const deltas = events.filter(
361
+ (e) =>
362
+ e.event === 'content-block-delta' && 'index' in e && e.index === 0
363
+ );
364
+ expect(deltas.length).toBe(2);
365
+
366
+ const d1 = deltas[0] as { delta: { type: string; text?: string } };
367
+ expect(d1.delta.type).toBe('text-delta');
368
+ expect(d1.delta.text).toBe('Hello');
369
+
370
+ const d2 = deltas[1] as { delta: { type: string; text?: string } };
371
+ expect(d2.delta.type).toBe('text-delta');
372
+ expect(d2.delta.text).toBe(' world');
373
+ });
374
+
375
+ test('content-block-finish carries finalized text', async () => {
376
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
377
+ const events = await collectEvents(model);
378
+
379
+ expect(
380
+ events.find((e) => e.event === 'content-block-finish' && e.index === 0)
381
+ ).toMatchObject({
382
+ content: { type: 'text', text: 'Hello world' },
383
+ });
384
+ });
385
+
386
+ test('message-finish carries stop reason', async () => {
387
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
388
+ const events = await collectEvents(model);
389
+
390
+ const finish = events.find((e) => e.event === 'message-finish') as {
391
+ reason: string;
392
+ };
393
+ expect(finish.reason).toBe('stop');
394
+ });
395
+ });
396
+
397
+ describe('thinking + text streaming', () => {
398
+ test('reasoning block accumulates correctly', async () => {
399
+ const model = new MockStreamChatAnthropic(thinkingPlusTextEvents());
400
+ const events = await collectEvents(model, {
401
+ streamUsage: true,
402
+ } as BaseChatModelCallOptions);
403
+
404
+ const reasoningDeltas = events.filter(
405
+ (e) =>
406
+ e.event === 'content-block-delta' &&
407
+ 'index' in e &&
408
+ e.index === 0 &&
409
+ 'delta' in e &&
410
+ (e.delta as { type: string }).type === 'reasoning-delta'
411
+ );
412
+ expect(reasoningDeltas.length).toBe(2);
413
+
414
+ const rd1 = reasoningDeltas[0] as {
415
+ delta: { type: string; reasoning: string };
416
+ };
417
+ expect(rd1.delta.reasoning).toBe('Let me');
418
+
419
+ const rd2 = reasoningDeltas[1] as {
420
+ delta: { type: string; reasoning: string };
421
+ };
422
+ expect(rd2.delta.reasoning).toBe(' reason...');
423
+
424
+ expect(
425
+ events.find((e) => e.event === 'content-block-finish' && e.index === 0)
426
+ ).toMatchObject({
427
+ content: { type: 'reasoning', reasoning: 'Let me reason...' },
428
+ });
429
+ });
430
+
431
+ test('text block follows reasoning with correct index', async () => {
432
+ const model = new MockStreamChatAnthropic(thinkingPlusTextEvents());
433
+ const events = await collectEvents(model);
434
+
435
+ expect(
436
+ events.find((e) => e.event === 'content-block-finish' && e.index === 1)
437
+ ).toMatchObject({
438
+ content: { type: 'text', text: 'The answer is 42.' },
439
+ });
440
+ });
441
+
442
+ test('signature delta is handled as non_standard', async () => {
443
+ const model = new MockStreamChatAnthropic(thinkingPlusTextEvents());
444
+ const events = await collectEvents(model);
445
+
446
+ const sigDelta = events.find(
447
+ (e) =>
448
+ e.event === 'content-block-delta' &&
449
+ 'delta' in e &&
450
+ (e.delta as { fields?: { signature?: string } }).fields?.signature ===
451
+ 'sig_abc'
452
+ ) as { delta: { type: string; fields?: { signature?: string } } };
453
+ expect(sigDelta).toBeDefined();
454
+ expect(sigDelta.delta.type).toBe('block-delta');
455
+ expect(sigDelta.delta.fields?.signature).toBe('sig_abc');
456
+ });
457
+ });
458
+
459
+ describe('tool call streaming', () => {
460
+ test('tool call args accumulate correctly', async () => {
461
+ const model = new MockStreamChatAnthropic(toolCallEvents());
462
+ const events = await collectEvents(model);
463
+
464
+ expect(
465
+ events.find((e) => e.event === 'content-block-start' && e.index === 1)
466
+ ).toMatchObject({
467
+ content: {
468
+ type: 'tool_call_chunk',
469
+ name: 'web_search',
470
+ id: 'toolu_01ABC',
471
+ },
472
+ });
473
+
474
+ const toolDeltas = events.filter(
475
+ (e) =>
476
+ e.event === 'content-block-delta' && 'index' in e && e.index === 1
477
+ );
478
+ expect(toolDeltas.length).toBe(2);
479
+
480
+ const td1 = toolDeltas[0] as {
481
+ delta: { type: string; fields: { type: string; args?: string } };
482
+ };
483
+ expect(td1.delta.type).toBe('block-delta');
484
+ expect(td1.delta.fields.type).toBe('tool_call_chunk');
485
+ expect(td1.delta.fields.args).toBe('{"query"');
486
+
487
+ const td2 = toolDeltas[1] as {
488
+ delta: { type: string; fields: { type: string; args?: string } };
489
+ };
490
+ expect(td2.delta.type).toBe('block-delta');
491
+ expect(td2.delta.fields.type).toBe('tool_call_chunk');
492
+ expect(td2.delta.fields.args).toBe('{"query":"weather"}');
493
+ });
494
+
495
+ test('tool call finish has parsed args', async () => {
496
+ const model = new MockStreamChatAnthropic(toolCallEvents());
497
+ const events = await collectEvents(model);
498
+
499
+ expect(
500
+ events.find((e) => e.event === 'content-block-finish' && e.index === 1)
501
+ ).toMatchObject({
502
+ content: {
503
+ type: 'tool_call',
504
+ name: 'web_search',
505
+ id: 'toolu_01ABC',
506
+ args: { query: 'weather' },
507
+ },
508
+ });
509
+ });
510
+
511
+ test('message-finish has tool_use reason', async () => {
512
+ const model = new MockStreamChatAnthropic(toolCallEvents());
513
+ const events = await collectEvents(model);
514
+
515
+ const finish = events.find((e) => e.event === 'message-finish') as {
516
+ reason: string;
517
+ };
518
+ expect(finish.reason).toBe('tool_use');
519
+ });
520
+ });
521
+
522
+ describe('usage streaming', () => {
523
+ test('usage snapshot with cache details', async () => {
524
+ const model = new MockStreamChatAnthropic(cacheUsageEvents());
525
+ const events = await collectEvents(model, {
526
+ streamUsage: true,
527
+ } as BaseChatModelCallOptions);
528
+
529
+ const start = events.find((e) => e.event === 'message-start') as {
530
+ usage: {
531
+ input_tokens: number;
532
+ input_token_details: {
533
+ cache_creation: number;
534
+ cache_read: number;
535
+ };
536
+ };
537
+ };
538
+ // 100 + 500 + 200 = 800
539
+ expect(start.usage.input_tokens).toBe(800);
540
+ expect(start.usage.input_token_details.cache_creation).toBe(500);
541
+ expect(start.usage.input_token_details.cache_read).toBe(200);
542
+ });
543
+
544
+ test('usage event emitted on message_delta', async () => {
545
+ const model = new MockStreamChatAnthropic(cacheUsageEvents());
546
+ const events = await collectEvents(model, {
547
+ streamUsage: true,
548
+ } as BaseChatModelCallOptions);
549
+
550
+ const usageEvents = events.filter((e) => e.event === 'usage');
551
+ expect(usageEvents.length).toBeGreaterThanOrEqual(1);
552
+
553
+ const lastUsage = usageEvents[usageEvents.length - 1] as {
554
+ usage: { output_tokens: number };
555
+ };
556
+ expect(lastUsage.usage.output_tokens).toBe(3);
557
+ });
558
+
559
+ test('message-finish carries final usage', async () => {
560
+ const model = new MockStreamChatAnthropic(cacheUsageEvents());
561
+ const events = await collectEvents(model, {
562
+ streamUsage: true,
563
+ } as BaseChatModelCallOptions);
564
+
565
+ const finish = events.find((e) => e.event === 'message-finish') as {
566
+ usage: { input_tokens: number; output_tokens: number };
567
+ };
568
+ expect(finish.usage.input_tokens).toBe(800);
569
+ expect(finish.usage.output_tokens).toBe(3);
570
+ });
571
+
572
+ test('no usage events when streamUsage is false', async () => {
573
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
574
+ model.streamUsage = false;
575
+ const events = await collectEvents(model, {
576
+ streamUsage: false,
577
+ } as BaseChatModelCallOptions);
578
+
579
+ const usageEvents = events.filter((e) => e.event === 'usage');
580
+ expect(usageEvents.length).toBe(0);
581
+
582
+ const start = events.find((e) => e.event === 'message-start') as {
583
+ usage?: unknown;
584
+ };
585
+ expect(start.usage).toBeUndefined();
586
+ });
587
+ });
588
+
589
+ describe('provider passthrough', () => {
590
+ test('message_start metadata is forwarded as provider event', async () => {
591
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
592
+ const events = await collectEvents(model);
593
+
594
+ const providerEvents = events.filter((e) => e.event === 'provider');
595
+ const metaEvent = providerEvents.find(
596
+ (e) => (e as { name: string }).name === 'message_start'
597
+ ) as { provider: string; payload: { model: string; id: string } };
598
+ expect(metaEvent).toBeDefined();
599
+ expect(metaEvent.provider).toBe('anthropic');
600
+ expect(metaEvent.payload.model).toBe('claude-sonnet-4-20250514');
601
+ expect(metaEvent.payload.id).toBe('msg_01ABC');
602
+ });
603
+
604
+ test('unknown events are forwarded as provider events', async () => {
605
+ const eventsWithPing = [
606
+ ...textOnlyEvents().slice(0, -1), // everything except message_stop
607
+ { type: 'ping' },
608
+ textOnlyEvents().slice(-1)[0], // message_stop
609
+ ];
610
+ const model = new MockStreamChatAnthropic(eventsWithPing);
611
+ const events = await collectEvents(model);
612
+
613
+ const pingEvent = events.find(
614
+ (e) => e.event === 'provider' && (e as { name: string }).name === 'ping'
615
+ );
616
+ expect(pingEvent).toBeDefined();
617
+ });
618
+ });
619
+
620
+ describe('integration with ChatModelStream', () => {
621
+ test('text sub-stream works end-to-end', async () => {
622
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
623
+ const stream = new ChatModelStream(streamEvents(model));
624
+ const text = await stream.text;
625
+ expect(text).toBe('Hello world');
626
+ });
627
+
628
+ test('toolCalls sub-stream works end-to-end', async () => {
629
+ const model = new MockStreamChatAnthropic(toolCallEvents());
630
+ const stream = new ChatModelStream(streamEvents(model));
631
+ const calls = await stream.toolCalls;
632
+ expect(calls.length).toBe(1);
633
+ expect(calls[0]!.name).toBe('web_search');
634
+ expect(calls[0]!.args).toEqual({ query: 'weather' });
635
+ });
636
+
637
+ test('reasoning sub-stream works end-to-end', async () => {
638
+ const model = new MockStreamChatAnthropic(thinkingPlusTextEvents());
639
+ const stream = new ChatModelStream(streamEvents(model));
640
+ const reasoning = await stream.reasoning;
641
+ expect(reasoning).toBe('Let me reason...');
642
+ });
643
+
644
+ test('output assembles correct AIMessage', async () => {
645
+ const model = new MockStreamChatAnthropic(toolCallEvents());
646
+ const stream = new ChatModelStream(streamEvents(model));
647
+ const message = await stream.output;
648
+
649
+ expect(message.id).toBe('msg_03GHI');
650
+ expect(message._getType()).toBe('ai');
651
+
652
+ const content = message.content as Array<{
653
+ type: string;
654
+ text?: string;
655
+ name?: string;
656
+ args?: unknown;
657
+ }>;
658
+
659
+ expect(content.length).toBe(2);
660
+ expect(content[0]!.type).toBe('text');
661
+ expect(content[0]!.text).toBe('Let me search.');
662
+ expect(content[1]!.type).toBe('tool_call');
663
+ expect(content[1]!.name).toBe('web_search');
664
+ expect(content[1]!.args).toEqual({ query: 'weather' });
665
+
666
+ expect(message.tool_calls?.length).toBe(1);
667
+ expect(message.tool_calls?.[0]?.name).toBe('web_search');
668
+ });
669
+
670
+ test('usage sub-stream works end-to-end', async () => {
671
+ const model = new MockStreamChatAnthropic(cacheUsageEvents());
672
+ const stream = new ChatModelStream(
673
+ streamEvents(model, { streamUsage: true } as BaseChatModelCallOptions)
674
+ );
675
+ const usage = await stream.usage;
676
+ expect(usage?.input_tokens).toBe(800);
677
+ expect(usage?.output_tokens).toBe(3);
678
+ });
679
+
680
+ test('await stream returns AIMessage directly', async () => {
681
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
682
+ const message = await new ChatModelStream(streamEvents(model));
683
+ expect(message._getType()).toBe('ai');
684
+ expect(message.id).toBe('msg_01ABC');
685
+ });
686
+
687
+ test('sequential sub-stream consumption', async () => {
688
+ const model = new MockStreamChatAnthropic(toolCallEvents());
689
+ const stream = new ChatModelStream(streamEvents(model));
690
+
691
+ const text = await stream.text;
692
+ expect(text).toBe('Let me search.');
693
+
694
+ const tools = await stream.toolCalls;
695
+ expect(tools.length).toBe(1);
696
+ expect(tools[0]!.name).toBe('web_search');
697
+ });
698
+
699
+ test('parallel sub-stream consumption from events', async () => {
700
+ const model = new MockStreamChatAnthropic(toolCallEvents());
701
+ const events = await collectEvents(model);
702
+
703
+ async function* replay(): AsyncGenerator<ChatModelStreamEvent> {
704
+ for (const e of events) {
705
+ yield e;
706
+ }
707
+ }
708
+ const stream = new ChatModelStream(replay());
709
+
710
+ const [text, tools] = await Promise.all([stream.text, stream.toolCalls]);
711
+
712
+ expect(text).toBe('Let me search.');
713
+ expect(tools.length).toBe(1);
714
+ expect(tools[0]!.name).toBe('web_search');
715
+ });
716
+ });
717
+
718
+ // Re-expressed from upstream's `streaming events` describe, which used vitest
719
+ // custom matchers (`toHaveStreamText` / `toHaveStreamReasoning` /
720
+ // `toHaveStreamToolCalls`) that do not exist in jest. The matchers wrap the
721
+ // `ChatModelStream` sub-streams asserted here directly. `model.streamEvents(...)`
722
+ // is the inherited public entry; we drive it via the internal generator to keep
723
+ // the mocked transport in play.
724
+ describe('streaming events (sub-stream assertions)', () => {
725
+ test('streams text', async () => {
726
+ const model = new MockStreamChatAnthropic(textOnlyEvents());
727
+ const stream = new ChatModelStream(streamEvents(model));
728
+ expect(await stream.text).toBe('Hello world');
729
+ });
730
+
731
+ test('streams reasoning', async () => {
732
+ const model = new MockStreamChatAnthropic(thinkingPlusTextEvents());
733
+ const stream = new ChatModelStream(streamEvents(model));
734
+ expect(await stream.reasoning).toBe('Let me reason...');
735
+ });
736
+
737
+ test('streams tool calls', async () => {
738
+ const model = new MockStreamChatAnthropic(toolCallEvents());
739
+ const stream = new ChatModelStream(streamEvents(model));
740
+ const calls = await stream.toolCalls;
741
+ expect(calls).toEqual([
742
+ expect.objectContaining({
743
+ name: 'web_search',
744
+ args: { query: 'weather' },
745
+ }),
746
+ ]);
747
+ });
748
+ });
749
+
750
+ // Dropped (inherited): live — upstream had no live-API cases in this file; every
751
+ // case is unit-testable with the mocked transport above.
752
+ });