@ai-sdk/openai-compatible 2.0.15 → 2.0.17

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 (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +5 -0
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.js +23 -6
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +23 -6
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +3 -2
  9. package/src/chat/convert-openai-compatible-chat-usage.ts +55 -0
  10. package/src/chat/convert-to-openai-compatible-chat-messages.test.ts +1238 -0
  11. package/src/chat/convert-to-openai-compatible-chat-messages.ts +246 -0
  12. package/src/chat/get-response-metadata.ts +15 -0
  13. package/src/chat/map-openai-compatible-finish-reason.ts +19 -0
  14. package/src/chat/openai-compatible-api-types.ts +86 -0
  15. package/src/chat/openai-compatible-chat-language-model.test.ts +3292 -0
  16. package/src/chat/openai-compatible-chat-language-model.ts +830 -0
  17. package/src/chat/openai-compatible-chat-options.ts +34 -0
  18. package/src/chat/openai-compatible-metadata-extractor.ts +48 -0
  19. package/src/chat/openai-compatible-prepare-tools.test.ts +336 -0
  20. package/src/chat/openai-compatible-prepare-tools.ts +98 -0
  21. package/src/completion/convert-openai-compatible-completion-usage.ts +46 -0
  22. package/src/completion/convert-to-openai-compatible-completion-prompt.ts +93 -0
  23. package/src/completion/get-response-metadata.ts +15 -0
  24. package/src/completion/map-openai-compatible-finish-reason.ts +19 -0
  25. package/src/completion/openai-compatible-completion-language-model.test.ts +773 -0
  26. package/src/completion/openai-compatible-completion-language-model.ts +390 -0
  27. package/src/completion/openai-compatible-completion-options.ts +33 -0
  28. package/src/embedding/openai-compatible-embedding-model.test.ts +171 -0
  29. package/src/embedding/openai-compatible-embedding-model.ts +166 -0
  30. package/src/embedding/openai-compatible-embedding-options.ts +21 -0
  31. package/src/image/openai-compatible-image-model.test.ts +494 -0
  32. package/src/image/openai-compatible-image-model.ts +205 -0
  33. package/src/image/openai-compatible-image-settings.ts +1 -0
  34. package/src/index.ts +27 -0
  35. package/src/internal/index.ts +4 -0
  36. package/src/openai-compatible-error.ts +30 -0
  37. package/src/openai-compatible-provider.test.ts +329 -0
  38. package/src/openai-compatible-provider.ts +189 -0
  39. package/src/version.ts +5 -0
@@ -0,0 +1,3292 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { LanguageModelV3Prompt } from '@ai-sdk/provider';
3
+ import { createTestServer } from '@ai-sdk/test-server/with-vitest';
4
+ import {
5
+ convertReadableStreamToArray,
6
+ isNodeVersion,
7
+ } from '@ai-sdk/provider-utils/test';
8
+ import { createOpenAICompatible } from '../openai-compatible-provider';
9
+ import { OpenAICompatibleChatLanguageModel } from './openai-compatible-chat-language-model';
10
+
11
+ const TEST_PROMPT: LanguageModelV3Prompt = [
12
+ { role: 'user', content: [{ type: 'text', text: 'Hello' }] },
13
+ ];
14
+
15
+ const provider = createOpenAICompatible({
16
+ baseURL: 'https://my.api.com/v1/',
17
+ name: 'test-provider',
18
+ headers: {
19
+ Authorization: `Bearer test-api-key`,
20
+ },
21
+ });
22
+
23
+ const model = provider('grok-beta');
24
+
25
+ const server = createTestServer({
26
+ 'https://my.api.com/v1/chat/completions': {},
27
+ });
28
+
29
+ describe('config', () => {
30
+ it('should extract base name from provider string', () => {
31
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4', {
32
+ provider: 'anthropic.beta',
33
+ url: () => '',
34
+ headers: () => ({}),
35
+ });
36
+
37
+ expect(model['providerOptionsName']).toBe('anthropic');
38
+ });
39
+
40
+ it('should handle provider without dot notation', () => {
41
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4', {
42
+ provider: 'openai',
43
+ url: () => '',
44
+ headers: () => ({}),
45
+ });
46
+
47
+ expect(model['providerOptionsName']).toBe('openai');
48
+ });
49
+
50
+ it('should return empty for empty provider', () => {
51
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4', {
52
+ provider: '',
53
+ url: () => '',
54
+ headers: () => ({}),
55
+ });
56
+
57
+ expect(model['providerOptionsName']).toBe('');
58
+ });
59
+ });
60
+
61
+ describe('doGenerate', () => {
62
+ function prepareJsonResponse({
63
+ content = '',
64
+ reasoning_content = '',
65
+ reasoning = '',
66
+ tool_calls,
67
+ function_call,
68
+ usage = {
69
+ prompt_tokens: 4,
70
+ total_tokens: 34,
71
+ completion_tokens: 30,
72
+ },
73
+ finish_reason = 'stop',
74
+ id = 'chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd',
75
+ created = 1711115037,
76
+ model = 'grok-beta',
77
+ headers,
78
+ }: {
79
+ content?: string;
80
+ reasoning_content?: string;
81
+ reasoning?: string;
82
+ tool_calls?: Array<{
83
+ id: string;
84
+ type: 'function';
85
+ function: {
86
+ name: string;
87
+ arguments: string;
88
+ };
89
+ extra_content?: {
90
+ google?: {
91
+ thought_signature?: string;
92
+ };
93
+ };
94
+ }>;
95
+ function_call?: {
96
+ name: string;
97
+ arguments: string;
98
+ };
99
+ usage?: {
100
+ prompt_tokens?: number;
101
+ total_tokens?: number;
102
+ completion_tokens?: number;
103
+ prompt_tokens_details?: {
104
+ cached_tokens?: number;
105
+ };
106
+ completion_tokens_details?: {
107
+ reasoning_tokens?: number;
108
+ accepted_prediction_tokens?: number;
109
+ rejected_prediction_tokens?: number;
110
+ };
111
+ };
112
+ finish_reason?: string;
113
+ created?: number;
114
+ id?: string;
115
+ model?: string;
116
+ headers?: Record<string, string>;
117
+ } = {}) {
118
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
119
+ type: 'json-value',
120
+ headers,
121
+ body: {
122
+ id,
123
+ object: 'chat.completion',
124
+ created,
125
+ model,
126
+ choices: [
127
+ {
128
+ index: 0,
129
+ message: {
130
+ role: 'assistant',
131
+ content,
132
+ reasoning_content,
133
+ reasoning,
134
+ tool_calls,
135
+ function_call,
136
+ },
137
+ finish_reason,
138
+ },
139
+ ],
140
+ usage,
141
+ system_fingerprint: 'fp_3bc1b5746c',
142
+ },
143
+ };
144
+ }
145
+
146
+ it('should pass user setting to requests', async () => {
147
+ prepareJsonResponse({ content: 'Hello, World!' });
148
+ const modelWithUser = provider('grok-beta');
149
+ await modelWithUser.doGenerate({
150
+ prompt: TEST_PROMPT,
151
+ providerOptions: {
152
+ xai: {
153
+ user: 'test-user-id',
154
+ },
155
+ },
156
+ });
157
+ expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(`
158
+ {
159
+ "messages": [
160
+ {
161
+ "content": "Hello",
162
+ "role": "user",
163
+ },
164
+ ],
165
+ "model": "grok-beta",
166
+ }
167
+ `);
168
+ });
169
+
170
+ it('should extract text response', async () => {
171
+ prepareJsonResponse({ content: 'Hello, World!' });
172
+
173
+ const { content } = await model.doGenerate({
174
+ prompt: TEST_PROMPT,
175
+ });
176
+
177
+ expect(content).toMatchInlineSnapshot(`
178
+ [
179
+ {
180
+ "text": "Hello, World!",
181
+ "type": "text",
182
+ },
183
+ ]
184
+ `);
185
+ });
186
+
187
+ it('should extract reasoning content', async () => {
188
+ prepareJsonResponse({
189
+ content: 'Hello, World!',
190
+ reasoning_content: 'This is the reasoning behind the response',
191
+ });
192
+
193
+ const { content } = await model.doGenerate({
194
+ prompt: TEST_PROMPT,
195
+ });
196
+
197
+ expect(content).toMatchInlineSnapshot(`
198
+ [
199
+ {
200
+ "text": "Hello, World!",
201
+ "type": "text",
202
+ },
203
+ {
204
+ "text": "This is the reasoning behind the response",
205
+ "type": "reasoning",
206
+ },
207
+ ]
208
+ `);
209
+ });
210
+
211
+ it('should extract reasoning from reasoning field when reasoning_content is not provided', async () => {
212
+ prepareJsonResponse({
213
+ content: 'Hello, World!',
214
+ reasoning: 'This is the reasoning from the reasoning field',
215
+ });
216
+
217
+ const { content } = await model.doGenerate({
218
+ prompt: TEST_PROMPT,
219
+ });
220
+
221
+ expect(content).toMatchInlineSnapshot(`
222
+ [
223
+ {
224
+ "text": "Hello, World!",
225
+ "type": "text",
226
+ },
227
+ ]
228
+ `);
229
+ });
230
+
231
+ it('should prefer reasoning_content over reasoning field when both are provided', async () => {
232
+ prepareJsonResponse({
233
+ content: 'Hello, World!',
234
+ reasoning_content: 'This is from reasoning_content',
235
+ reasoning: 'This is from reasoning field',
236
+ });
237
+
238
+ const { content } = await model.doGenerate({
239
+ prompt: TEST_PROMPT,
240
+ });
241
+
242
+ expect(content).toMatchInlineSnapshot(`
243
+ [
244
+ {
245
+ "text": "Hello, World!",
246
+ "type": "text",
247
+ },
248
+ {
249
+ "text": "This is from reasoning_content",
250
+ "type": "reasoning",
251
+ },
252
+ ]
253
+ `);
254
+ });
255
+
256
+ it('should extract usage', async () => {
257
+ prepareJsonResponse({
258
+ usage: { prompt_tokens: 20, total_tokens: 25, completion_tokens: 5 },
259
+ });
260
+
261
+ const { usage } = await model.doGenerate({
262
+ prompt: TEST_PROMPT,
263
+ });
264
+
265
+ expect(usage).toMatchInlineSnapshot(`
266
+ {
267
+ "inputTokens": {
268
+ "cacheRead": 0,
269
+ "cacheWrite": undefined,
270
+ "noCache": 20,
271
+ "total": 20,
272
+ },
273
+ "outputTokens": {
274
+ "reasoning": 0,
275
+ "text": 5,
276
+ "total": 5,
277
+ },
278
+ "raw": {
279
+ "completion_tokens": 5,
280
+ "prompt_tokens": 20,
281
+ "total_tokens": 25,
282
+ },
283
+ }
284
+ `);
285
+ });
286
+
287
+ it('should send additional response information', async () => {
288
+ prepareJsonResponse({
289
+ id: 'test-id',
290
+ created: 123,
291
+ model: 'test-model',
292
+ });
293
+
294
+ const { response } = await model.doGenerate({
295
+ prompt: TEST_PROMPT,
296
+ });
297
+
298
+ expect(response).toMatchInlineSnapshot(`
299
+ {
300
+ "body": {
301
+ "choices": [
302
+ {
303
+ "finish_reason": "stop",
304
+ "index": 0,
305
+ "message": {
306
+ "content": "",
307
+ "reasoning": "",
308
+ "reasoning_content": "",
309
+ "role": "assistant",
310
+ },
311
+ },
312
+ ],
313
+ "created": 123,
314
+ "id": "test-id",
315
+ "model": "test-model",
316
+ "object": "chat.completion",
317
+ "system_fingerprint": "fp_3bc1b5746c",
318
+ "usage": {
319
+ "completion_tokens": 30,
320
+ "prompt_tokens": 4,
321
+ "total_tokens": 34,
322
+ },
323
+ },
324
+ "headers": {
325
+ "content-length": "313",
326
+ "content-type": "application/json",
327
+ },
328
+ "id": "test-id",
329
+ "modelId": "test-model",
330
+ "timestamp": 1970-01-01T00:02:03.000Z,
331
+ }
332
+ `);
333
+ });
334
+
335
+ it('should support partial usage', async () => {
336
+ prepareJsonResponse({
337
+ usage: { prompt_tokens: 20, total_tokens: 20 },
338
+ });
339
+
340
+ const { usage } = await model.doGenerate({
341
+ prompt: TEST_PROMPT,
342
+ });
343
+
344
+ expect(usage).toMatchInlineSnapshot(`
345
+ {
346
+ "inputTokens": {
347
+ "cacheRead": 0,
348
+ "cacheWrite": undefined,
349
+ "noCache": 20,
350
+ "total": 20,
351
+ },
352
+ "outputTokens": {
353
+ "reasoning": 0,
354
+ "text": 0,
355
+ "total": 0,
356
+ },
357
+ "raw": {
358
+ "prompt_tokens": 20,
359
+ "total_tokens": 20,
360
+ },
361
+ }
362
+ `);
363
+ });
364
+
365
+ it('should extract finish reason', async () => {
366
+ prepareJsonResponse({
367
+ finish_reason: 'stop',
368
+ });
369
+
370
+ const response = await model.doGenerate({
371
+ prompt: TEST_PROMPT,
372
+ });
373
+
374
+ expect(response.finishReason).toMatchInlineSnapshot(`
375
+ {
376
+ "raw": "stop",
377
+ "unified": "stop",
378
+ }
379
+ `);
380
+ });
381
+
382
+ it('should support unknown finish reason', async () => {
383
+ prepareJsonResponse({
384
+ finish_reason: 'eos',
385
+ });
386
+
387
+ const response = await model.doGenerate({
388
+ prompt: TEST_PROMPT,
389
+ });
390
+
391
+ expect(response.finishReason).toMatchInlineSnapshot(`
392
+ {
393
+ "raw": "eos",
394
+ "unified": "other",
395
+ }
396
+ `);
397
+ });
398
+
399
+ it('should expose the raw response headers', async () => {
400
+ prepareJsonResponse({
401
+ headers: { 'test-header': 'test-value' },
402
+ });
403
+
404
+ const { response } = await model.doGenerate({
405
+ prompt: TEST_PROMPT,
406
+ });
407
+
408
+ expect(response?.headers).toStrictEqual({
409
+ // default headers:
410
+ 'content-length': '350',
411
+ 'content-type': 'application/json',
412
+
413
+ // custom header
414
+ 'test-header': 'test-value',
415
+ });
416
+ });
417
+
418
+ it('should pass the model and the messages', async () => {
419
+ prepareJsonResponse({ content: '' });
420
+
421
+ await model.doGenerate({
422
+ prompt: TEST_PROMPT,
423
+ });
424
+
425
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
426
+ model: 'grok-beta',
427
+ messages: [{ role: 'user', content: 'Hello' }],
428
+ });
429
+ });
430
+
431
+ it('should pass settings', async () => {
432
+ prepareJsonResponse();
433
+
434
+ await provider('grok-beta').doGenerate({
435
+ prompt: TEST_PROMPT,
436
+ providerOptions: {
437
+ openaiCompatible: {
438
+ user: 'test-user-id',
439
+ },
440
+ },
441
+ });
442
+
443
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
444
+ model: 'grok-beta',
445
+ messages: [{ role: 'user', content: 'Hello' }],
446
+ user: 'test-user-id',
447
+ });
448
+ });
449
+
450
+ it('should pass settings with deprecated openai-compatible key and emit warning', async () => {
451
+ prepareJsonResponse();
452
+
453
+ const result = await provider('grok-beta').doGenerate({
454
+ prompt: TEST_PROMPT,
455
+ providerOptions: {
456
+ 'openai-compatible': {
457
+ user: 'test-user-id',
458
+ },
459
+ },
460
+ });
461
+
462
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
463
+ model: 'grok-beta',
464
+ messages: [{ role: 'user', content: 'Hello' }],
465
+ user: 'test-user-id',
466
+ });
467
+
468
+ expect(result.warnings).toContainEqual({
469
+ type: 'other',
470
+ message: `The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead.`,
471
+ });
472
+ });
473
+
474
+ it('should include provider-specific options', async () => {
475
+ prepareJsonResponse();
476
+
477
+ await provider('grok-beta').doGenerate({
478
+ providerOptions: {
479
+ 'test-provider': {
480
+ someCustomOption: 'test-value',
481
+ },
482
+ },
483
+ prompt: TEST_PROMPT,
484
+ });
485
+
486
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
487
+ model: 'grok-beta',
488
+ messages: [{ role: 'user', content: 'Hello' }],
489
+ someCustomOption: 'test-value',
490
+ });
491
+ });
492
+
493
+ it('should not include provider-specific options for different provider', async () => {
494
+ prepareJsonResponse();
495
+
496
+ await provider('grok-beta').doGenerate({
497
+ providerOptions: {
498
+ notThisProviderName: {
499
+ someCustomOption: 'test-value',
500
+ },
501
+ },
502
+ prompt: TEST_PROMPT,
503
+ });
504
+
505
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
506
+ model: 'grok-beta',
507
+ messages: [{ role: 'user', content: 'Hello' }],
508
+ });
509
+ });
510
+
511
+ it('should pass tools and toolChoice', async () => {
512
+ prepareJsonResponse({ content: '' });
513
+
514
+ await model.doGenerate({
515
+ tools: [
516
+ {
517
+ type: 'function',
518
+ name: 'test-tool',
519
+ inputSchema: {
520
+ type: 'object',
521
+ properties: { value: { type: 'string' } },
522
+ required: ['value'],
523
+ additionalProperties: false,
524
+ $schema: 'http://json-schema.org/draft-07/schema#',
525
+ },
526
+ },
527
+ ],
528
+ toolChoice: {
529
+ type: 'tool',
530
+ toolName: 'test-tool',
531
+ },
532
+ prompt: TEST_PROMPT,
533
+ });
534
+
535
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
536
+ model: 'grok-beta',
537
+ messages: [{ role: 'user', content: 'Hello' }],
538
+ tools: [
539
+ {
540
+ type: 'function',
541
+ function: {
542
+ name: 'test-tool',
543
+ parameters: {
544
+ type: 'object',
545
+ properties: { value: { type: 'string' } },
546
+ required: ['value'],
547
+ additionalProperties: false,
548
+ $schema: 'http://json-schema.org/draft-07/schema#',
549
+ },
550
+ },
551
+ },
552
+ ],
553
+ tool_choice: {
554
+ type: 'function',
555
+ function: { name: 'test-tool' },
556
+ },
557
+ });
558
+ });
559
+
560
+ it('should pass headers', async () => {
561
+ prepareJsonResponse({ content: '' });
562
+
563
+ const provider = createOpenAICompatible({
564
+ baseURL: 'https://my.api.com/v1/',
565
+ name: 'test-provider',
566
+ headers: {
567
+ Authorization: `Bearer test-api-key`,
568
+ 'Custom-Provider-Header': 'provider-header-value',
569
+ },
570
+ });
571
+
572
+ await provider('grok-beta').doGenerate({
573
+ prompt: TEST_PROMPT,
574
+ headers: {
575
+ 'Custom-Request-Header': 'request-header-value',
576
+ },
577
+ });
578
+
579
+ expect(server.calls[0].requestHeaders).toStrictEqual({
580
+ authorization: 'Bearer test-api-key',
581
+ 'content-type': 'application/json',
582
+ 'custom-provider-header': 'provider-header-value',
583
+ 'custom-request-header': 'request-header-value',
584
+ });
585
+ });
586
+
587
+ it('should parse tool results', async () => {
588
+ prepareJsonResponse({
589
+ tool_calls: [
590
+ {
591
+ id: 'call_O17Uplv4lJvD6DVdIvFFeRMw',
592
+ type: 'function',
593
+ function: {
594
+ name: 'test-tool',
595
+ arguments: '{"value":"Spark"}',
596
+ },
597
+ },
598
+ ],
599
+ });
600
+
601
+ const result = await model.doGenerate({
602
+ tools: [
603
+ {
604
+ type: 'function',
605
+ name: 'test-tool',
606
+ inputSchema: {
607
+ type: 'object',
608
+ properties: { value: { type: 'string' } },
609
+ required: ['value'],
610
+ additionalProperties: false,
611
+ $schema: 'http://json-schema.org/draft-07/schema#',
612
+ },
613
+ },
614
+ ],
615
+ toolChoice: {
616
+ type: 'tool',
617
+ toolName: 'test-tool',
618
+ },
619
+ prompt: TEST_PROMPT,
620
+ });
621
+
622
+ expect(result.content).toMatchInlineSnapshot(`
623
+ [
624
+ {
625
+ "input": "{"value":"Spark"}",
626
+ "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw",
627
+ "toolName": "test-tool",
628
+ "type": "tool-call",
629
+ },
630
+ ]
631
+ `);
632
+ });
633
+
634
+ describe('Google Gemini thought signatures (OpenAI compatibility)', () => {
635
+ it('should parse thought signature from extra_content and include in providerMetadata', async () => {
636
+ prepareJsonResponse({
637
+ tool_calls: [
638
+ {
639
+ id: 'function-call-1',
640
+ type: 'function',
641
+ function: {
642
+ name: 'check_flight',
643
+ arguments: '{"flight":"AA100"}',
644
+ },
645
+ extra_content: {
646
+ google: {
647
+ thought_signature: '<Signature A>',
648
+ },
649
+ },
650
+ },
651
+ ],
652
+ });
653
+
654
+ const result = await model.doGenerate({
655
+ tools: [
656
+ {
657
+ type: 'function',
658
+ name: 'check_flight',
659
+ inputSchema: {
660
+ type: 'object',
661
+ properties: { flight: { type: 'string' } },
662
+ required: ['flight'],
663
+ additionalProperties: false,
664
+ $schema: 'http://json-schema.org/draft-07/schema#',
665
+ },
666
+ },
667
+ ],
668
+ prompt: TEST_PROMPT,
669
+ });
670
+
671
+ expect(result.content).toMatchInlineSnapshot(`
672
+ [
673
+ {
674
+ "input": "{"flight":"AA100"}",
675
+ "providerMetadata": {
676
+ "test-provider": {
677
+ "thoughtSignature": "<Signature A>",
678
+ },
679
+ },
680
+ "toolCallId": "function-call-1",
681
+ "toolName": "check_flight",
682
+ "type": "tool-call",
683
+ },
684
+ ]
685
+ `);
686
+ });
687
+
688
+ it('should handle parallel tool calls with signature only on first call', async () => {
689
+ prepareJsonResponse({
690
+ tool_calls: [
691
+ {
692
+ id: 'function-call-paris',
693
+ type: 'function',
694
+ function: {
695
+ name: 'get_current_temperature',
696
+ arguments: '{"location":"Paris"}',
697
+ },
698
+ extra_content: {
699
+ google: {
700
+ thought_signature: '<Signature A>',
701
+ },
702
+ },
703
+ },
704
+ {
705
+ id: 'function-call-london',
706
+ type: 'function',
707
+ function: {
708
+ name: 'get_current_temperature',
709
+ arguments: '{"location":"London"}',
710
+ },
711
+ // No extra_content - parallel calls don't have signatures
712
+ },
713
+ ],
714
+ });
715
+
716
+ const result = await model.doGenerate({
717
+ tools: [
718
+ {
719
+ type: 'function',
720
+ name: 'get_current_temperature',
721
+ inputSchema: {
722
+ type: 'object',
723
+ properties: { location: { type: 'string' } },
724
+ required: ['location'],
725
+ additionalProperties: false,
726
+ $schema: 'http://json-schema.org/draft-07/schema#',
727
+ },
728
+ },
729
+ ],
730
+ prompt: TEST_PROMPT,
731
+ });
732
+
733
+ expect(result.content).toMatchInlineSnapshot(`
734
+ [
735
+ {
736
+ "input": "{"location":"Paris"}",
737
+ "providerMetadata": {
738
+ "test-provider": {
739
+ "thoughtSignature": "<Signature A>",
740
+ },
741
+ },
742
+ "toolCallId": "function-call-paris",
743
+ "toolName": "get_current_temperature",
744
+ "type": "tool-call",
745
+ },
746
+ {
747
+ "input": "{"location":"London"}",
748
+ "toolCallId": "function-call-london",
749
+ "toolName": "get_current_temperature",
750
+ "type": "tool-call",
751
+ },
752
+ ]
753
+ `);
754
+ });
755
+
756
+ it('should not include providerMetadata when no thought signature is present', async () => {
757
+ prepareJsonResponse({
758
+ tool_calls: [
759
+ {
760
+ id: 'call-1',
761
+ type: 'function',
762
+ function: {
763
+ name: 'some_tool',
764
+ arguments: '{"param":"value"}',
765
+ },
766
+ // No extra_content
767
+ },
768
+ ],
769
+ });
770
+
771
+ const result = await model.doGenerate({
772
+ tools: [
773
+ {
774
+ type: 'function',
775
+ name: 'some_tool',
776
+ inputSchema: {
777
+ type: 'object',
778
+ properties: { param: { type: 'string' } },
779
+ required: ['param'],
780
+ additionalProperties: false,
781
+ $schema: 'http://json-schema.org/draft-07/schema#',
782
+ },
783
+ },
784
+ ],
785
+ prompt: TEST_PROMPT,
786
+ });
787
+
788
+ expect(result.content).toMatchInlineSnapshot(`
789
+ [
790
+ {
791
+ "input": "{"param":"value"}",
792
+ "toolCallId": "call-1",
793
+ "toolName": "some_tool",
794
+ "type": "tool-call",
795
+ },
796
+ ]
797
+ `);
798
+ });
799
+ });
800
+
801
+ describe('response format', () => {
802
+ it('should not send a response_format when response format is text', async () => {
803
+ prepareJsonResponse({ content: '{"value":"Spark"}' });
804
+
805
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4o-2024-08-06', {
806
+ provider: 'test-provider',
807
+ url: () => 'https://my.api.com/v1/chat/completions',
808
+ headers: () => ({}),
809
+ supportsStructuredOutputs: false,
810
+ });
811
+
812
+ await model.doGenerate({
813
+ prompt: TEST_PROMPT,
814
+ responseFormat: { type: 'text' },
815
+ });
816
+
817
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
818
+ model: 'gpt-4o-2024-08-06',
819
+ messages: [{ role: 'user', content: 'Hello' }],
820
+ });
821
+ });
822
+
823
+ it('should forward json response format as "json_object" without schema', async () => {
824
+ prepareJsonResponse({ content: '{"value":"Spark"}' });
825
+
826
+ const model = provider('gpt-4o-2024-08-06');
827
+
828
+ await model.doGenerate({
829
+ prompt: TEST_PROMPT,
830
+ responseFormat: { type: 'json' },
831
+ });
832
+
833
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
834
+ model: 'gpt-4o-2024-08-06',
835
+ messages: [{ role: 'user', content: 'Hello' }],
836
+ response_format: { type: 'json_object' },
837
+ });
838
+ });
839
+
840
+ it('should forward json response format as "json_object" and omit schema when structuredOutputs are disabled', async () => {
841
+ prepareJsonResponse({ content: '{"value":"Spark"}' });
842
+
843
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4o-2024-08-06', {
844
+ provider: 'test-provider',
845
+ url: () => 'https://my.api.com/v1/chat/completions',
846
+ headers: () => ({}),
847
+ supportsStructuredOutputs: false,
848
+ });
849
+
850
+ const { warnings } = await model.doGenerate({
851
+ prompt: TEST_PROMPT,
852
+ responseFormat: {
853
+ type: 'json',
854
+ schema: {
855
+ type: 'object',
856
+ properties: { value: { type: 'string' } },
857
+ required: ['value'],
858
+ additionalProperties: false,
859
+ $schema: 'http://json-schema.org/draft-07/schema#',
860
+ },
861
+ },
862
+ });
863
+
864
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
865
+ model: 'gpt-4o-2024-08-06',
866
+ messages: [{ role: 'user', content: 'Hello' }],
867
+ response_format: { type: 'json_object' },
868
+ });
869
+
870
+ expect(warnings).toMatchInlineSnapshot(`
871
+ [
872
+ {
873
+ "details": "JSON response format schema is only supported with structuredOutputs",
874
+ "feature": "responseFormat",
875
+ "type": "unsupported",
876
+ },
877
+ ]
878
+ `);
879
+ });
880
+
881
+ it('should forward json response format as "json_object" and include schema when structuredOutputs are enabled', async () => {
882
+ prepareJsonResponse({ content: '{"value":"Spark"}' });
883
+
884
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4o-2024-08-06', {
885
+ provider: 'test-provider',
886
+ url: () => 'https://my.api.com/v1/chat/completions',
887
+ headers: () => ({}),
888
+ supportsStructuredOutputs: true,
889
+ });
890
+
891
+ const { warnings } = await model.doGenerate({
892
+ prompt: TEST_PROMPT,
893
+ responseFormat: {
894
+ type: 'json',
895
+ schema: {
896
+ type: 'object',
897
+ properties: { value: { type: 'string' } },
898
+ required: ['value'],
899
+ additionalProperties: false,
900
+ $schema: 'http://json-schema.org/draft-07/schema#',
901
+ },
902
+ },
903
+ });
904
+
905
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
906
+ model: 'gpt-4o-2024-08-06',
907
+ messages: [{ role: 'user', content: 'Hello' }],
908
+ response_format: {
909
+ type: 'json_schema',
910
+ json_schema: {
911
+ strict: true,
912
+ name: 'response',
913
+ schema: {
914
+ type: 'object',
915
+ properties: { value: { type: 'string' } },
916
+ required: ['value'],
917
+ additionalProperties: false,
918
+ $schema: 'http://json-schema.org/draft-07/schema#',
919
+ },
920
+ },
921
+ },
922
+ });
923
+
924
+ expect(warnings).toEqual([]);
925
+ });
926
+
927
+ it('should pass reasoningEffort setting from providerOptions', async () => {
928
+ prepareJsonResponse({ content: '{"value":"test"}' });
929
+
930
+ const model = new OpenAICompatibleChatLanguageModel('gpt-5', {
931
+ provider: 'test-provider',
932
+ url: () => 'https://my.api.com/v1/chat/completions',
933
+ headers: () => ({}),
934
+ });
935
+
936
+ await model.doGenerate({
937
+ prompt: TEST_PROMPT,
938
+ providerOptions: {
939
+ 'test-provider': { reasoningEffort: 'high' },
940
+ },
941
+ });
942
+
943
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
944
+ model: 'gpt-5',
945
+ messages: [{ role: 'user', content: 'Hello' }],
946
+ reasoning_effort: 'high',
947
+ });
948
+ });
949
+
950
+ it('should not duplicate reasoningEffort in request body', async () => {
951
+ prepareJsonResponse({ content: '{"value":"test"}' });
952
+
953
+ const model = new OpenAICompatibleChatLanguageModel('gpt-5', {
954
+ provider: 'test-provider',
955
+ url: () => 'https://my.api.com/v1/chat/completions',
956
+ headers: () => ({}),
957
+ });
958
+
959
+ await model.doGenerate({
960
+ prompt: TEST_PROMPT,
961
+ providerOptions: {
962
+ 'test-provider': {
963
+ reasoningEffort: 'high',
964
+ customOption: 'should-be-included',
965
+ },
966
+ },
967
+ });
968
+
969
+ const body = await server.calls[0].requestBodyJson;
970
+
971
+ expect(body.reasoning_effort).toBe('high');
972
+ expect(body.reasoningEffort).toBeUndefined();
973
+ expect(body.customOption).toBe('should-be-included');
974
+ });
975
+
976
+ it('should pass textVerbosity setting from providerOptions', async () => {
977
+ prepareJsonResponse({ content: '{"value":"test"}' });
978
+
979
+ const model = new OpenAICompatibleChatLanguageModel('gpt-5', {
980
+ provider: 'test-provider',
981
+ url: () => 'https://my.api.com/v1/chat/completions',
982
+ headers: () => ({}),
983
+ });
984
+
985
+ await model.doGenerate({
986
+ prompt: TEST_PROMPT,
987
+ providerOptions: {
988
+ 'test-provider': { textVerbosity: 'low' },
989
+ },
990
+ });
991
+
992
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
993
+ model: 'gpt-5',
994
+ messages: [{ role: 'user', content: 'Hello' }],
995
+ verbosity: 'low',
996
+ });
997
+ });
998
+
999
+ it('should not duplicate textVerbosity in request body', async () => {
1000
+ prepareJsonResponse({ content: '{"value":"test"}' });
1001
+
1002
+ const model = new OpenAICompatibleChatLanguageModel('gpt-5', {
1003
+ provider: 'test-provider',
1004
+ url: () => 'https://my.api.com/v1/chat/completions',
1005
+ headers: () => ({}),
1006
+ });
1007
+
1008
+ await model.doGenerate({
1009
+ prompt: TEST_PROMPT,
1010
+ providerOptions: {
1011
+ 'test-provider': {
1012
+ textVerbosity: 'medium',
1013
+ customOption: 'should-be-included',
1014
+ },
1015
+ },
1016
+ });
1017
+
1018
+ const body = await server.calls[0].requestBodyJson;
1019
+
1020
+ expect(body.verbosity).toBe('medium');
1021
+ expect(body.textVerbosity).toBeUndefined();
1022
+ expect(body.customOption).toBe('should-be-included');
1023
+ });
1024
+
1025
+ it('should use json_schema & strict with responseFormat json when structuredOutputs are enabled', async () => {
1026
+ prepareJsonResponse({ content: '{"value":"Spark"}' });
1027
+
1028
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4o-2024-08-06', {
1029
+ provider: 'test-provider',
1030
+ url: () => 'https://my.api.com/v1/chat/completions',
1031
+ headers: () => ({}),
1032
+ supportsStructuredOutputs: true,
1033
+ });
1034
+
1035
+ await model.doGenerate({
1036
+ responseFormat: {
1037
+ type: 'json',
1038
+ schema: {
1039
+ type: 'object',
1040
+ properties: { value: { type: 'string' } },
1041
+ required: ['value'],
1042
+ additionalProperties: false,
1043
+ $schema: 'http://json-schema.org/draft-07/schema#',
1044
+ },
1045
+ },
1046
+ prompt: TEST_PROMPT,
1047
+ });
1048
+
1049
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
1050
+ model: 'gpt-4o-2024-08-06',
1051
+ messages: [{ role: 'user', content: 'Hello' }],
1052
+ response_format: {
1053
+ type: 'json_schema',
1054
+ json_schema: {
1055
+ strict: true,
1056
+ name: 'response',
1057
+ schema: {
1058
+ type: 'object',
1059
+ properties: { value: { type: 'string' } },
1060
+ required: ['value'],
1061
+ additionalProperties: false,
1062
+ $schema: 'http://json-schema.org/draft-07/schema#',
1063
+ },
1064
+ },
1065
+ },
1066
+ });
1067
+ });
1068
+
1069
+ it('should set name & description with responseFormat json when structuredOutputs are enabled', async () => {
1070
+ prepareJsonResponse({ content: '{"value":"Spark"}' });
1071
+
1072
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4o-2024-08-06', {
1073
+ provider: 'test-provider',
1074
+ url: () => 'https://my.api.com/v1/chat/completions',
1075
+ headers: () => ({}),
1076
+ supportsStructuredOutputs: true,
1077
+ });
1078
+
1079
+ await model.doGenerate({
1080
+ responseFormat: {
1081
+ type: 'json',
1082
+ name: 'test-name',
1083
+ description: 'test description',
1084
+ schema: {
1085
+ type: 'object',
1086
+ properties: { value: { type: 'string' } },
1087
+ required: ['value'],
1088
+ additionalProperties: false,
1089
+ $schema: 'http://json-schema.org/draft-07/schema#',
1090
+ },
1091
+ },
1092
+ prompt: TEST_PROMPT,
1093
+ });
1094
+
1095
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
1096
+ model: 'gpt-4o-2024-08-06',
1097
+ messages: [{ role: 'user', content: 'Hello' }],
1098
+ response_format: {
1099
+ type: 'json_schema',
1100
+ json_schema: {
1101
+ strict: true,
1102
+ name: 'test-name',
1103
+ description: 'test description',
1104
+ schema: {
1105
+ type: 'object',
1106
+ properties: { value: { type: 'string' } },
1107
+ required: ['value'],
1108
+ additionalProperties: false,
1109
+ $schema: 'http://json-schema.org/draft-07/schema#',
1110
+ },
1111
+ },
1112
+ },
1113
+ });
1114
+ });
1115
+
1116
+ it('should send strict: false when strictJsonSchema is explicitly disabled', async () => {
1117
+ prepareJsonResponse({ content: '{"value":"Spark"}' });
1118
+
1119
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4o-2024-08-06', {
1120
+ provider: 'test-provider',
1121
+ url: () => 'https://my.api.com/v1/chat/completions',
1122
+ headers: () => ({}),
1123
+ supportsStructuredOutputs: true,
1124
+ });
1125
+
1126
+ await model.doGenerate({
1127
+ responseFormat: {
1128
+ type: 'json',
1129
+ name: 'test-name',
1130
+ description: 'test description',
1131
+ schema: {
1132
+ type: 'object',
1133
+ properties: { value: { type: 'string' } },
1134
+ required: ['value'],
1135
+ additionalProperties: false,
1136
+ $schema: 'http://json-schema.org/draft-07/schema#',
1137
+ },
1138
+ },
1139
+ providerOptions: {
1140
+ 'test-provider': {
1141
+ strictJsonSchema: false,
1142
+ },
1143
+ },
1144
+ prompt: TEST_PROMPT,
1145
+ });
1146
+
1147
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
1148
+ model: 'gpt-4o-2024-08-06',
1149
+ messages: [{ role: 'user', content: 'Hello' }],
1150
+ response_format: {
1151
+ type: 'json_schema',
1152
+ json_schema: {
1153
+ strict: false,
1154
+ name: 'test-name',
1155
+ description: 'test description',
1156
+ schema: {
1157
+ type: 'object',
1158
+ properties: { value: { type: 'string' } },
1159
+ required: ['value'],
1160
+ additionalProperties: false,
1161
+ $schema: 'http://json-schema.org/draft-07/schema#',
1162
+ },
1163
+ },
1164
+ },
1165
+ });
1166
+ });
1167
+
1168
+ it('should allow for undefined schema with responseFormat json when structuredOutputs are enabled', async () => {
1169
+ prepareJsonResponse({ content: '{"value":"Spark"}' });
1170
+
1171
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4o-2024-08-06', {
1172
+ provider: 'test-provider',
1173
+ url: () => 'https://my.api.com/v1/chat/completions',
1174
+ headers: () => ({}),
1175
+ supportsStructuredOutputs: true,
1176
+ });
1177
+
1178
+ await model.doGenerate({
1179
+ responseFormat: {
1180
+ type: 'json',
1181
+ name: 'test-name',
1182
+ description: 'test description',
1183
+ },
1184
+ prompt: TEST_PROMPT,
1185
+ });
1186
+
1187
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
1188
+ model: 'gpt-4o-2024-08-06',
1189
+ messages: [{ role: 'user', content: 'Hello' }],
1190
+ response_format: {
1191
+ type: 'json_object',
1192
+ },
1193
+ });
1194
+ });
1195
+ });
1196
+
1197
+ it('should send request body', async () => {
1198
+ prepareJsonResponse({ content: '' });
1199
+
1200
+ const { request } = await model.doGenerate({
1201
+ prompt: TEST_PROMPT,
1202
+ });
1203
+
1204
+ expect(request).toStrictEqual({
1205
+ body: '{"model":"grok-beta","messages":[{"role":"user","content":"Hello"}]}',
1206
+ });
1207
+ });
1208
+
1209
+ describe('usage details', () => {
1210
+ it('should extract detailed token usage when available', async () => {
1211
+ prepareJsonResponse({
1212
+ usage: {
1213
+ prompt_tokens: 20,
1214
+ completion_tokens: 30,
1215
+ total_tokens: 50,
1216
+ prompt_tokens_details: {
1217
+ cached_tokens: 5,
1218
+ },
1219
+ completion_tokens_details: {
1220
+ reasoning_tokens: 10,
1221
+ accepted_prediction_tokens: 15,
1222
+ rejected_prediction_tokens: 5,
1223
+ },
1224
+ },
1225
+ });
1226
+
1227
+ const result = await model.doGenerate({
1228
+ prompt: TEST_PROMPT,
1229
+ });
1230
+
1231
+ expect(result.usage).toMatchInlineSnapshot(`
1232
+ {
1233
+ "inputTokens": {
1234
+ "cacheRead": 5,
1235
+ "cacheWrite": undefined,
1236
+ "noCache": 15,
1237
+ "total": 20,
1238
+ },
1239
+ "outputTokens": {
1240
+ "reasoning": 10,
1241
+ "text": 20,
1242
+ "total": 30,
1243
+ },
1244
+ "raw": {
1245
+ "completion_tokens": 30,
1246
+ "completion_tokens_details": {
1247
+ "accepted_prediction_tokens": 15,
1248
+ "reasoning_tokens": 10,
1249
+ "rejected_prediction_tokens": 5,
1250
+ },
1251
+ "prompt_tokens": 20,
1252
+ "prompt_tokens_details": {
1253
+ "cached_tokens": 5,
1254
+ },
1255
+ "total_tokens": 50,
1256
+ },
1257
+ }
1258
+ `);
1259
+ expect(result.providerMetadata).toMatchInlineSnapshot(`
1260
+ {
1261
+ "test-provider": {
1262
+ "acceptedPredictionTokens": 15,
1263
+ "rejectedPredictionTokens": 5,
1264
+ },
1265
+ }
1266
+ `);
1267
+ });
1268
+
1269
+ it('should handle missing token details', async () => {
1270
+ prepareJsonResponse({
1271
+ usage: {
1272
+ prompt_tokens: 20,
1273
+ completion_tokens: 30,
1274
+ // No token details provided
1275
+ },
1276
+ });
1277
+
1278
+ const result = await model.doGenerate({
1279
+ prompt: TEST_PROMPT,
1280
+ });
1281
+
1282
+ expect(result.providerMetadata!['test-provider']).toStrictEqual({});
1283
+ });
1284
+
1285
+ it('should handle partial token details', async () => {
1286
+ prepareJsonResponse({
1287
+ usage: {
1288
+ prompt_tokens: 20,
1289
+ completion_tokens: 30,
1290
+ total_tokens: 50,
1291
+ prompt_tokens_details: {
1292
+ cached_tokens: 5,
1293
+ },
1294
+ completion_tokens_details: {
1295
+ // Only reasoning tokens provided
1296
+ reasoning_tokens: 10,
1297
+ },
1298
+ },
1299
+ });
1300
+
1301
+ const result = await model.doGenerate({
1302
+ prompt: TEST_PROMPT,
1303
+ });
1304
+
1305
+ expect(result.usage).toMatchInlineSnapshot(`
1306
+ {
1307
+ "inputTokens": {
1308
+ "cacheRead": 5,
1309
+ "cacheWrite": undefined,
1310
+ "noCache": 15,
1311
+ "total": 20,
1312
+ },
1313
+ "outputTokens": {
1314
+ "reasoning": 10,
1315
+ "text": 20,
1316
+ "total": 30,
1317
+ },
1318
+ "raw": {
1319
+ "completion_tokens": 30,
1320
+ "completion_tokens_details": {
1321
+ "reasoning_tokens": 10,
1322
+ },
1323
+ "prompt_tokens": 20,
1324
+ "prompt_tokens_details": {
1325
+ "cached_tokens": 5,
1326
+ },
1327
+ "total_tokens": 50,
1328
+ },
1329
+ }
1330
+ `);
1331
+ });
1332
+ });
1333
+ });
1334
+
1335
+ describe('doStream', () => {
1336
+ function prepareStreamResponse({
1337
+ content = [],
1338
+ finish_reason = 'stop',
1339
+ headers,
1340
+ }: {
1341
+ content?: string[];
1342
+ finish_reason?: string;
1343
+ headers?: Record<string, string>;
1344
+ }) {
1345
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
1346
+ type: 'stream-chunks',
1347
+ headers,
1348
+ chunks: [
1349
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1702657020,"model":"grok-beta",` +
1350
+ `"system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n`,
1351
+ ...content.map(text => {
1352
+ return (
1353
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1702657020,"model":"grok-beta",` +
1354
+ `"system_fingerprint":null,"choices":[{"index":1,"delta":{"content":"${text}"},"finish_reason":null}]}\n\n`
1355
+ );
1356
+ }),
1357
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1702657020,"model":"grok-beta",` +
1358
+ `"system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"${finish_reason}"}]}\n\n`,
1359
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-beta",` +
1360
+ `"system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"${finish_reason}"}],` +
1361
+ `"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,` +
1362
+ `"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}\n\n`,
1363
+ 'data: [DONE]\n\n',
1364
+ ],
1365
+ };
1366
+ }
1367
+
1368
+ it('should respect the includeUsage option', async () => {
1369
+ prepareStreamResponse({
1370
+ content: ['Hello', ', ', 'World!'],
1371
+ finish_reason: 'stop',
1372
+ });
1373
+
1374
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4o-2024-08-06', {
1375
+ provider: 'test-provider',
1376
+ url: () => 'https://my.api.com/v1/chat/completions',
1377
+ headers: () => ({}),
1378
+ includeUsage: true,
1379
+ });
1380
+
1381
+ await model.doStream({
1382
+ prompt: TEST_PROMPT,
1383
+ includeRawChunks: false,
1384
+ });
1385
+
1386
+ const body = await server.calls[0].requestBodyJson;
1387
+
1388
+ expect(body.stream).toBe(true);
1389
+ expect(body.stream_options).toStrictEqual({ include_usage: true });
1390
+ });
1391
+
1392
+ it('should stream text deltas', async () => {
1393
+ prepareStreamResponse({
1394
+ content: ['Hello', ', ', 'World!'],
1395
+ finish_reason: 'stop',
1396
+ });
1397
+
1398
+ const { stream } = await model.doStream({
1399
+ prompt: TEST_PROMPT,
1400
+ includeRawChunks: false,
1401
+ });
1402
+
1403
+ // note: space moved to last chunk bc of trimming
1404
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
1405
+ [
1406
+ {
1407
+ "type": "stream-start",
1408
+ "warnings": [],
1409
+ },
1410
+ {
1411
+ "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798",
1412
+ "modelId": "grok-beta",
1413
+ "timestamp": 2023-12-15T16:17:00.000Z,
1414
+ "type": "response-metadata",
1415
+ },
1416
+ {
1417
+ "id": "txt-0",
1418
+ "type": "text-start",
1419
+ },
1420
+ {
1421
+ "delta": "Hello",
1422
+ "id": "txt-0",
1423
+ "type": "text-delta",
1424
+ },
1425
+ {
1426
+ "delta": ", ",
1427
+ "id": "txt-0",
1428
+ "type": "text-delta",
1429
+ },
1430
+ {
1431
+ "delta": "World!",
1432
+ "id": "txt-0",
1433
+ "type": "text-delta",
1434
+ },
1435
+ {
1436
+ "id": "txt-0",
1437
+ "type": "text-end",
1438
+ },
1439
+ {
1440
+ "finishReason": {
1441
+ "raw": "stop",
1442
+ "unified": "stop",
1443
+ },
1444
+ "providerMetadata": {
1445
+ "test-provider": {},
1446
+ },
1447
+ "type": "finish",
1448
+ "usage": {
1449
+ "inputTokens": {
1450
+ "cacheRead": 0,
1451
+ "cacheWrite": undefined,
1452
+ "noCache": 18,
1453
+ "total": 18,
1454
+ },
1455
+ "outputTokens": {
1456
+ "reasoning": 0,
1457
+ "text": 439,
1458
+ "total": 439,
1459
+ },
1460
+ "raw": {
1461
+ "completion_tokens": 439,
1462
+ "prompt_tokens": 18,
1463
+ "total_tokens": 457,
1464
+ },
1465
+ },
1466
+ },
1467
+ ]
1468
+ `);
1469
+ });
1470
+
1471
+ it('should stream reasoning content before text deltas', async () => {
1472
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
1473
+ type: 'stream-chunks',
1474
+ chunks: [
1475
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1476
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":"", "reasoning_content":"Let me think"},"finish_reason":null}]}\n\n`,
1477
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1478
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"", "reasoning_content":" about this"},"finish_reason":null}]}\n\n`,
1479
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1480
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"Here's"},"finish_reason":null}]}\n\n`,
1481
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1482
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":" my response"},"finish_reason":null}]}\n\n`,
1483
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-beta",` +
1484
+ `"system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],` +
1485
+ `"usage":{"prompt_tokens":18,"completion_tokens":439}}\n\n`,
1486
+ 'data: [DONE]\n\n',
1487
+ ],
1488
+ };
1489
+
1490
+ const { stream } = await model.doStream({
1491
+ prompt: TEST_PROMPT,
1492
+ includeRawChunks: false,
1493
+ });
1494
+
1495
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
1496
+ [
1497
+ {
1498
+ "type": "stream-start",
1499
+ "warnings": [],
1500
+ },
1501
+ {
1502
+ "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798",
1503
+ "modelId": "grok-beta",
1504
+ "timestamp": 2024-03-25T09:06:38.000Z,
1505
+ "type": "response-metadata",
1506
+ },
1507
+ {
1508
+ "id": "reasoning-0",
1509
+ "type": "reasoning-start",
1510
+ },
1511
+ {
1512
+ "delta": "Let me think",
1513
+ "id": "reasoning-0",
1514
+ "type": "reasoning-delta",
1515
+ },
1516
+ {
1517
+ "delta": " about this",
1518
+ "id": "reasoning-0",
1519
+ "type": "reasoning-delta",
1520
+ },
1521
+ {
1522
+ "id": "reasoning-0",
1523
+ "type": "reasoning-end",
1524
+ },
1525
+ {
1526
+ "id": "txt-0",
1527
+ "type": "text-start",
1528
+ },
1529
+ {
1530
+ "delta": "Here's",
1531
+ "id": "txt-0",
1532
+ "type": "text-delta",
1533
+ },
1534
+ {
1535
+ "delta": " my response",
1536
+ "id": "txt-0",
1537
+ "type": "text-delta",
1538
+ },
1539
+ {
1540
+ "id": "txt-0",
1541
+ "type": "text-end",
1542
+ },
1543
+ {
1544
+ "finishReason": {
1545
+ "raw": "stop",
1546
+ "unified": "stop",
1547
+ },
1548
+ "providerMetadata": {
1549
+ "test-provider": {},
1550
+ },
1551
+ "type": "finish",
1552
+ "usage": {
1553
+ "inputTokens": {
1554
+ "cacheRead": 0,
1555
+ "cacheWrite": undefined,
1556
+ "noCache": 18,
1557
+ "total": 18,
1558
+ },
1559
+ "outputTokens": {
1560
+ "reasoning": 0,
1561
+ "text": 439,
1562
+ "total": 439,
1563
+ },
1564
+ "raw": {
1565
+ "completion_tokens": 439,
1566
+ "prompt_tokens": 18,
1567
+ },
1568
+ },
1569
+ },
1570
+ ]
1571
+ `);
1572
+ });
1573
+
1574
+ it('should stream reasoning from reasoning field when reasoning_content is not provided', async () => {
1575
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
1576
+ type: 'stream-chunks',
1577
+ chunks: [
1578
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1579
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":"", "reasoning":"Let me consider"},"finish_reason":null}]}\n\n`,
1580
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1581
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"", "reasoning":" this carefully"},"finish_reason":null}]}\n\n`,
1582
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1583
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"My answer is"},"finish_reason":null}]}\n\n`,
1584
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1585
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":" correct"},"finish_reason":null}]}\n\n`,
1586
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-beta",` +
1587
+ `"system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],` +
1588
+ `"usage":{"prompt_tokens":18,"completion_tokens":439}}\n\n`,
1589
+ 'data: [DONE]\n\n',
1590
+ ],
1591
+ };
1592
+
1593
+ const { stream } = await model.doStream({
1594
+ prompt: TEST_PROMPT,
1595
+ includeRawChunks: false,
1596
+ });
1597
+
1598
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
1599
+ [
1600
+ {
1601
+ "type": "stream-start",
1602
+ "warnings": [],
1603
+ },
1604
+ {
1605
+ "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798",
1606
+ "modelId": "grok-beta",
1607
+ "timestamp": 2024-03-25T09:06:38.000Z,
1608
+ "type": "response-metadata",
1609
+ },
1610
+ {
1611
+ "id": "reasoning-0",
1612
+ "type": "reasoning-start",
1613
+ },
1614
+ {
1615
+ "delta": "Let me consider",
1616
+ "id": "reasoning-0",
1617
+ "type": "reasoning-delta",
1618
+ },
1619
+ {
1620
+ "delta": " this carefully",
1621
+ "id": "reasoning-0",
1622
+ "type": "reasoning-delta",
1623
+ },
1624
+ {
1625
+ "id": "reasoning-0",
1626
+ "type": "reasoning-end",
1627
+ },
1628
+ {
1629
+ "id": "txt-0",
1630
+ "type": "text-start",
1631
+ },
1632
+ {
1633
+ "delta": "My answer is",
1634
+ "id": "txt-0",
1635
+ "type": "text-delta",
1636
+ },
1637
+ {
1638
+ "delta": " correct",
1639
+ "id": "txt-0",
1640
+ "type": "text-delta",
1641
+ },
1642
+ {
1643
+ "id": "txt-0",
1644
+ "type": "text-end",
1645
+ },
1646
+ {
1647
+ "finishReason": {
1648
+ "raw": "stop",
1649
+ "unified": "stop",
1650
+ },
1651
+ "providerMetadata": {
1652
+ "test-provider": {},
1653
+ },
1654
+ "type": "finish",
1655
+ "usage": {
1656
+ "inputTokens": {
1657
+ "cacheRead": 0,
1658
+ "cacheWrite": undefined,
1659
+ "noCache": 18,
1660
+ "total": 18,
1661
+ },
1662
+ "outputTokens": {
1663
+ "reasoning": 0,
1664
+ "text": 439,
1665
+ "total": 439,
1666
+ },
1667
+ "raw": {
1668
+ "completion_tokens": 439,
1669
+ "prompt_tokens": 18,
1670
+ },
1671
+ },
1672
+ },
1673
+ ]
1674
+ `);
1675
+ });
1676
+
1677
+ it('should prefer reasoning_content over reasoning field in streaming when both are provided', async () => {
1678
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
1679
+ type: 'stream-chunks',
1680
+ chunks: [
1681
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1682
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":"", "reasoning_content":"From reasoning_content", "reasoning":"From reasoning"},"finish_reason":null}]}\n\n`,
1683
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1684
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"Final response"},"finish_reason":null}]}\n\n`,
1685
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-beta",` +
1686
+ `"system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],` +
1687
+ `"usage":{"prompt_tokens":18,"completion_tokens":439}}\n\n`,
1688
+ 'data: [DONE]\n\n',
1689
+ ],
1690
+ };
1691
+
1692
+ const { stream } = await model.doStream({
1693
+ prompt: TEST_PROMPT,
1694
+ includeRawChunks: false,
1695
+ });
1696
+
1697
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
1698
+ [
1699
+ {
1700
+ "type": "stream-start",
1701
+ "warnings": [],
1702
+ },
1703
+ {
1704
+ "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798",
1705
+ "modelId": "grok-beta",
1706
+ "timestamp": 2024-03-25T09:06:38.000Z,
1707
+ "type": "response-metadata",
1708
+ },
1709
+ {
1710
+ "id": "reasoning-0",
1711
+ "type": "reasoning-start",
1712
+ },
1713
+ {
1714
+ "delta": "From reasoning_content",
1715
+ "id": "reasoning-0",
1716
+ "type": "reasoning-delta",
1717
+ },
1718
+ {
1719
+ "id": "reasoning-0",
1720
+ "type": "reasoning-end",
1721
+ },
1722
+ {
1723
+ "id": "txt-0",
1724
+ "type": "text-start",
1725
+ },
1726
+ {
1727
+ "delta": "Final response",
1728
+ "id": "txt-0",
1729
+ "type": "text-delta",
1730
+ },
1731
+ {
1732
+ "id": "txt-0",
1733
+ "type": "text-end",
1734
+ },
1735
+ {
1736
+ "finishReason": {
1737
+ "raw": "stop",
1738
+ "unified": "stop",
1739
+ },
1740
+ "providerMetadata": {
1741
+ "test-provider": {},
1742
+ },
1743
+ "type": "finish",
1744
+ "usage": {
1745
+ "inputTokens": {
1746
+ "cacheRead": 0,
1747
+ "cacheWrite": undefined,
1748
+ "noCache": 18,
1749
+ "total": 18,
1750
+ },
1751
+ "outputTokens": {
1752
+ "reasoning": 0,
1753
+ "text": 439,
1754
+ "total": 439,
1755
+ },
1756
+ "raw": {
1757
+ "completion_tokens": 439,
1758
+ "prompt_tokens": 18,
1759
+ },
1760
+ },
1761
+ },
1762
+ ]
1763
+ `);
1764
+ });
1765
+
1766
+ it('should stream tool deltas', async () => {
1767
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
1768
+ type: 'stream-chunks',
1769
+ chunks: [
1770
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1771
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,` +
1772
+ `"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":""}}]},` +
1773
+ `"finish_reason":null}]}\n\n`,
1774
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1775
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\""}}]},` +
1776
+ `"finish_reason":null}]}\n\n`,
1777
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1778
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"value"}}]},` +
1779
+ `"finish_reason":null}]}\n\n`,
1780
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1781
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":\\""}}]},` +
1782
+ `"finish_reason":null}]}\n\n`,
1783
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1784
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Spark"}}]},` +
1785
+ `"finish_reason":null}]}\n\n`,
1786
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1787
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"le"}}]},` +
1788
+ `"finish_reason":null}]}\n\n`,
1789
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1790
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Day"}}]},` +
1791
+ `"finish_reason":null}]}\n\n`,
1792
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
1793
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"}"}}]},` +
1794
+ `"finish_reason":null}]}\n\n`,
1795
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-beta",` +
1796
+ `"system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],` +
1797
+ `"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,` +
1798
+ `"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}\n\n`,
1799
+ 'data: [DONE]\n\n',
1800
+ ],
1801
+ };
1802
+
1803
+ const { stream } = await model.doStream({
1804
+ tools: [
1805
+ {
1806
+ type: 'function',
1807
+ name: 'test-tool',
1808
+ inputSchema: {
1809
+ type: 'object',
1810
+ properties: { value: { type: 'string' } },
1811
+ required: ['value'],
1812
+ additionalProperties: false,
1813
+ $schema: 'http://json-schema.org/draft-07/schema#',
1814
+ },
1815
+ },
1816
+ ],
1817
+ prompt: TEST_PROMPT,
1818
+ includeRawChunks: false,
1819
+ });
1820
+
1821
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
1822
+ [
1823
+ {
1824
+ "type": "stream-start",
1825
+ "warnings": [],
1826
+ },
1827
+ {
1828
+ "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798",
1829
+ "modelId": "grok-beta",
1830
+ "timestamp": 2024-03-25T09:06:38.000Z,
1831
+ "type": "response-metadata",
1832
+ },
1833
+ {
1834
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1835
+ "toolName": "test-tool",
1836
+ "type": "tool-input-start",
1837
+ },
1838
+ {
1839
+ "delta": "{"",
1840
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1841
+ "type": "tool-input-delta",
1842
+ },
1843
+ {
1844
+ "delta": "value",
1845
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1846
+ "type": "tool-input-delta",
1847
+ },
1848
+ {
1849
+ "delta": "":"",
1850
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1851
+ "type": "tool-input-delta",
1852
+ },
1853
+ {
1854
+ "delta": "Spark",
1855
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1856
+ "type": "tool-input-delta",
1857
+ },
1858
+ {
1859
+ "delta": "le",
1860
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1861
+ "type": "tool-input-delta",
1862
+ },
1863
+ {
1864
+ "delta": " Day",
1865
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1866
+ "type": "tool-input-delta",
1867
+ },
1868
+ {
1869
+ "delta": ""}",
1870
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1871
+ "type": "tool-input-delta",
1872
+ },
1873
+ {
1874
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1875
+ "type": "tool-input-end",
1876
+ },
1877
+ {
1878
+ "input": "{"value":"Sparkle Day"}",
1879
+ "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw",
1880
+ "toolName": "test-tool",
1881
+ "type": "tool-call",
1882
+ },
1883
+ {
1884
+ "finishReason": {
1885
+ "raw": "tool_calls",
1886
+ "unified": "tool-calls",
1887
+ },
1888
+ "providerMetadata": {
1889
+ "test-provider": {},
1890
+ },
1891
+ "type": "finish",
1892
+ "usage": {
1893
+ "inputTokens": {
1894
+ "cacheRead": 0,
1895
+ "cacheWrite": undefined,
1896
+ "noCache": 18,
1897
+ "total": 18,
1898
+ },
1899
+ "outputTokens": {
1900
+ "reasoning": 0,
1901
+ "text": 439,
1902
+ "total": 439,
1903
+ },
1904
+ "raw": {
1905
+ "completion_tokens": 439,
1906
+ "prompt_tokens": 18,
1907
+ "total_tokens": 457,
1908
+ },
1909
+ },
1910
+ },
1911
+ ]
1912
+ `);
1913
+ });
1914
+
1915
+ it('should stream tool call with thought signature from extra_content', async () => {
1916
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
1917
+ type: 'stream-chunks',
1918
+ chunks: [
1919
+ // First chunk with tool call start and thought signature in extra_content
1920
+ `data: {"id":"chatcmpl-gemini-thought","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro",` +
1921
+ `"choices":[{"index":0,"delta":{"role":"assistant","content":null,` +
1922
+ `"tool_calls":[{"index":0,"id":"function-call-1","type":"function","function":{"name":"check_flight","arguments":""},` +
1923
+ `"extra_content":{"google":{"thought_signature":"<Signature A>"}}}]},` +
1924
+ `"finish_reason":null}]}\n\n`,
1925
+ // Subsequent chunks with arguments
1926
+ `data: {"id":"chatcmpl-gemini-thought","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro",` +
1927
+ `"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"flight\\":"}}]},` +
1928
+ `"finish_reason":null}]}\n\n`,
1929
+ `data: {"id":"chatcmpl-gemini-thought","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro",` +
1930
+ `"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"AA100\\"}"}}]},` +
1931
+ `"finish_reason":null}]}\n\n`,
1932
+ `data: {"id":"chatcmpl-gemini-thought","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro",` +
1933
+ `"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],` +
1934
+ `"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}\n\n`,
1935
+ 'data: [DONE]\n\n',
1936
+ ],
1937
+ };
1938
+
1939
+ const { stream } = await model.doStream({
1940
+ tools: [
1941
+ {
1942
+ type: 'function',
1943
+ name: 'check_flight',
1944
+ inputSchema: {
1945
+ type: 'object',
1946
+ properties: { flight: { type: 'string' } },
1947
+ required: ['flight'],
1948
+ additionalProperties: false,
1949
+ $schema: 'http://json-schema.org/draft-07/schema#',
1950
+ },
1951
+ },
1952
+ ],
1953
+ prompt: TEST_PROMPT,
1954
+ includeRawChunks: false,
1955
+ });
1956
+
1957
+ const result = await convertReadableStreamToArray(stream);
1958
+
1959
+ // Find the tool-call event and verify it has the thought signature in providerMetadata
1960
+ const toolCallEvent = result.find(
1961
+ (event: { type: string }) => event.type === 'tool-call',
1962
+ );
1963
+ expect(toolCallEvent).toMatchObject({
1964
+ type: 'tool-call',
1965
+ toolCallId: 'function-call-1',
1966
+ toolName: 'check_flight',
1967
+ input: '{"flight":"AA100"}',
1968
+ providerMetadata: {
1969
+ 'test-provider': {
1970
+ thoughtSignature: '<Signature A>',
1971
+ },
1972
+ },
1973
+ });
1974
+ });
1975
+
1976
+ it('should stream parallel tool calls with signature only on first call', async () => {
1977
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
1978
+ type: 'stream-chunks',
1979
+ chunks: [
1980
+ // First chunk with two tool calls - only first has thought signature
1981
+ `data: {"id":"chatcmpl-gemini-parallel","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro",` +
1982
+ `"choices":[{"index":0,"delta":{"role":"assistant","content":null,` +
1983
+ `"tool_calls":[` +
1984
+ `{"index":0,"id":"call-paris","type":"function","function":{"name":"get_weather","arguments":""},` +
1985
+ `"extra_content":{"google":{"thought_signature":"<Signature A>"}}},` +
1986
+ `{"index":1,"id":"call-london","type":"function","function":{"name":"get_weather","arguments":""}}` +
1987
+ `]},` +
1988
+ `"finish_reason":null}]}\n\n`,
1989
+ // Arguments for first call
1990
+ `data: {"id":"chatcmpl-gemini-parallel","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro",` +
1991
+ `"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"location\\":\\"Paris\\"}"}}]},` +
1992
+ `"finish_reason":null}]}\n\n`,
1993
+ // Arguments for second call
1994
+ `data: {"id":"chatcmpl-gemini-parallel","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro",` +
1995
+ `"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"location\\":\\"London\\"}"}}]},` +
1996
+ `"finish_reason":null}]}\n\n`,
1997
+ `data: {"id":"chatcmpl-gemini-parallel","object":"chat.completion.chunk","created":1711357598,"model":"gemini-3-pro",` +
1998
+ `"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],` +
1999
+ `"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}\n\n`,
2000
+ 'data: [DONE]\n\n',
2001
+ ],
2002
+ };
2003
+
2004
+ const { stream } = await model.doStream({
2005
+ tools: [
2006
+ {
2007
+ type: 'function',
2008
+ name: 'get_weather',
2009
+ inputSchema: {
2010
+ type: 'object',
2011
+ properties: { location: { type: 'string' } },
2012
+ required: ['location'],
2013
+ additionalProperties: false,
2014
+ $schema: 'http://json-schema.org/draft-07/schema#',
2015
+ },
2016
+ },
2017
+ ],
2018
+ prompt: TEST_PROMPT,
2019
+ includeRawChunks: false,
2020
+ });
2021
+
2022
+ const result = await convertReadableStreamToArray(stream);
2023
+
2024
+ const toolCallEvents = result.filter(
2025
+ (event: { type: string }) => event.type === 'tool-call',
2026
+ );
2027
+
2028
+ expect(toolCallEvents).toHaveLength(2);
2029
+
2030
+ // First tool call should have thought signature
2031
+ expect(toolCallEvents[0]).toMatchObject({
2032
+ type: 'tool-call',
2033
+ toolCallId: 'call-paris',
2034
+ toolName: 'get_weather',
2035
+ providerMetadata: {
2036
+ 'test-provider': {
2037
+ thoughtSignature: '<Signature A>',
2038
+ },
2039
+ },
2040
+ });
2041
+
2042
+ // Second tool call should NOT have thought signature
2043
+ expect(toolCallEvents[1]).toMatchObject({
2044
+ type: 'tool-call',
2045
+ toolCallId: 'call-london',
2046
+ toolName: 'get_weather',
2047
+ });
2048
+ expect(
2049
+ (toolCallEvents[1] as { providerMetadata?: unknown }).providerMetadata,
2050
+ ).toBeUndefined();
2051
+ });
2052
+
2053
+ it('should stream tool call deltas when tool call arguments are passed in the first chunk', async () => {
2054
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2055
+ type: 'stream-chunks',
2056
+ chunks: [
2057
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2058
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,` +
2059
+ `"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":"{\\""}}]},` +
2060
+ `"finish_reason":null}]}\n\n`,
2061
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2062
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"va"}}]},` +
2063
+ `"finish_reason":null}]}\n\n`,
2064
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2065
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"lue"}}]},` +
2066
+ `"finish_reason":null}]}\n\n`,
2067
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2068
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":\\""}}]},` +
2069
+ `"finish_reason":null}]}\n\n`,
2070
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2071
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Spark"}}]},` +
2072
+ `"finish_reason":null}]}\n\n`,
2073
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2074
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"le"}}]},` +
2075
+ `"finish_reason":null}]}\n\n`,
2076
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2077
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Day"}}]},` +
2078
+ `"finish_reason":null}]}\n\n`,
2079
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2080
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"}"}}]},` +
2081
+ `"finish_reason":null}]}\n\n`,
2082
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-beta",` +
2083
+ `"system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],` +
2084
+ `"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,` +
2085
+ `"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}\n\n`,
2086
+ 'data: [DONE]\n\n',
2087
+ ],
2088
+ };
2089
+
2090
+ const { stream } = await model.doStream({
2091
+ tools: [
2092
+ {
2093
+ type: 'function',
2094
+ name: 'test-tool',
2095
+ inputSchema: {
2096
+ type: 'object',
2097
+ properties: { value: { type: 'string' } },
2098
+ required: ['value'],
2099
+ additionalProperties: false,
2100
+ $schema: 'http://json-schema.org/draft-07/schema#',
2101
+ },
2102
+ },
2103
+ ],
2104
+ prompt: TEST_PROMPT,
2105
+ includeRawChunks: false,
2106
+ });
2107
+
2108
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
2109
+ [
2110
+ {
2111
+ "type": "stream-start",
2112
+ "warnings": [],
2113
+ },
2114
+ {
2115
+ "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798",
2116
+ "modelId": "grok-beta",
2117
+ "timestamp": 2024-03-25T09:06:38.000Z,
2118
+ "type": "response-metadata",
2119
+ },
2120
+ {
2121
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2122
+ "toolName": "test-tool",
2123
+ "type": "tool-input-start",
2124
+ },
2125
+ {
2126
+ "delta": "{"",
2127
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2128
+ "type": "tool-input-delta",
2129
+ },
2130
+ {
2131
+ "delta": "va",
2132
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2133
+ "type": "tool-input-delta",
2134
+ },
2135
+ {
2136
+ "delta": "lue",
2137
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2138
+ "type": "tool-input-delta",
2139
+ },
2140
+ {
2141
+ "delta": "":"",
2142
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2143
+ "type": "tool-input-delta",
2144
+ },
2145
+ {
2146
+ "delta": "Spark",
2147
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2148
+ "type": "tool-input-delta",
2149
+ },
2150
+ {
2151
+ "delta": "le",
2152
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2153
+ "type": "tool-input-delta",
2154
+ },
2155
+ {
2156
+ "delta": " Day",
2157
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2158
+ "type": "tool-input-delta",
2159
+ },
2160
+ {
2161
+ "delta": ""}",
2162
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2163
+ "type": "tool-input-delta",
2164
+ },
2165
+ {
2166
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2167
+ "type": "tool-input-end",
2168
+ },
2169
+ {
2170
+ "input": "{"value":"Sparkle Day"}",
2171
+ "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2172
+ "toolName": "test-tool",
2173
+ "type": "tool-call",
2174
+ },
2175
+ {
2176
+ "finishReason": {
2177
+ "raw": "tool_calls",
2178
+ "unified": "tool-calls",
2179
+ },
2180
+ "providerMetadata": {
2181
+ "test-provider": {},
2182
+ },
2183
+ "type": "finish",
2184
+ "usage": {
2185
+ "inputTokens": {
2186
+ "cacheRead": 0,
2187
+ "cacheWrite": undefined,
2188
+ "noCache": 18,
2189
+ "total": 18,
2190
+ },
2191
+ "outputTokens": {
2192
+ "reasoning": 0,
2193
+ "text": 439,
2194
+ "total": 439,
2195
+ },
2196
+ "raw": {
2197
+ "completion_tokens": 439,
2198
+ "prompt_tokens": 18,
2199
+ "total_tokens": 457,
2200
+ },
2201
+ },
2202
+ },
2203
+ ]
2204
+ `);
2205
+ });
2206
+
2207
+ it('should not duplicate tool calls when there is an additional empty chunk after the tool call has been completed', async () => {
2208
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2209
+ type: 'stream-chunks',
2210
+ chunks: [
2211
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2212
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],` +
2213
+ `"usage":{"prompt_tokens":226,"total_tokens":226,"completion_tokens":0}}\n\n`,
2214
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2215
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"id":"chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",` +
2216
+ `"type":"function","index":0,"function":{"name":"searchGoogle"}}]},"logprobs":null,"finish_reason":null}],` +
2217
+ `"usage":{"prompt_tokens":226,"total_tokens":233,"completion_tokens":7}}\n\n`,
2218
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2219
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,` +
2220
+ `"function":{"arguments":"{\\"query\\": \\""}}]},"logprobs":null,"finish_reason":null}],` +
2221
+ `"usage":{"prompt_tokens":226,"total_tokens":241,"completion_tokens":15}}\n\n`,
2222
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2223
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,` +
2224
+ `"function":{"arguments":"latest"}}]},"logprobs":null,"finish_reason":null}],` +
2225
+ `"usage":{"prompt_tokens":226,"total_tokens":242,"completion_tokens":16}}\n\n`,
2226
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2227
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,` +
2228
+ `"function":{"arguments":" news"}}]},"logprobs":null,"finish_reason":null}],` +
2229
+ `"usage":{"prompt_tokens":226,"total_tokens":243,"completion_tokens":17}}\n\n`,
2230
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2231
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,` +
2232
+ `"function":{"arguments":" on"}}]},"logprobs":null,"finish_reason":null}],` +
2233
+ `"usage":{"prompt_tokens":226,"total_tokens":244,"completion_tokens":18}}\n\n`,
2234
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2235
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,` +
2236
+ `"function":{"arguments":" ai\\"}"}}]},"logprobs":null,"finish_reason":null}],` +
2237
+ `"usage":{"prompt_tokens":226,"total_tokens":245,"completion_tokens":19}}\n\n`,
2238
+ // empty arguments chunk after the tool call has already been finished:
2239
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2240
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,` +
2241
+ `"function":{"arguments":""}}]},"logprobs":null,"finish_reason":"tool_calls","stop_reason":128008}],` +
2242
+ `"usage":{"prompt_tokens":226,"total_tokens":246,"completion_tokens":20}}\n\n`,
2243
+ `data: {"id":"chat-2267f7e2910a4254bac0650ba74cfc1c","object":"chat.completion.chunk","created":1733162241,` +
2244
+ `"model":"meta/llama-3.1-8b-instruct:fp8","choices":[],` +
2245
+ `"usage":{"prompt_tokens":226,"total_tokens":246,"completion_tokens":20}}\n\n`,
2246
+ `data: [DONE]\n\n`,
2247
+ ],
2248
+ };
2249
+
2250
+ const { stream } = await model.doStream({
2251
+ tools: [
2252
+ {
2253
+ type: 'function',
2254
+ name: 'searchGoogle',
2255
+ inputSchema: {
2256
+ type: 'object',
2257
+ properties: { query: { type: 'string' } },
2258
+ required: ['query'],
2259
+ additionalProperties: false,
2260
+ $schema: 'http://json-schema.org/draft-07/schema#',
2261
+ },
2262
+ },
2263
+ ],
2264
+ prompt: TEST_PROMPT,
2265
+ includeRawChunks: false,
2266
+ });
2267
+
2268
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
2269
+ [
2270
+ {
2271
+ "type": "stream-start",
2272
+ "warnings": [],
2273
+ },
2274
+ {
2275
+ "id": "chat-2267f7e2910a4254bac0650ba74cfc1c",
2276
+ "modelId": "meta/llama-3.1-8b-instruct:fp8",
2277
+ "timestamp": 2024-12-02T17:57:21.000Z,
2278
+ "type": "response-metadata",
2279
+ },
2280
+ {
2281
+ "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
2282
+ "toolName": "searchGoogle",
2283
+ "type": "tool-input-start",
2284
+ },
2285
+ {
2286
+ "delta": "{"query": "",
2287
+ "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
2288
+ "type": "tool-input-delta",
2289
+ },
2290
+ {
2291
+ "delta": "latest",
2292
+ "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
2293
+ "type": "tool-input-delta",
2294
+ },
2295
+ {
2296
+ "delta": " news",
2297
+ "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
2298
+ "type": "tool-input-delta",
2299
+ },
2300
+ {
2301
+ "delta": " on",
2302
+ "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
2303
+ "type": "tool-input-delta",
2304
+ },
2305
+ {
2306
+ "delta": " ai"}",
2307
+ "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
2308
+ "type": "tool-input-delta",
2309
+ },
2310
+ {
2311
+ "id": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
2312
+ "type": "tool-input-end",
2313
+ },
2314
+ {
2315
+ "input": "{"query": "latest news on ai"}",
2316
+ "toolCallId": "chatcmpl-tool-b3b307239370432d9910d4b79b4dbbaa",
2317
+ "toolName": "searchGoogle",
2318
+ "type": "tool-call",
2319
+ },
2320
+ {
2321
+ "finishReason": {
2322
+ "raw": "tool_calls",
2323
+ "unified": "tool-calls",
2324
+ },
2325
+ "providerMetadata": {
2326
+ "test-provider": {},
2327
+ },
2328
+ "type": "finish",
2329
+ "usage": {
2330
+ "inputTokens": {
2331
+ "cacheRead": 0,
2332
+ "cacheWrite": undefined,
2333
+ "noCache": 226,
2334
+ "total": 226,
2335
+ },
2336
+ "outputTokens": {
2337
+ "reasoning": 0,
2338
+ "text": 20,
2339
+ "total": 20,
2340
+ },
2341
+ "raw": {
2342
+ "completion_tokens": 20,
2343
+ "prompt_tokens": 226,
2344
+ "total_tokens": 246,
2345
+ },
2346
+ },
2347
+ },
2348
+ ]
2349
+ `);
2350
+ });
2351
+
2352
+ it('should stream tool call that is sent in one chunk', async () => {
2353
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2354
+ type: 'stream-chunks',
2355
+ chunks: [
2356
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2357
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,` +
2358
+ `"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":"{\\"value\\":\\"Sparkle Day\\"}"}}]},` +
2359
+ `"finish_reason":null}]}\n\n`,
2360
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-beta",` +
2361
+ `"system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],` +
2362
+ `"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,` +
2363
+ `"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}\n\n`,
2364
+ 'data: [DONE]\n\n',
2365
+ ],
2366
+ };
2367
+
2368
+ const { stream } = await model.doStream({
2369
+ tools: [
2370
+ {
2371
+ type: 'function',
2372
+ name: 'test-tool',
2373
+ inputSchema: {
2374
+ type: 'object',
2375
+ properties: { value: { type: 'string' } },
2376
+ required: ['value'],
2377
+ additionalProperties: false,
2378
+ $schema: 'http://json-schema.org/draft-07/schema#',
2379
+ },
2380
+ },
2381
+ ],
2382
+ prompt: TEST_PROMPT,
2383
+ includeRawChunks: false,
2384
+ });
2385
+
2386
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
2387
+ [
2388
+ {
2389
+ "type": "stream-start",
2390
+ "warnings": [],
2391
+ },
2392
+ {
2393
+ "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798",
2394
+ "modelId": "grok-beta",
2395
+ "timestamp": 2024-03-25T09:06:38.000Z,
2396
+ "type": "response-metadata",
2397
+ },
2398
+ {
2399
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2400
+ "toolName": "test-tool",
2401
+ "type": "tool-input-start",
2402
+ },
2403
+ {
2404
+ "delta": "{"value":"Sparkle Day"}",
2405
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2406
+ "type": "tool-input-delta",
2407
+ },
2408
+ {
2409
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2410
+ "type": "tool-input-end",
2411
+ },
2412
+ {
2413
+ "input": "{"value":"Sparkle Day"}",
2414
+ "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2415
+ "toolName": "test-tool",
2416
+ "type": "tool-call",
2417
+ },
2418
+ {
2419
+ "finishReason": {
2420
+ "raw": "tool_calls",
2421
+ "unified": "tool-calls",
2422
+ },
2423
+ "providerMetadata": {
2424
+ "test-provider": {},
2425
+ },
2426
+ "type": "finish",
2427
+ "usage": {
2428
+ "inputTokens": {
2429
+ "cacheRead": 0,
2430
+ "cacheWrite": undefined,
2431
+ "noCache": 18,
2432
+ "total": 18,
2433
+ },
2434
+ "outputTokens": {
2435
+ "reasoning": 0,
2436
+ "text": 439,
2437
+ "total": 439,
2438
+ },
2439
+ "raw": {
2440
+ "completion_tokens": 439,
2441
+ "prompt_tokens": 18,
2442
+ "total_tokens": 457,
2443
+ },
2444
+ },
2445
+ },
2446
+ ]
2447
+ `);
2448
+ });
2449
+
2450
+ it('should stream empty tool call that is sent in one chunk', async () => {
2451
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2452
+ type: 'stream-chunks',
2453
+ chunks: [
2454
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1711357598,"model":"grok-beta",` +
2455
+ `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,` +
2456
+ `"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":""}}]},` +
2457
+ `"finish_reason":null}]}\n\n`,
2458
+ `data: {"id":"chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798","object":"chat.completion.chunk","created":1729171479,"model":"grok-beta",` +
2459
+ `"system_fingerprint":"fp_10c08bf97d","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],` +
2460
+ `"usage":{"queue_time":0.061348671,"prompt_tokens":18,"prompt_time":0.000211569,` +
2461
+ `"completion_tokens":439,"completion_time":0.798181818,"total_tokens":457,"total_time":0.798393387}}\n\n`,
2462
+ 'data: [DONE]\n\n',
2463
+ ],
2464
+ };
2465
+
2466
+ const { stream } = await model.doStream({
2467
+ tools: [
2468
+ {
2469
+ type: 'function',
2470
+ name: 'test-tool',
2471
+ inputSchema: {
2472
+ type: 'object',
2473
+ properties: {},
2474
+ additionalProperties: false,
2475
+ $schema: 'http://json-schema.org/draft-07/schema#',
2476
+ },
2477
+ },
2478
+ ],
2479
+ prompt: TEST_PROMPT,
2480
+ includeRawChunks: false,
2481
+ });
2482
+
2483
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
2484
+ [
2485
+ {
2486
+ "type": "stream-start",
2487
+ "warnings": [],
2488
+ },
2489
+ {
2490
+ "id": "chatcmpl-e7f8e220-656c-4455-a132-dacfc1370798",
2491
+ "modelId": "grok-beta",
2492
+ "timestamp": 2024-03-25T09:06:38.000Z,
2493
+ "type": "response-metadata",
2494
+ },
2495
+ {
2496
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2497
+ "toolName": "test-tool",
2498
+ "type": "tool-input-start",
2499
+ },
2500
+ {
2501
+ "id": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2502
+ "type": "tool-input-end",
2503
+ },
2504
+ {
2505
+ "input": "",
2506
+ "toolCallId": "call_O17Uplv4lJvD6DVdIvFFeRMw",
2507
+ "toolName": "test-tool",
2508
+ "type": "tool-call",
2509
+ },
2510
+ {
2511
+ "finishReason": {
2512
+ "raw": "tool_calls",
2513
+ "unified": "tool-calls",
2514
+ },
2515
+ "providerMetadata": {
2516
+ "test-provider": {},
2517
+ },
2518
+ "type": "finish",
2519
+ "usage": {
2520
+ "inputTokens": {
2521
+ "cacheRead": 0,
2522
+ "cacheWrite": undefined,
2523
+ "noCache": 18,
2524
+ "total": 18,
2525
+ },
2526
+ "outputTokens": {
2527
+ "reasoning": 0,
2528
+ "text": 439,
2529
+ "total": 439,
2530
+ },
2531
+ "raw": {
2532
+ "completion_tokens": 439,
2533
+ "prompt_tokens": 18,
2534
+ "total_tokens": 457,
2535
+ },
2536
+ },
2537
+ },
2538
+ ]
2539
+ `);
2540
+ });
2541
+
2542
+ it('should handle error stream parts', async () => {
2543
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2544
+ type: 'stream-chunks',
2545
+ chunks: [
2546
+ `data: {"error": {"message": "Incorrect API key provided: as***T7. You can obtain an API key from https://console.api.com.", "code": "Client specified an invalid argument"}}\n\n`,
2547
+ 'data: [DONE]\n\n',
2548
+ ],
2549
+ };
2550
+
2551
+ const { stream } = await model.doStream({
2552
+ prompt: TEST_PROMPT,
2553
+ includeRawChunks: false,
2554
+ });
2555
+
2556
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
2557
+ [
2558
+ {
2559
+ "type": "stream-start",
2560
+ "warnings": [],
2561
+ },
2562
+ {
2563
+ "error": "Incorrect API key provided: as***T7. You can obtain an API key from https://console.api.com.",
2564
+ "type": "error",
2565
+ },
2566
+ {
2567
+ "finishReason": {
2568
+ "raw": undefined,
2569
+ "unified": "error",
2570
+ },
2571
+ "providerMetadata": {
2572
+ "test-provider": {},
2573
+ },
2574
+ "type": "finish",
2575
+ "usage": {
2576
+ "inputTokens": {
2577
+ "cacheRead": undefined,
2578
+ "cacheWrite": undefined,
2579
+ "noCache": undefined,
2580
+ "total": undefined,
2581
+ },
2582
+ "outputTokens": {
2583
+ "reasoning": undefined,
2584
+ "text": undefined,
2585
+ "total": undefined,
2586
+ },
2587
+ "raw": undefined,
2588
+ },
2589
+ },
2590
+ ]
2591
+ `);
2592
+ });
2593
+
2594
+ it.skipIf(isNodeVersion(20))(
2595
+ 'should handle unparsable stream parts',
2596
+ async () => {
2597
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2598
+ type: 'stream-chunks',
2599
+ chunks: [`data: {unparsable}\n\n`, 'data: [DONE]\n\n'],
2600
+ };
2601
+
2602
+ const { stream } = await model.doStream({
2603
+ prompt: TEST_PROMPT,
2604
+ includeRawChunks: false,
2605
+ });
2606
+
2607
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
2608
+ [
2609
+ {
2610
+ "type": "stream-start",
2611
+ "warnings": [],
2612
+ },
2613
+ {
2614
+ "error": [AI_JSONParseError: JSON parsing failed: Text: {unparsable}.
2615
+ Error message: Expected property name or '}' in JSON at position 1 (line 1 column 2)],
2616
+ "type": "error",
2617
+ },
2618
+ {
2619
+ "finishReason": {
2620
+ "raw": undefined,
2621
+ "unified": "error",
2622
+ },
2623
+ "providerMetadata": {
2624
+ "test-provider": {},
2625
+ },
2626
+ "type": "finish",
2627
+ "usage": {
2628
+ "inputTokens": {
2629
+ "cacheRead": undefined,
2630
+ "cacheWrite": undefined,
2631
+ "noCache": undefined,
2632
+ "total": undefined,
2633
+ },
2634
+ "outputTokens": {
2635
+ "reasoning": undefined,
2636
+ "text": undefined,
2637
+ "total": undefined,
2638
+ },
2639
+ "raw": undefined,
2640
+ },
2641
+ },
2642
+ ]
2643
+ `);
2644
+ },
2645
+ );
2646
+
2647
+ it('should expose the raw response headers', async () => {
2648
+ prepareStreamResponse({
2649
+ headers: { 'test-header': 'test-value' },
2650
+ });
2651
+
2652
+ const { response } = await model.doStream({
2653
+ prompt: TEST_PROMPT,
2654
+ includeRawChunks: false,
2655
+ });
2656
+
2657
+ expect(response?.headers).toStrictEqual({
2658
+ // default headers:
2659
+ 'content-type': 'text/event-stream',
2660
+ 'cache-control': 'no-cache',
2661
+ connection: 'keep-alive',
2662
+
2663
+ // custom header
2664
+ 'test-header': 'test-value',
2665
+ });
2666
+ });
2667
+
2668
+ it('should pass the messages and the model', async () => {
2669
+ prepareStreamResponse({ content: [] });
2670
+
2671
+ await model.doStream({
2672
+ prompt: TEST_PROMPT,
2673
+ includeRawChunks: false,
2674
+ });
2675
+
2676
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
2677
+ stream: true,
2678
+ model: 'grok-beta',
2679
+ messages: [{ role: 'user', content: 'Hello' }],
2680
+ });
2681
+ });
2682
+
2683
+ it('should pass headers', async () => {
2684
+ prepareStreamResponse({ content: [] });
2685
+
2686
+ const provider = createOpenAICompatible({
2687
+ baseURL: 'https://my.api.com/v1',
2688
+ name: 'test-provider',
2689
+ headers: {
2690
+ Authorization: `Bearer test-api-key`,
2691
+ 'Custom-Provider-Header': 'provider-header-value',
2692
+ },
2693
+ });
2694
+
2695
+ await provider('grok-beta').doStream({
2696
+ prompt: TEST_PROMPT,
2697
+ includeRawChunks: false,
2698
+ headers: {
2699
+ 'Custom-Request-Header': 'request-header-value',
2700
+ },
2701
+ });
2702
+
2703
+ expect(await server.calls[0].requestHeaders).toStrictEqual({
2704
+ authorization: 'Bearer test-api-key',
2705
+ 'content-type': 'application/json',
2706
+ 'custom-provider-header': 'provider-header-value',
2707
+ 'custom-request-header': 'request-header-value',
2708
+ });
2709
+ });
2710
+
2711
+ it('should include provider-specific options', async () => {
2712
+ prepareStreamResponse({ content: [] });
2713
+
2714
+ await provider('grok-beta').doStream({
2715
+ providerOptions: {
2716
+ 'test-provider': {
2717
+ someCustomOption: 'test-value',
2718
+ },
2719
+ },
2720
+ prompt: TEST_PROMPT,
2721
+ includeRawChunks: false,
2722
+ });
2723
+
2724
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
2725
+ stream: true,
2726
+ model: 'grok-beta',
2727
+ messages: [{ role: 'user', content: 'Hello' }],
2728
+ someCustomOption: 'test-value',
2729
+ });
2730
+ });
2731
+
2732
+ it('should not include provider-specific options for different provider', async () => {
2733
+ prepareStreamResponse({ content: [] });
2734
+
2735
+ await provider('grok-beta').doStream({
2736
+ providerOptions: {
2737
+ notThisProviderName: {
2738
+ someCustomOption: 'test-value',
2739
+ },
2740
+ },
2741
+ prompt: TEST_PROMPT,
2742
+ includeRawChunks: false,
2743
+ });
2744
+
2745
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
2746
+ stream: true,
2747
+ model: 'grok-beta',
2748
+ messages: [{ role: 'user', content: 'Hello' }],
2749
+ });
2750
+ });
2751
+
2752
+ it('should send request body', async () => {
2753
+ prepareStreamResponse({ content: [] });
2754
+
2755
+ const { request } = await model.doStream({
2756
+ prompt: TEST_PROMPT,
2757
+ includeRawChunks: false,
2758
+ });
2759
+
2760
+ expect(request).toMatchInlineSnapshot(`
2761
+ {
2762
+ "body": {
2763
+ "frequency_penalty": undefined,
2764
+ "max_tokens": undefined,
2765
+ "messages": [
2766
+ {
2767
+ "content": "Hello",
2768
+ "role": "user",
2769
+ },
2770
+ ],
2771
+ "model": "grok-beta",
2772
+ "presence_penalty": undefined,
2773
+ "reasoning_effort": undefined,
2774
+ "response_format": undefined,
2775
+ "seed": undefined,
2776
+ "stop": undefined,
2777
+ "stream": true,
2778
+ "stream_options": undefined,
2779
+ "temperature": undefined,
2780
+ "tool_choice": undefined,
2781
+ "tools": undefined,
2782
+ "top_p": undefined,
2783
+ "user": undefined,
2784
+ "verbosity": undefined,
2785
+ },
2786
+ }
2787
+ `);
2788
+ });
2789
+
2790
+ describe('usage details in streaming', () => {
2791
+ it('should extract detailed token usage from stream finish', async () => {
2792
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2793
+ type: 'stream-chunks',
2794
+ chunks: [
2795
+ `data: {"id":"chat-id","choices":[{"delta":{"content":"Hello"}}]}\n\n`,
2796
+ `data: {"choices":[{"delta":{},"finish_reason":"stop"}],` +
2797
+ `"usage":{"prompt_tokens":20,"completion_tokens":30,` +
2798
+ `"prompt_tokens_details":{"cached_tokens":5},` +
2799
+ `"completion_tokens_details":{` +
2800
+ `"reasoning_tokens":10,` +
2801
+ `"accepted_prediction_tokens":15,` +
2802
+ `"rejected_prediction_tokens":5}}}\n\n`,
2803
+ 'data: [DONE]\n\n',
2804
+ ],
2805
+ };
2806
+
2807
+ const { stream } = await model.doStream({
2808
+ prompt: TEST_PROMPT,
2809
+ includeRawChunks: false,
2810
+ });
2811
+
2812
+ const parts = await convertReadableStreamToArray(stream);
2813
+ const finishPart = parts.find(part => part.type === 'finish');
2814
+
2815
+ expect(finishPart).toMatchInlineSnapshot(`
2816
+ {
2817
+ "finishReason": {
2818
+ "raw": "stop",
2819
+ "unified": "stop",
2820
+ },
2821
+ "providerMetadata": {
2822
+ "test-provider": {
2823
+ "acceptedPredictionTokens": 15,
2824
+ "rejectedPredictionTokens": 5,
2825
+ },
2826
+ },
2827
+ "type": "finish",
2828
+ "usage": {
2829
+ "inputTokens": {
2830
+ "cacheRead": 5,
2831
+ "cacheWrite": undefined,
2832
+ "noCache": 15,
2833
+ "total": 20,
2834
+ },
2835
+ "outputTokens": {
2836
+ "reasoning": 10,
2837
+ "text": 20,
2838
+ "total": 30,
2839
+ },
2840
+ "raw": {
2841
+ "completion_tokens": 30,
2842
+ "completion_tokens_details": {
2843
+ "accepted_prediction_tokens": 15,
2844
+ "reasoning_tokens": 10,
2845
+ "rejected_prediction_tokens": 5,
2846
+ },
2847
+ "prompt_tokens": 20,
2848
+ "prompt_tokens_details": {
2849
+ "cached_tokens": 5,
2850
+ },
2851
+ },
2852
+ },
2853
+ }
2854
+ `);
2855
+ });
2856
+
2857
+ it('should handle missing token details in stream', async () => {
2858
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2859
+ type: 'stream-chunks',
2860
+ chunks: [
2861
+ `data: {"id":"chat-id","choices":[{"delta":{"content":"Hello"}}]}\n\n`,
2862
+ `data: {"choices":[{"delta":{},"finish_reason":"stop"}],` +
2863
+ `"usage":{"prompt_tokens":20,"completion_tokens":30}}\n\n`,
2864
+ 'data: [DONE]\n\n',
2865
+ ],
2866
+ };
2867
+
2868
+ const { stream } = await model.doStream({
2869
+ prompt: TEST_PROMPT,
2870
+ includeRawChunks: false,
2871
+ });
2872
+
2873
+ const parts = await convertReadableStreamToArray(stream);
2874
+ const finishPart = parts.find(part => part.type === 'finish');
2875
+
2876
+ expect(finishPart?.providerMetadata!['test-provider']).toStrictEqual({});
2877
+ });
2878
+
2879
+ it('should handle partial token details in stream', async () => {
2880
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2881
+ type: 'stream-chunks',
2882
+ chunks: [
2883
+ `data: {"id":"chat-id","choices":[{"delta":{"content":"Hello"}}]}\n\n`,
2884
+ `data: {"choices":[{"delta":{},"finish_reason":"stop"}],` +
2885
+ `"usage":{"prompt_tokens":20,"completion_tokens":30,` +
2886
+ `"total_tokens":50,` +
2887
+ `"prompt_tokens_details":{"cached_tokens":5},` +
2888
+ `"completion_tokens_details":{"reasoning_tokens":10}}}\n\n`,
2889
+ 'data: [DONE]\n\n',
2890
+ ],
2891
+ };
2892
+
2893
+ const { stream } = await model.doStream({
2894
+ prompt: TEST_PROMPT,
2895
+ includeRawChunks: false,
2896
+ });
2897
+
2898
+ const parts = await convertReadableStreamToArray(stream);
2899
+ const finishPart = parts.find(part => part.type === 'finish');
2900
+
2901
+ expect(finishPart).toMatchInlineSnapshot(`
2902
+ {
2903
+ "finishReason": {
2904
+ "raw": "stop",
2905
+ "unified": "stop",
2906
+ },
2907
+ "providerMetadata": {
2908
+ "test-provider": {},
2909
+ },
2910
+ "type": "finish",
2911
+ "usage": {
2912
+ "inputTokens": {
2913
+ "cacheRead": 5,
2914
+ "cacheWrite": undefined,
2915
+ "noCache": 15,
2916
+ "total": 20,
2917
+ },
2918
+ "outputTokens": {
2919
+ "reasoning": 10,
2920
+ "text": 20,
2921
+ "total": 30,
2922
+ },
2923
+ "raw": {
2924
+ "completion_tokens": 30,
2925
+ "completion_tokens_details": {
2926
+ "reasoning_tokens": 10,
2927
+ },
2928
+ "prompt_tokens": 20,
2929
+ "prompt_tokens_details": {
2930
+ "cached_tokens": 5,
2931
+ },
2932
+ "total_tokens": 50,
2933
+ },
2934
+ },
2935
+ }
2936
+ `);
2937
+ });
2938
+ });
2939
+ });
2940
+
2941
+ describe('metadata extraction', () => {
2942
+ const testMetadataExtractor = {
2943
+ extractMetadata: async ({ parsedBody }: { parsedBody: unknown }) => {
2944
+ if (
2945
+ typeof parsedBody !== 'object' ||
2946
+ !parsedBody ||
2947
+ !('test_field' in parsedBody)
2948
+ ) {
2949
+ return undefined;
2950
+ }
2951
+ return {
2952
+ 'test-provider': {
2953
+ value: parsedBody.test_field as string,
2954
+ },
2955
+ };
2956
+ },
2957
+ createStreamExtractor: () => {
2958
+ let accumulatedValue: string | undefined;
2959
+
2960
+ return {
2961
+ processChunk: (chunk: unknown) => {
2962
+ if (
2963
+ typeof chunk === 'object' &&
2964
+ chunk &&
2965
+ 'choices' in chunk &&
2966
+ Array.isArray(chunk.choices) &&
2967
+ chunk.choices[0]?.finish_reason === 'stop' &&
2968
+ 'test_field' in chunk
2969
+ ) {
2970
+ accumulatedValue = chunk.test_field as string;
2971
+ }
2972
+ },
2973
+ buildMetadata: () =>
2974
+ accumulatedValue
2975
+ ? {
2976
+ 'test-provider': {
2977
+ value: accumulatedValue,
2978
+ },
2979
+ }
2980
+ : undefined,
2981
+ };
2982
+ },
2983
+ };
2984
+
2985
+ it('should process metadata from complete response', async () => {
2986
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
2987
+ type: 'json-value',
2988
+ body: {
2989
+ id: 'chatcmpl-123',
2990
+ object: 'chat.completion',
2991
+ created: 1711115037,
2992
+ model: 'gpt-4',
2993
+ choices: [
2994
+ {
2995
+ index: 0,
2996
+ message: {
2997
+ role: 'assistant',
2998
+ },
2999
+ finish_reason: 'stop',
3000
+ },
3001
+ ],
3002
+ test_field: 'test_value',
3003
+ },
3004
+ };
3005
+
3006
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4', {
3007
+ provider: 'test-provider',
3008
+ url: () => 'https://my.api.com/v1/chat/completions',
3009
+ headers: () => ({}),
3010
+ metadataExtractor: testMetadataExtractor,
3011
+ });
3012
+
3013
+ const result = await model.doGenerate({
3014
+ prompt: TEST_PROMPT,
3015
+ });
3016
+
3017
+ expect(result.providerMetadata).toEqual({
3018
+ 'test-provider': {
3019
+ value: 'test_value',
3020
+ },
3021
+ });
3022
+
3023
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
3024
+ model: 'gpt-4',
3025
+ messages: [{ role: 'user', content: 'Hello' }],
3026
+ });
3027
+ });
3028
+
3029
+ it('should process metadata from streaming response', async () => {
3030
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
3031
+ type: 'stream-chunks',
3032
+ chunks: [
3033
+ 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
3034
+ 'data: {"choices":[{"finish_reason":"stop"}],"test_field":"test_value"}\n\n',
3035
+ 'data: [DONE]\n\n',
3036
+ ],
3037
+ };
3038
+
3039
+ const model = new OpenAICompatibleChatLanguageModel('gpt-4', {
3040
+ provider: 'test-provider',
3041
+ url: () => 'https://my.api.com/v1/chat/completions',
3042
+ headers: () => ({}),
3043
+ metadataExtractor: testMetadataExtractor,
3044
+ });
3045
+
3046
+ const result = await model.doStream({
3047
+ prompt: TEST_PROMPT,
3048
+ includeRawChunks: false,
3049
+ });
3050
+
3051
+ const parts = await convertReadableStreamToArray(result.stream);
3052
+ const finishPart = parts.find(part => part.type === 'finish');
3053
+
3054
+ expect(finishPart?.providerMetadata).toEqual({
3055
+ 'test-provider': {
3056
+ value: 'test_value',
3057
+ },
3058
+ });
3059
+
3060
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
3061
+ model: 'gpt-4',
3062
+ messages: [{ role: 'user', content: 'Hello' }],
3063
+ stream: true,
3064
+ });
3065
+ });
3066
+ });
3067
+
3068
+ describe('raw chunks', () => {
3069
+ it('should include raw chunks when includeRawChunks is true', async () => {
3070
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
3071
+ type: 'stream-chunks',
3072
+ chunks: [
3073
+ `data: {"id":"chat-id","choices":[{"delta":{"content":"Hello"}}]}\n\n`,
3074
+ `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n`,
3075
+ 'data: [DONE]\n\n',
3076
+ ],
3077
+ };
3078
+
3079
+ const { stream } = await model.doStream({
3080
+ prompt: TEST_PROMPT,
3081
+ includeRawChunks: true,
3082
+ });
3083
+
3084
+ expect(await convertReadableStreamToArray(stream)).toMatchInlineSnapshot(`
3085
+ [
3086
+ {
3087
+ "type": "stream-start",
3088
+ "warnings": [],
3089
+ },
3090
+ {
3091
+ "rawValue": {
3092
+ "choices": [
3093
+ {
3094
+ "delta": {
3095
+ "content": "Hello",
3096
+ },
3097
+ },
3098
+ ],
3099
+ "id": "chat-id",
3100
+ },
3101
+ "type": "raw",
3102
+ },
3103
+ {
3104
+ "id": "chat-id",
3105
+ "modelId": undefined,
3106
+ "timestamp": undefined,
3107
+ "type": "response-metadata",
3108
+ },
3109
+ {
3110
+ "id": "txt-0",
3111
+ "type": "text-start",
3112
+ },
3113
+ {
3114
+ "delta": "Hello",
3115
+ "id": "txt-0",
3116
+ "type": "text-delta",
3117
+ },
3118
+ {
3119
+ "rawValue": {
3120
+ "choices": [
3121
+ {
3122
+ "delta": {},
3123
+ "finish_reason": "stop",
3124
+ },
3125
+ ],
3126
+ },
3127
+ "type": "raw",
3128
+ },
3129
+ {
3130
+ "id": "txt-0",
3131
+ "type": "text-end",
3132
+ },
3133
+ {
3134
+ "finishReason": {
3135
+ "raw": "stop",
3136
+ "unified": "stop",
3137
+ },
3138
+ "providerMetadata": {
3139
+ "test-provider": {},
3140
+ },
3141
+ "type": "finish",
3142
+ "usage": {
3143
+ "inputTokens": {
3144
+ "cacheRead": undefined,
3145
+ "cacheWrite": undefined,
3146
+ "noCache": undefined,
3147
+ "total": undefined,
3148
+ },
3149
+ "outputTokens": {
3150
+ "reasoning": undefined,
3151
+ "text": undefined,
3152
+ "total": undefined,
3153
+ },
3154
+ "raw": undefined,
3155
+ },
3156
+ },
3157
+ ]
3158
+ `);
3159
+ });
3160
+ });
3161
+
3162
+ describe('transformRequestBody', () => {
3163
+ function prepareTransformJsonResponse() {
3164
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
3165
+ type: 'json-value',
3166
+ body: {
3167
+ id: 'chatcmpl-test',
3168
+ object: 'chat.completion',
3169
+ created: 1711115037,
3170
+ model: 'grok-beta',
3171
+ choices: [
3172
+ {
3173
+ index: 0,
3174
+ message: {
3175
+ role: 'assistant',
3176
+ content: 'Hello!',
3177
+ },
3178
+ finish_reason: 'stop',
3179
+ },
3180
+ ],
3181
+ usage: {
3182
+ prompt_tokens: 4,
3183
+ total_tokens: 34,
3184
+ completion_tokens: 30,
3185
+ },
3186
+ },
3187
+ };
3188
+ }
3189
+
3190
+ function prepareTransformStreamResponse() {
3191
+ server.urls['https://my.api.com/v1/chat/completions'].response = {
3192
+ type: 'stream-chunks',
3193
+ chunks: [
3194
+ `data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1711115037,"model":"grok-beta","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n`,
3195
+ `data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1711115037,"model":"grok-beta","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}\n\n`,
3196
+ 'data: [DONE]\n\n',
3197
+ ],
3198
+ };
3199
+ }
3200
+
3201
+ it('should transform request body in doGenerate when transformRequestBody is provided', async () => {
3202
+ const transformFn = vi.fn((body: Record<string, any>) => ({
3203
+ ...body,
3204
+ custom_field: 'added-by-transform',
3205
+ }));
3206
+
3207
+ prepareTransformJsonResponse();
3208
+
3209
+ const model = new OpenAICompatibleChatLanguageModel('grok-beta', {
3210
+ provider: 'test-provider',
3211
+ url: ({ path }) => `https://my.api.com/v1${path}`,
3212
+ headers: () => ({}),
3213
+ transformRequestBody: transformFn,
3214
+ });
3215
+
3216
+ await model.doGenerate({
3217
+ prompt: TEST_PROMPT,
3218
+ });
3219
+
3220
+ // Verify transform was called
3221
+ expect(transformFn).toHaveBeenCalledOnce();
3222
+ expect(transformFn).toHaveBeenCalledWith(
3223
+ expect.objectContaining({
3224
+ model: 'grok-beta',
3225
+ messages: [{ role: 'user', content: 'Hello' }],
3226
+ }),
3227
+ );
3228
+
3229
+ // Verify transformed body was sent
3230
+ expect(await server.calls[0].requestBodyJson).toMatchObject({
3231
+ custom_field: 'added-by-transform',
3232
+ });
3233
+ });
3234
+
3235
+ it('should transform request body in doStream when transformRequestBody is provided', async () => {
3236
+ const transformFn = vi.fn((body: Record<string, any>) => ({
3237
+ ...body,
3238
+ custom_field: 'added-by-transform',
3239
+ }));
3240
+
3241
+ prepareTransformStreamResponse();
3242
+
3243
+ const model = new OpenAICompatibleChatLanguageModel('grok-beta', {
3244
+ provider: 'test-provider',
3245
+ url: ({ path }) => `https://my.api.com/v1${path}`,
3246
+ headers: () => ({}),
3247
+ transformRequestBody: transformFn,
3248
+ });
3249
+
3250
+ const { stream } = await model.doStream({
3251
+ prompt: TEST_PROMPT,
3252
+ });
3253
+
3254
+ // Consume the stream
3255
+ await convertReadableStreamToArray(stream);
3256
+
3257
+ // Verify transform was called
3258
+ expect(transformFn).toHaveBeenCalledOnce();
3259
+ expect(transformFn).toHaveBeenCalledWith(
3260
+ expect.objectContaining({
3261
+ model: 'grok-beta',
3262
+ messages: [{ role: 'user', content: 'Hello' }],
3263
+ stream: true,
3264
+ }),
3265
+ );
3266
+
3267
+ // Verify transformed body was sent
3268
+ expect(await server.calls[0].requestBodyJson).toMatchObject({
3269
+ custom_field: 'added-by-transform',
3270
+ });
3271
+ });
3272
+
3273
+ it('should work without transformRequestBody', async () => {
3274
+ prepareTransformJsonResponse();
3275
+
3276
+ const model = new OpenAICompatibleChatLanguageModel('grok-beta', {
3277
+ provider: 'test-provider',
3278
+ url: ({ path }) => `https://my.api.com/v1${path}`,
3279
+ headers: () => ({}),
3280
+ });
3281
+
3282
+ await model.doGenerate({
3283
+ prompt: TEST_PROMPT,
3284
+ });
3285
+
3286
+ const requestBody = await server.calls[0].requestBodyJson;
3287
+ expect(requestBody).toMatchObject({
3288
+ model: 'grok-beta',
3289
+ });
3290
+ expect(requestBody).not.toHaveProperty('custom_field');
3291
+ });
3292
+ });