@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,1238 @@
1
+ import { convertToOpenAICompatibleChatMessages } from './convert-to-openai-compatible-chat-messages';
2
+ import { describe, it, expect } from 'vitest';
3
+
4
+ describe('user messages', () => {
5
+ it('should convert messages with only a text part to a string content', async () => {
6
+ const result = convertToOpenAICompatibleChatMessages([
7
+ {
8
+ role: 'user',
9
+ content: [{ type: 'text', text: 'Hello' }],
10
+ },
11
+ ]);
12
+
13
+ expect(result).toEqual([{ role: 'user', content: 'Hello' }]);
14
+ });
15
+
16
+ it('should convert messages with image parts', async () => {
17
+ const result = convertToOpenAICompatibleChatMessages([
18
+ {
19
+ role: 'user',
20
+ content: [
21
+ { type: 'text', text: 'Hello' },
22
+ {
23
+ type: 'file',
24
+ data: Buffer.from([0, 1, 2, 3]).toString('base64'),
25
+ mediaType: 'image/png',
26
+ },
27
+ ],
28
+ },
29
+ ]);
30
+
31
+ expect(result).toEqual([
32
+ {
33
+ role: 'user',
34
+ content: [
35
+ { type: 'text', text: 'Hello' },
36
+ {
37
+ type: 'image_url',
38
+ image_url: { url: 'data:image/png;base64,AAECAw==' },
39
+ },
40
+ ],
41
+ },
42
+ ]);
43
+ });
44
+
45
+ it('should convert messages with image parts from Uint8Array', async () => {
46
+ const result = convertToOpenAICompatibleChatMessages([
47
+ {
48
+ role: 'user',
49
+ content: [
50
+ { type: 'text', text: 'Hi' },
51
+ {
52
+ type: 'file',
53
+ data: new Uint8Array([0, 1, 2, 3]),
54
+ mediaType: 'image/png',
55
+ },
56
+ ],
57
+ },
58
+ ]);
59
+
60
+ expect(result).toEqual([
61
+ {
62
+ role: 'user',
63
+ content: [
64
+ { type: 'text', text: 'Hi' },
65
+ {
66
+ type: 'image_url',
67
+ image_url: { url: 'data:image/png;base64,AAECAw==' },
68
+ },
69
+ ],
70
+ },
71
+ ]);
72
+ });
73
+
74
+ it('should handle URL-based images', async () => {
75
+ const result = convertToOpenAICompatibleChatMessages([
76
+ {
77
+ role: 'user',
78
+ content: [
79
+ {
80
+ type: 'file',
81
+ data: new URL('https://example.com/image.jpg'),
82
+ mediaType: 'image/*',
83
+ },
84
+ ],
85
+ },
86
+ ]);
87
+
88
+ expect(result).toEqual([
89
+ {
90
+ role: 'user',
91
+ content: [
92
+ {
93
+ type: 'image_url',
94
+ image_url: { url: 'https://example.com/image.jpg' },
95
+ },
96
+ ],
97
+ },
98
+ ]);
99
+ });
100
+
101
+ it('should convert messages with audio/wav parts', async () => {
102
+ const result = convertToOpenAICompatibleChatMessages([
103
+ {
104
+ role: 'user',
105
+ content: [
106
+ { type: 'text', text: 'Transcribe this audio' },
107
+ {
108
+ type: 'file',
109
+ data: Buffer.from([0, 1, 2, 3]).toString('base64'),
110
+ mediaType: 'audio/wav',
111
+ },
112
+ ],
113
+ },
114
+ ]);
115
+
116
+ expect(result).toEqual([
117
+ {
118
+ role: 'user',
119
+ content: [
120
+ { type: 'text', text: 'Transcribe this audio' },
121
+ {
122
+ type: 'input_audio',
123
+ input_audio: { data: 'AAECAw==', format: 'wav' },
124
+ },
125
+ ],
126
+ },
127
+ ]);
128
+ });
129
+
130
+ it('should convert messages with audio/mp3 parts', async () => {
131
+ const result = convertToOpenAICompatibleChatMessages([
132
+ {
133
+ role: 'user',
134
+ content: [
135
+ {
136
+ type: 'file',
137
+ data: new Uint8Array([0, 1, 2, 3]),
138
+ mediaType: 'audio/mp3',
139
+ },
140
+ ],
141
+ },
142
+ ]);
143
+
144
+ expect(result).toEqual([
145
+ {
146
+ role: 'user',
147
+ content: [
148
+ {
149
+ type: 'input_audio',
150
+ input_audio: { data: 'AAECAw==', format: 'mp3' },
151
+ },
152
+ ],
153
+ },
154
+ ]);
155
+ });
156
+
157
+ it('should convert messages with audio/mpeg parts to mp3 format', async () => {
158
+ const result = convertToOpenAICompatibleChatMessages([
159
+ {
160
+ role: 'user',
161
+ content: [
162
+ {
163
+ type: 'file',
164
+ data: new Uint8Array([0, 1, 2, 3]),
165
+ mediaType: 'audio/mpeg',
166
+ },
167
+ ],
168
+ },
169
+ ]);
170
+
171
+ expect(result).toEqual([
172
+ {
173
+ role: 'user',
174
+ content: [
175
+ {
176
+ type: 'input_audio',
177
+ input_audio: { data: 'AAECAw==', format: 'mp3' },
178
+ },
179
+ ],
180
+ },
181
+ ]);
182
+ });
183
+
184
+ it('should throw error for audio parts with URLs', async () => {
185
+ expect(() =>
186
+ convertToOpenAICompatibleChatMessages([
187
+ {
188
+ role: 'user',
189
+ content: [
190
+ {
191
+ type: 'file',
192
+ data: new URL('https://example.com/audio.wav'),
193
+ mediaType: 'audio/wav',
194
+ },
195
+ ],
196
+ },
197
+ ]),
198
+ ).toThrow("'audio file parts with URLs' functionality not supported");
199
+ });
200
+
201
+ it('should throw error for unsupported audio format', async () => {
202
+ expect(() =>
203
+ convertToOpenAICompatibleChatMessages([
204
+ {
205
+ role: 'user',
206
+ content: [
207
+ {
208
+ type: 'file',
209
+ data: new Uint8Array([0, 1, 2, 3]),
210
+ mediaType: 'audio/ogg',
211
+ },
212
+ ],
213
+ },
214
+ ]),
215
+ ).toThrow("'audio media type audio/ogg' functionality not supported");
216
+ });
217
+
218
+ it('should convert messages with PDF parts', async () => {
219
+ const result = convertToOpenAICompatibleChatMessages([
220
+ {
221
+ role: 'user',
222
+ content: [
223
+ { type: 'text', text: 'Summarize this PDF' },
224
+ {
225
+ type: 'file',
226
+ data: Buffer.from([0, 1, 2, 3]).toString('base64'),
227
+ mediaType: 'application/pdf',
228
+ },
229
+ ],
230
+ },
231
+ ]);
232
+
233
+ expect(result).toEqual([
234
+ {
235
+ role: 'user',
236
+ content: [
237
+ { type: 'text', text: 'Summarize this PDF' },
238
+ {
239
+ type: 'file',
240
+ file: {
241
+ filename: 'document.pdf',
242
+ file_data: 'data:application/pdf;base64,AAECAw==',
243
+ },
244
+ },
245
+ ],
246
+ },
247
+ ]);
248
+ });
249
+
250
+ it('should convert messages with PDF parts using provided filename', async () => {
251
+ const result = convertToOpenAICompatibleChatMessages([
252
+ {
253
+ role: 'user',
254
+ content: [
255
+ {
256
+ type: 'file',
257
+ data: new Uint8Array([0, 1, 2, 3]),
258
+ mediaType: 'application/pdf',
259
+ filename: 'report.pdf',
260
+ },
261
+ ],
262
+ },
263
+ ]);
264
+
265
+ expect(result).toEqual([
266
+ {
267
+ role: 'user',
268
+ content: [
269
+ {
270
+ type: 'file',
271
+ file: {
272
+ filename: 'report.pdf',
273
+ file_data: 'data:application/pdf;base64,AAECAw==',
274
+ },
275
+ },
276
+ ],
277
+ },
278
+ ]);
279
+ });
280
+
281
+ it('should throw error for PDF parts with URLs', async () => {
282
+ expect(() =>
283
+ convertToOpenAICompatibleChatMessages([
284
+ {
285
+ role: 'user',
286
+ content: [
287
+ {
288
+ type: 'file',
289
+ data: new URL('https://example.com/document.pdf'),
290
+ mediaType: 'application/pdf',
291
+ },
292
+ ],
293
+ },
294
+ ]),
295
+ ).toThrow("'PDF file parts with URLs' functionality not supported");
296
+ });
297
+
298
+ it('should convert messages with text/markdown parts', async () => {
299
+ const result = convertToOpenAICompatibleChatMessages([
300
+ {
301
+ role: 'user',
302
+ content: [
303
+ { type: 'text', text: 'Summarize this document' },
304
+ {
305
+ type: 'file',
306
+ data: '# Hello World\n\nThis is **markdown** content.',
307
+ mediaType: 'text/markdown',
308
+ },
309
+ ],
310
+ },
311
+ ]);
312
+
313
+ expect(result).toEqual([
314
+ {
315
+ role: 'user',
316
+ content: [
317
+ { type: 'text', text: 'Summarize this document' },
318
+ {
319
+ type: 'text',
320
+ text: '# Hello World\n\nThis is **markdown** content.',
321
+ },
322
+ ],
323
+ },
324
+ ]);
325
+ });
326
+
327
+ it('should convert messages with text/plain parts from Uint8Array', async () => {
328
+ const encoder = new TextEncoder();
329
+ const result = convertToOpenAICompatibleChatMessages([
330
+ {
331
+ role: 'user',
332
+ content: [
333
+ {
334
+ type: 'file',
335
+ data: encoder.encode('Plain text content'),
336
+ mediaType: 'text/plain',
337
+ },
338
+ ],
339
+ },
340
+ ]);
341
+
342
+ expect(result).toEqual([
343
+ {
344
+ role: 'user',
345
+ content: [
346
+ {
347
+ type: 'text',
348
+ text: 'Plain text content',
349
+ },
350
+ ],
351
+ },
352
+ ]);
353
+ });
354
+
355
+ it('should convert text file URL to string', async () => {
356
+ const result = convertToOpenAICompatibleChatMessages([
357
+ {
358
+ role: 'user',
359
+ content: [
360
+ {
361
+ type: 'file',
362
+ data: new URL('https://example.com/readme.md'),
363
+ mediaType: 'text/markdown',
364
+ },
365
+ ],
366
+ },
367
+ ]);
368
+
369
+ expect(result).toEqual([
370
+ {
371
+ role: 'user',
372
+ content: [
373
+ {
374
+ type: 'text',
375
+ text: 'https://example.com/readme.md',
376
+ },
377
+ ],
378
+ },
379
+ ]);
380
+ });
381
+
382
+ it('should throw error for unsupported file types', async () => {
383
+ expect(() =>
384
+ convertToOpenAICompatibleChatMessages([
385
+ {
386
+ role: 'user',
387
+ content: [
388
+ {
389
+ type: 'file',
390
+ data: new Uint8Array([0, 1, 2, 3]),
391
+ mediaType: 'video/mp4',
392
+ },
393
+ ],
394
+ },
395
+ ]),
396
+ ).toThrow("'file part media type video/mp4' functionality not supported");
397
+ });
398
+ });
399
+
400
+ describe('tool calls', () => {
401
+ it('should stringify arguments to tool calls', () => {
402
+ const result = convertToOpenAICompatibleChatMessages([
403
+ {
404
+ role: 'assistant',
405
+ content: [
406
+ {
407
+ type: 'tool-call',
408
+ input: { foo: 'bar123' },
409
+ toolCallId: 'quux',
410
+ toolName: 'thwomp',
411
+ },
412
+ ],
413
+ },
414
+ {
415
+ role: 'tool',
416
+ content: [
417
+ {
418
+ type: 'tool-result',
419
+ toolCallId: 'quux',
420
+ toolName: 'thwomp',
421
+ output: { type: 'json', value: { oof: '321rab' } },
422
+ },
423
+ ],
424
+ },
425
+ ]);
426
+
427
+ expect(result).toEqual([
428
+ {
429
+ role: 'assistant',
430
+ content: '',
431
+ tool_calls: [
432
+ {
433
+ type: 'function',
434
+ id: 'quux',
435
+ function: {
436
+ name: 'thwomp',
437
+ arguments: JSON.stringify({ foo: 'bar123' }),
438
+ },
439
+ },
440
+ ],
441
+ },
442
+ {
443
+ role: 'tool',
444
+ content: JSON.stringify({ oof: '321rab' }),
445
+ tool_call_id: 'quux',
446
+ },
447
+ ]);
448
+ });
449
+
450
+ it('should handle text output type in tool results', () => {
451
+ const result = convertToOpenAICompatibleChatMessages([
452
+ {
453
+ role: 'assistant',
454
+ content: [
455
+ {
456
+ type: 'tool-call',
457
+ input: { query: 'weather' },
458
+ toolCallId: 'call-1',
459
+ toolName: 'getWeather',
460
+ },
461
+ ],
462
+ },
463
+ {
464
+ role: 'tool',
465
+ content: [
466
+ {
467
+ type: 'tool-result',
468
+ toolCallId: 'call-1',
469
+ toolName: 'getWeather',
470
+ output: { type: 'text', value: 'It is sunny today' },
471
+ },
472
+ ],
473
+ },
474
+ ]);
475
+
476
+ expect(result).toEqual([
477
+ {
478
+ role: 'assistant',
479
+ content: '',
480
+ tool_calls: [
481
+ {
482
+ type: 'function',
483
+ id: 'call-1',
484
+ function: {
485
+ name: 'getWeather',
486
+ arguments: JSON.stringify({ query: 'weather' }),
487
+ },
488
+ },
489
+ ],
490
+ },
491
+ {
492
+ role: 'tool',
493
+ content: 'It is sunny today',
494
+ tool_call_id: 'call-1',
495
+ },
496
+ ]);
497
+ });
498
+ });
499
+
500
+ describe('provider-specific metadata merging', () => {
501
+ it('should merge system message metadata', async () => {
502
+ const result = convertToOpenAICompatibleChatMessages([
503
+ {
504
+ role: 'system',
505
+ content: 'You are a helpful assistant.',
506
+ providerOptions: {
507
+ openaiCompatible: {
508
+ cacheControl: { type: 'ephemeral' },
509
+ },
510
+ },
511
+ },
512
+ ]);
513
+
514
+ expect(result).toEqual([
515
+ {
516
+ role: 'system',
517
+ content: 'You are a helpful assistant.',
518
+ cacheControl: { type: 'ephemeral' },
519
+ },
520
+ ]);
521
+ });
522
+
523
+ it('should merge user message content metadata', async () => {
524
+ const result = convertToOpenAICompatibleChatMessages([
525
+ {
526
+ role: 'user',
527
+ content: [
528
+ {
529
+ type: 'text',
530
+ text: 'Hello',
531
+ providerOptions: {
532
+ openaiCompatible: {
533
+ cacheControl: { type: 'ephemeral' },
534
+ },
535
+ },
536
+ },
537
+ ],
538
+ },
539
+ ]);
540
+
541
+ expect(result).toEqual([
542
+ {
543
+ role: 'user',
544
+ content: 'Hello',
545
+ cacheControl: { type: 'ephemeral' },
546
+ },
547
+ ]);
548
+ });
549
+
550
+ it('should prioritize content-level metadata when merging', async () => {
551
+ const result = convertToOpenAICompatibleChatMessages([
552
+ {
553
+ role: 'user',
554
+ providerOptions: {
555
+ openaiCompatible: {
556
+ messageLevel: true,
557
+ },
558
+ },
559
+ content: [
560
+ {
561
+ type: 'text',
562
+ text: 'Hello',
563
+ providerOptions: {
564
+ openaiCompatible: {
565
+ contentLevel: true,
566
+ },
567
+ },
568
+ },
569
+ ],
570
+ },
571
+ ]);
572
+
573
+ expect(result).toEqual([
574
+ {
575
+ role: 'user',
576
+ content: 'Hello',
577
+ contentLevel: true,
578
+ },
579
+ ]);
580
+ });
581
+
582
+ it('should handle tool calls with metadata', async () => {
583
+ const result = convertToOpenAICompatibleChatMessages([
584
+ {
585
+ role: 'assistant',
586
+ content: [
587
+ {
588
+ type: 'tool-call',
589
+ toolCallId: 'call1',
590
+ toolName: 'calculator',
591
+ input: { x: 1, y: 2 },
592
+ providerOptions: {
593
+ openaiCompatible: {
594
+ cacheControl: { type: 'ephemeral' },
595
+ },
596
+ },
597
+ },
598
+ ],
599
+ },
600
+ ]);
601
+
602
+ expect(result).toEqual([
603
+ {
604
+ role: 'assistant',
605
+ content: '',
606
+ tool_calls: [
607
+ {
608
+ id: 'call1',
609
+ type: 'function',
610
+ function: {
611
+ name: 'calculator',
612
+ arguments: JSON.stringify({ x: 1, y: 2 }),
613
+ },
614
+ cacheControl: { type: 'ephemeral' },
615
+ },
616
+ ],
617
+ },
618
+ ]);
619
+ });
620
+
621
+ it('should handle image content with metadata', async () => {
622
+ const imageUrl = new URL('https://example.com/image.jpg');
623
+ const result = convertToOpenAICompatibleChatMessages([
624
+ {
625
+ role: 'user',
626
+ content: [
627
+ {
628
+ type: 'file',
629
+ data: imageUrl,
630
+ mediaType: 'image/*',
631
+ providerOptions: {
632
+ openaiCompatible: {
633
+ cacheControl: { type: 'ephemeral' },
634
+ },
635
+ },
636
+ },
637
+ ],
638
+ },
639
+ ]);
640
+
641
+ expect(result).toEqual([
642
+ {
643
+ role: 'user',
644
+ content: [
645
+ {
646
+ type: 'image_url',
647
+ image_url: { url: imageUrl.toString() },
648
+ cacheControl: { type: 'ephemeral' },
649
+ },
650
+ ],
651
+ },
652
+ ]);
653
+ });
654
+
655
+ it('should omit non-openaiCompatible metadata', async () => {
656
+ const result = convertToOpenAICompatibleChatMessages([
657
+ {
658
+ role: 'system',
659
+ content: 'Hello',
660
+ providerOptions: {
661
+ someOtherProvider: {
662
+ shouldBeIgnored: true,
663
+ },
664
+ },
665
+ },
666
+ ]);
667
+
668
+ expect(result).toEqual([
669
+ {
670
+ role: 'system',
671
+ content: 'Hello',
672
+ },
673
+ ]);
674
+ });
675
+
676
+ it('should handle a user message with multiple content parts (text + image)', () => {
677
+ const result = convertToOpenAICompatibleChatMessages([
678
+ {
679
+ role: 'user',
680
+ content: [
681
+ {
682
+ type: 'text',
683
+ text: 'Hello from part 1',
684
+ providerOptions: {
685
+ openaiCompatible: { sentiment: 'positive' },
686
+ leftoverKey: { foo: 'some leftover data' },
687
+ },
688
+ },
689
+ {
690
+ type: 'file',
691
+ data: Buffer.from([0, 1, 2, 3]).toString('base64'),
692
+ mediaType: 'image/png',
693
+ providerOptions: {
694
+ openaiCompatible: { alt_text: 'A sample image' },
695
+ },
696
+ },
697
+ ],
698
+ providerOptions: {
699
+ openaiCompatible: { priority: 'high' },
700
+ },
701
+ },
702
+ ]);
703
+
704
+ expect(result).toEqual([
705
+ {
706
+ role: 'user',
707
+ priority: 'high', // hoisted from message-level providerOptions
708
+ content: [
709
+ {
710
+ type: 'text',
711
+ text: 'Hello from part 1',
712
+ sentiment: 'positive', // hoisted from part-level openaiCompatible
713
+ },
714
+ {
715
+ type: 'image_url',
716
+ image_url: {
717
+ url: 'data:image/png;base64,AAECAw==',
718
+ },
719
+ alt_text: 'A sample image',
720
+ },
721
+ ],
722
+ },
723
+ ]);
724
+ });
725
+
726
+ it('should handle a user message with multiple text parts (flattening disabled)', () => {
727
+ const result = convertToOpenAICompatibleChatMessages([
728
+ {
729
+ role: 'user',
730
+ content: [
731
+ { type: 'text', text: 'Part 1' },
732
+ { type: 'text', text: 'Part 2' },
733
+ ],
734
+ },
735
+ ]);
736
+
737
+ // Because there are multiple text parts, the converter won't flatten them
738
+ expect(result).toEqual([
739
+ {
740
+ role: 'user',
741
+ content: [
742
+ { type: 'text', text: 'Part 1' },
743
+ { type: 'text', text: 'Part 2' },
744
+ ],
745
+ },
746
+ ]);
747
+ });
748
+
749
+ it('should handle an assistant message with text plus multiple tool calls', () => {
750
+ const result = convertToOpenAICompatibleChatMessages([
751
+ {
752
+ role: 'assistant',
753
+ content: [
754
+ { type: 'text', text: 'Checking that now...' },
755
+ {
756
+ type: 'tool-call',
757
+ toolCallId: 'call1',
758
+ toolName: 'searchTool',
759
+ input: { query: 'Weather' },
760
+ providerOptions: {
761
+ openaiCompatible: { function_call_reason: 'user request' },
762
+ },
763
+ },
764
+ { type: 'text', text: 'Almost there...' },
765
+ {
766
+ type: 'tool-call',
767
+ toolCallId: 'call2',
768
+ toolName: 'mapsTool',
769
+ input: { location: 'Paris' },
770
+ },
771
+ ],
772
+ },
773
+ ]);
774
+
775
+ expect(result).toEqual([
776
+ {
777
+ role: 'assistant',
778
+ content: 'Checking that now...Almost there...',
779
+ tool_calls: [
780
+ {
781
+ id: 'call1',
782
+ type: 'function',
783
+ function: {
784
+ name: 'searchTool',
785
+ arguments: JSON.stringify({ query: 'Weather' }),
786
+ },
787
+ function_call_reason: 'user request',
788
+ },
789
+ {
790
+ id: 'call2',
791
+ type: 'function',
792
+ function: {
793
+ name: 'mapsTool',
794
+ arguments: JSON.stringify({ location: 'Paris' }),
795
+ },
796
+ },
797
+ ],
798
+ },
799
+ ]);
800
+ });
801
+
802
+ it('should handle a single tool role message with multiple tool-result parts', () => {
803
+ const result = convertToOpenAICompatibleChatMessages([
804
+ {
805
+ role: 'tool',
806
+ providerOptions: {
807
+ // this just gets omitted as we prioritize content-level metadata
808
+ openaiCompatible: { responseTier: 'detailed' },
809
+ },
810
+ content: [
811
+ {
812
+ type: 'tool-result',
813
+ toolCallId: 'call123',
814
+ toolName: 'calculator',
815
+ output: { type: 'json', value: { stepOne: 'data chunk 1' } },
816
+ },
817
+ {
818
+ type: 'tool-result',
819
+ toolCallId: 'call123',
820
+ toolName: 'calculator',
821
+ providerOptions: {
822
+ openaiCompatible: { partial: true },
823
+ },
824
+ output: { type: 'json', value: { stepTwo: 'data chunk 2' } },
825
+ },
826
+ ],
827
+ },
828
+ ]);
829
+
830
+ expect(result).toEqual([
831
+ {
832
+ role: 'tool',
833
+ tool_call_id: 'call123',
834
+ content: JSON.stringify({ stepOne: 'data chunk 1' }),
835
+ },
836
+ {
837
+ role: 'tool',
838
+ tool_call_id: 'call123',
839
+ content: JSON.stringify({ stepTwo: 'data chunk 2' }),
840
+ partial: true,
841
+ },
842
+ ]);
843
+ });
844
+
845
+ it('should handle multiple content parts with multiple metadata layers', () => {
846
+ const result = convertToOpenAICompatibleChatMessages([
847
+ {
848
+ role: 'user',
849
+ providerOptions: {
850
+ openaiCompatible: { messageLevel: 'global-metadata' },
851
+ leftoverForMessage: { x: 123 },
852
+ },
853
+ content: [
854
+ {
855
+ type: 'text',
856
+ text: 'Part A',
857
+ providerOptions: {
858
+ openaiCompatible: { textPartLevel: 'localized' },
859
+ leftoverForText: { info: 'text leftover' },
860
+ },
861
+ },
862
+ {
863
+ type: 'file',
864
+ data: Buffer.from([9, 8, 7, 6]).toString('base64'),
865
+ mediaType: 'image/png',
866
+ providerOptions: {
867
+ openaiCompatible: { imagePartLevel: 'image-data' },
868
+ },
869
+ },
870
+ ],
871
+ },
872
+ ]);
873
+
874
+ expect(result).toEqual([
875
+ {
876
+ role: 'user',
877
+ messageLevel: 'global-metadata',
878
+ content: [
879
+ {
880
+ type: 'text',
881
+ text: 'Part A',
882
+ textPartLevel: 'localized',
883
+ },
884
+ {
885
+ type: 'image_url',
886
+ image_url: {
887
+ url: 'data:image/png;base64,CQgHBg==',
888
+ },
889
+ imagePartLevel: 'image-data',
890
+ },
891
+ ],
892
+ },
893
+ ]);
894
+ });
895
+
896
+ it('should handle different tool metadata vs. message-level metadata', () => {
897
+ const result = convertToOpenAICompatibleChatMessages([
898
+ {
899
+ role: 'assistant',
900
+ providerOptions: {
901
+ openaiCompatible: { globalPriority: 'high' },
902
+ },
903
+ content: [
904
+ { type: 'text', text: 'Initiating tool calls...' },
905
+ {
906
+ type: 'tool-call',
907
+ toolCallId: 'callXYZ',
908
+ toolName: 'awesomeTool',
909
+ input: { param: 'someValue' },
910
+ providerOptions: {
911
+ openaiCompatible: {
912
+ toolPriority: 'critical',
913
+ },
914
+ },
915
+ },
916
+ ],
917
+ },
918
+ ]);
919
+
920
+ expect(result).toEqual([
921
+ {
922
+ role: 'assistant',
923
+ globalPriority: 'high',
924
+ content: 'Initiating tool calls...',
925
+ tool_calls: [
926
+ {
927
+ id: 'callXYZ',
928
+ type: 'function',
929
+ function: {
930
+ name: 'awesomeTool',
931
+ arguments: JSON.stringify({ param: 'someValue' }),
932
+ },
933
+ toolPriority: 'critical',
934
+ },
935
+ ],
936
+ },
937
+ ]);
938
+ });
939
+
940
+ it('should handle metadata collisions and overwrites in tool calls', () => {
941
+ const result = convertToOpenAICompatibleChatMessages([
942
+ {
943
+ role: 'assistant',
944
+ providerOptions: {
945
+ openaiCompatible: {
946
+ cacheControl: { type: 'default' },
947
+ sharedKey: 'assistantLevel',
948
+ },
949
+ },
950
+ content: [
951
+ {
952
+ type: 'tool-call',
953
+ toolCallId: 'collisionToolCall',
954
+ toolName: 'collider',
955
+ input: { num: 42 },
956
+ providerOptions: {
957
+ openaiCompatible: {
958
+ cacheControl: { type: 'ephemeral' }, // overwrites top-level
959
+ sharedKey: 'toolLevel',
960
+ },
961
+ },
962
+ },
963
+ ],
964
+ },
965
+ ]);
966
+
967
+ expect(result).toEqual([
968
+ {
969
+ role: 'assistant',
970
+ cacheControl: { type: 'default' },
971
+ sharedKey: 'assistantLevel',
972
+ content: '',
973
+ tool_calls: [
974
+ {
975
+ id: 'collisionToolCall',
976
+ type: 'function',
977
+ function: {
978
+ name: 'collider',
979
+ arguments: JSON.stringify({ num: 42 }),
980
+ },
981
+ cacheControl: { type: 'ephemeral' },
982
+ sharedKey: 'toolLevel',
983
+ },
984
+ ],
985
+ },
986
+ ]);
987
+ });
988
+ });
989
+
990
+ describe('Google Gemini thought signatures (OpenAI compatibility)', () => {
991
+ it('should serialize thought signature to extra_content for single tool call', () => {
992
+ const result = convertToOpenAICompatibleChatMessages([
993
+ {
994
+ role: 'assistant',
995
+ content: [
996
+ {
997
+ type: 'tool-call',
998
+ toolCallId: 'function-call-1',
999
+ toolName: 'check_flight',
1000
+ input: { flight: 'AA100' },
1001
+ providerOptions: {
1002
+ google: {
1003
+ thoughtSignature: '<Signature A>',
1004
+ },
1005
+ },
1006
+ },
1007
+ ],
1008
+ },
1009
+ ]);
1010
+
1011
+ expect(result).toEqual([
1012
+ {
1013
+ role: 'assistant',
1014
+ content: '',
1015
+ tool_calls: [
1016
+ {
1017
+ id: 'function-call-1',
1018
+ type: 'function',
1019
+ function: {
1020
+ name: 'check_flight',
1021
+ arguments: JSON.stringify({ flight: 'AA100' }),
1022
+ },
1023
+ extra_content: {
1024
+ google: {
1025
+ thought_signature: '<Signature A>',
1026
+ },
1027
+ },
1028
+ },
1029
+ ],
1030
+ },
1031
+ ]);
1032
+ });
1033
+
1034
+ it('should handle sequential tool calls with separate signatures (Turn 1 Step 3 scenario)', () => {
1035
+ const result = convertToOpenAICompatibleChatMessages([
1036
+ {
1037
+ role: 'user',
1038
+ content: [
1039
+ {
1040
+ type: 'text',
1041
+ text: 'Check flight status for AA100 and book a taxi 2 hours before if delayed.',
1042
+ },
1043
+ ],
1044
+ },
1045
+ {
1046
+ role: 'assistant',
1047
+ content: [
1048
+ {
1049
+ type: 'tool-call',
1050
+ toolCallId: 'function-call-1',
1051
+ toolName: 'check_flight',
1052
+ input: { flight: 'AA100' },
1053
+ providerOptions: {
1054
+ google: {
1055
+ thoughtSignature: '<Signature A>',
1056
+ },
1057
+ },
1058
+ },
1059
+ ],
1060
+ },
1061
+ {
1062
+ role: 'tool',
1063
+ content: [
1064
+ {
1065
+ type: 'tool-result',
1066
+ toolCallId: 'function-call-1',
1067
+ toolName: 'check_flight',
1068
+ output: {
1069
+ type: 'json',
1070
+ value: { status: 'delayed', departure_time: '12 PM' },
1071
+ },
1072
+ },
1073
+ ],
1074
+ },
1075
+ {
1076
+ role: 'assistant',
1077
+ content: [
1078
+ {
1079
+ type: 'tool-call',
1080
+ toolCallId: 'function-call-2',
1081
+ toolName: 'book_taxi',
1082
+ input: { time: '10 AM' },
1083
+ providerOptions: {
1084
+ google: {
1085
+ thoughtSignature: '<Signature B>',
1086
+ },
1087
+ },
1088
+ },
1089
+ ],
1090
+ },
1091
+ {
1092
+ role: 'tool',
1093
+ content: [
1094
+ {
1095
+ type: 'tool-result',
1096
+ toolCallId: 'function-call-2',
1097
+ toolName: 'book_taxi',
1098
+ output: { type: 'json', value: { booking_status: 'success' } },
1099
+ },
1100
+ ],
1101
+ },
1102
+ ]);
1103
+
1104
+ // Verify both signatures are preserved
1105
+ expect(result[1]).toEqual({
1106
+ role: 'assistant',
1107
+ content: '',
1108
+ tool_calls: [
1109
+ {
1110
+ id: 'function-call-1',
1111
+ type: 'function',
1112
+ function: {
1113
+ name: 'check_flight',
1114
+ arguments: JSON.stringify({ flight: 'AA100' }),
1115
+ },
1116
+ extra_content: {
1117
+ google: {
1118
+ thought_signature: '<Signature A>',
1119
+ },
1120
+ },
1121
+ },
1122
+ ],
1123
+ });
1124
+
1125
+ expect(result[3]).toEqual({
1126
+ role: 'assistant',
1127
+ content: '',
1128
+ tool_calls: [
1129
+ {
1130
+ id: 'function-call-2',
1131
+ type: 'function',
1132
+ function: {
1133
+ name: 'book_taxi',
1134
+ arguments: JSON.stringify({ time: '10 AM' }),
1135
+ },
1136
+ extra_content: {
1137
+ google: {
1138
+ thought_signature: '<Signature B>',
1139
+ },
1140
+ },
1141
+ },
1142
+ ],
1143
+ });
1144
+ });
1145
+
1146
+ it('should handle parallel tool calls with signature only on first call', () => {
1147
+ const result = convertToOpenAICompatibleChatMessages([
1148
+ {
1149
+ role: 'assistant',
1150
+ content: [
1151
+ {
1152
+ type: 'tool-call',
1153
+ toolCallId: 'function-call-paris',
1154
+ toolName: 'get_current_temperature',
1155
+ input: { location: 'Paris' },
1156
+ providerOptions: {
1157
+ google: {
1158
+ thoughtSignature: '<Signature A>',
1159
+ },
1160
+ },
1161
+ },
1162
+ {
1163
+ type: 'tool-call',
1164
+ toolCallId: 'function-call-london',
1165
+ toolName: 'get_current_temperature',
1166
+ input: { location: 'London' },
1167
+ // No signature on parallel function call
1168
+ },
1169
+ ],
1170
+ },
1171
+ ]);
1172
+
1173
+ expect(result).toEqual([
1174
+ {
1175
+ role: 'assistant',
1176
+ content: '',
1177
+ tool_calls: [
1178
+ {
1179
+ id: 'function-call-paris',
1180
+ type: 'function',
1181
+ function: {
1182
+ name: 'get_current_temperature',
1183
+ arguments: JSON.stringify({ location: 'Paris' }),
1184
+ },
1185
+ extra_content: {
1186
+ google: {
1187
+ thought_signature: '<Signature A>',
1188
+ },
1189
+ },
1190
+ },
1191
+ {
1192
+ id: 'function-call-london',
1193
+ type: 'function',
1194
+ function: {
1195
+ name: 'get_current_temperature',
1196
+ arguments: JSON.stringify({ location: 'London' }),
1197
+ },
1198
+ // No extra_content for second parallel call
1199
+ },
1200
+ ],
1201
+ },
1202
+ ]);
1203
+ });
1204
+
1205
+ it('should not include extra_content when no thought signature is present', () => {
1206
+ const result = convertToOpenAICompatibleChatMessages([
1207
+ {
1208
+ role: 'assistant',
1209
+ content: [
1210
+ {
1211
+ type: 'tool-call',
1212
+ toolCallId: 'call-1',
1213
+ toolName: 'some_tool',
1214
+ input: { param: 'value' },
1215
+ },
1216
+ ],
1217
+ },
1218
+ ]);
1219
+
1220
+ expect(result).toEqual([
1221
+ {
1222
+ role: 'assistant',
1223
+ content: '',
1224
+ tool_calls: [
1225
+ {
1226
+ id: 'call-1',
1227
+ type: 'function',
1228
+ function: {
1229
+ name: 'some_tool',
1230
+ arguments: JSON.stringify({ param: 'value' }),
1231
+ },
1232
+ // No extra_content field
1233
+ },
1234
+ ],
1235
+ },
1236
+ ]);
1237
+ });
1238
+ });