@a5c-ai/transport-adapter 5.1.1-staging.52898ebfc24f

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 (82) hide show
  1. package/README.md +54 -0
  2. package/dist/__tests__/codecs.test.d.ts +1 -0
  3. package/dist/__tests__/codecs.test.d.ts.map +1 -0
  4. package/dist/__tests__/codecs.test.js +698 -0
  5. package/dist/__tests__/codecs.test.js.map +1 -0
  6. package/dist/__tests__/openai-engine.test.d.ts +1 -0
  7. package/dist/__tests__/openai-engine.test.d.ts.map +1 -0
  8. package/dist/__tests__/openai-engine.test.js +89 -0
  9. package/dist/__tests__/openai-engine.test.js.map +1 -0
  10. package/dist/bin/adapters-proxy.d.ts +2 -0
  11. package/dist/bin/adapters-proxy.d.ts.map +1 -0
  12. package/dist/bin/adapters-proxy.js +5 -0
  13. package/dist/bin/adapters-proxy.js.map +1 -0
  14. package/dist/bin/adapters-transport-proxy.d.ts +2 -0
  15. package/dist/bin/adapters-transport-proxy.d.ts.map +1 -0
  16. package/dist/bin/adapters-transport-proxy.js +57 -0
  17. package/dist/bin/adapters-transport-proxy.js.map +1 -0
  18. package/dist/codec.d.ts +36 -0
  19. package/dist/codec.d.ts.map +1 -0
  20. package/dist/codec.js +2 -0
  21. package/dist/codec.js.map +1 -0
  22. package/dist/codecs/anthropic.d.ts +12 -0
  23. package/dist/codecs/anthropic.d.ts.map +1 -0
  24. package/dist/codecs/anthropic.js +185 -0
  25. package/dist/codecs/anthropic.js.map +1 -0
  26. package/dist/codecs/bedrock.d.ts +12 -0
  27. package/dist/codecs/bedrock.d.ts.map +1 -0
  28. package/dist/codecs/bedrock.js +175 -0
  29. package/dist/codecs/bedrock.js.map +1 -0
  30. package/dist/codecs/google.d.ts +12 -0
  31. package/dist/codecs/google.d.ts.map +1 -0
  32. package/dist/codecs/google.js +176 -0
  33. package/dist/codecs/google.js.map +1 -0
  34. package/dist/codecs/index.d.ts +30 -0
  35. package/dist/codecs/index.d.ts.map +1 -0
  36. package/dist/codecs/index.js +115 -0
  37. package/dist/codecs/index.js.map +1 -0
  38. package/dist/codecs/openai-chat.d.ts +12 -0
  39. package/dist/codecs/openai-chat.d.ts.map +1 -0
  40. package/dist/codecs/openai-chat.js +191 -0
  41. package/dist/codecs/openai-chat.js.map +1 -0
  42. package/dist/codecs/openai-responses.d.ts +12 -0
  43. package/dist/codecs/openai-responses.d.ts.map +1 -0
  44. package/dist/codecs/openai-responses.js +229 -0
  45. package/dist/codecs/openai-responses.js.map +1 -0
  46. package/dist/config.d.ts +8 -0
  47. package/dist/config.d.ts.map +1 -0
  48. package/dist/config.js +86 -0
  49. package/dist/config.js.map +1 -0
  50. package/dist/engines/anthropic.d.ts +7 -0
  51. package/dist/engines/anthropic.d.ts.map +1 -0
  52. package/dist/engines/anthropic.js +232 -0
  53. package/dist/engines/anthropic.js.map +1 -0
  54. package/dist/engines/google.d.ts +17 -0
  55. package/dist/engines/google.d.ts.map +1 -0
  56. package/dist/engines/google.js +232 -0
  57. package/dist/engines/google.js.map +1 -0
  58. package/dist/engines/index.d.ts +3 -0
  59. package/dist/engines/index.d.ts.map +1 -0
  60. package/dist/engines/index.js +3 -0
  61. package/dist/engines/index.js.map +1 -0
  62. package/dist/engines/openai.d.ts +12 -0
  63. package/dist/engines/openai.d.ts.map +1 -0
  64. package/dist/engines/openai.js +253 -0
  65. package/dist/engines/openai.js.map +1 -0
  66. package/dist/index.d.ts +22 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +19 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/runtime.d.ts +19 -0
  71. package/dist/runtime.d.ts.map +1 -0
  72. package/dist/runtime.js +56 -0
  73. package/dist/runtime.js.map +1 -0
  74. package/dist/server.d.ts +4 -0
  75. package/dist/server.d.ts.map +1 -0
  76. package/dist/server.js +1428 -0
  77. package/dist/server.js.map +1 -0
  78. package/dist/types.d.ts +109 -0
  79. package/dist/types.d.ts.map +1 -0
  80. package/dist/types.js +11 -0
  81. package/dist/types.js.map +1 -0
  82. package/package.json +99 -0
@@ -0,0 +1,698 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { OpenAiChatCodec } from '../codecs/openai-chat.js';
3
+ import { AnthropicCodec } from '../codecs/anthropic.js';
4
+ import { GoogleCodec } from '../codecs/google.js';
5
+ import { BedrockConverseCodec } from '../codecs/bedrock.js';
6
+ import { OpenAiResponsesCodec } from '../codecs/openai-responses.js';
7
+ import { convertTools, getCodec, getCodecForDescriptor, listRegisteredCodecs, normalizeUsage, registerCodec, } from '../codecs/index.js';
8
+ /* -------------------------------------------------------------------------- */
9
+ /* Helpers */
10
+ /* -------------------------------------------------------------------------- */
11
+ function makeResult(overrides = {}) {
12
+ return {
13
+ id: 'res-1',
14
+ model: 'test-model',
15
+ role: 'assistant',
16
+ text: 'Hello world',
17
+ finishReason: 'stop',
18
+ usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
19
+ ...overrides,
20
+ };
21
+ }
22
+ /* ========================================================================== */
23
+ /* OpenAI Chat Codec */
24
+ /* ========================================================================== */
25
+ describe('OpenAiChatCodec', () => {
26
+ const codec = new OpenAiChatCodec();
27
+ describe('decodeRequest', () => {
28
+ it('decodes messages + tools into a CompletionRequest with tools extracted', () => {
29
+ const body = {
30
+ model: 'gpt-4o',
31
+ messages: [
32
+ { role: 'system', content: 'You are helpful.' },
33
+ { role: 'user', content: 'Hi' },
34
+ ],
35
+ tools: [
36
+ {
37
+ type: 'function',
38
+ function: {
39
+ name: 'get_weather',
40
+ description: 'Get weather',
41
+ parameters: { type: 'object', properties: { city: { type: 'string' } } },
42
+ },
43
+ },
44
+ ],
45
+ tool_choice: 'auto',
46
+ stream: false,
47
+ };
48
+ const req = codec.decodeRequest(body);
49
+ expect(req.model).toBe('gpt-4o');
50
+ expect(req.transport).toBe('openai-chat');
51
+ expect(req.messages).toHaveLength(2);
52
+ expect(req.messages[0]).toEqual({ role: 'system', content: 'You are helpful.' });
53
+ expect(req.messages[1]).toEqual({ role: 'user', content: 'Hi' });
54
+ expect(req.tools).toHaveLength(1);
55
+ expect(req.toolChoice).toBe('auto');
56
+ expect(req.stream).toBe(false);
57
+ expect(req.raw).toBe(body);
58
+ });
59
+ it('defaults model to mock-model when absent', () => {
60
+ const req = codec.decodeRequest({ messages: [] });
61
+ expect(req.model).toBe('mock-model');
62
+ });
63
+ it('handles array content with text parts', () => {
64
+ const req = codec.decodeRequest({
65
+ messages: [
66
+ { role: 'user', content: [{ type: 'text', text: 'part-1' }, { type: 'text', text: 'part-2' }] },
67
+ ],
68
+ });
69
+ expect(req.messages[0].content).toBe('part-1 part-2');
70
+ });
71
+ });
72
+ describe('encodeResult', () => {
73
+ it('produces an OpenAI-shaped response with id, object, model, choices, usage', () => {
74
+ const result = makeResult();
75
+ const encoded = codec.encodeResult(result);
76
+ expect(encoded.id).toBe('res-1');
77
+ expect(encoded.object).toBe('chat.completion');
78
+ expect(encoded.model).toBe('test-model');
79
+ const choices = encoded.choices;
80
+ expect(choices).toHaveLength(1);
81
+ expect(choices[0].index).toBe(0);
82
+ expect(choices[0].finish_reason).toBe('stop');
83
+ const message = choices[0].message;
84
+ expect(message.role).toBe('assistant');
85
+ expect(message.content).toBe('Hello world');
86
+ const usage = encoded.usage;
87
+ expect(usage.prompt_tokens).toBe(10);
88
+ expect(usage.completion_tokens).toBe(5);
89
+ expect(usage.total_tokens).toBe(15);
90
+ });
91
+ });
92
+ describe('normalizeTools', () => {
93
+ it('converts OpenAI function-tool wrapper to NormalizedToolDefinition', () => {
94
+ const tools = [
95
+ {
96
+ type: 'function',
97
+ function: {
98
+ name: 'search',
99
+ description: 'Search the web',
100
+ parameters: { type: 'object', properties: { q: { type: 'string' } } },
101
+ },
102
+ },
103
+ ];
104
+ const normalized = codec.normalizeTools(tools);
105
+ expect(normalized).toHaveLength(1);
106
+ expect(normalized[0].name).toBe('search');
107
+ expect(normalized[0].description).toBe('Search the web');
108
+ expect(normalized[0].parameters).toEqual({
109
+ type: 'object',
110
+ properties: { q: { type: 'string' } },
111
+ });
112
+ });
113
+ it('falls back to direct name-based object when type is not function', () => {
114
+ const tools = [{ name: 'lookup', description: 'Lookup something' }];
115
+ const normalized = codec.normalizeTools(tools);
116
+ expect(normalized).toHaveLength(1);
117
+ expect(normalized[0].name).toBe('lookup');
118
+ });
119
+ });
120
+ describe('extractCostRecord', () => {
121
+ it('extracts cost record from OpenAI usage', () => {
122
+ const result = makeResult();
123
+ const cost = codec.extractCostRecord(result);
124
+ expect(cost).toBeDefined();
125
+ expect(cost.inputTokens).toBe(10);
126
+ expect(cost.outputTokens).toBe(5);
127
+ expect(cost.provider).toBe('openai');
128
+ expect(cost.model).toBe('test-model');
129
+ });
130
+ it('returns undefined when usage is missing', () => {
131
+ const result = makeResult();
132
+ result.usage = undefined;
133
+ const cost = codec.extractCostRecord(result);
134
+ expect(cost).toBeUndefined();
135
+ });
136
+ });
137
+ });
138
+ /* ========================================================================== */
139
+ /* Anthropic Codec */
140
+ /* ========================================================================== */
141
+ describe('AnthropicCodec', () => {
142
+ const codec = new AnthropicCodec();
143
+ describe('decodeRequest', () => {
144
+ it('decodes Anthropic tool format (input_schema) from body', () => {
145
+ const body = {
146
+ model: 'claude-sonnet-4-20250514',
147
+ messages: [{ role: 'user', content: 'Hello' }],
148
+ tools: [
149
+ {
150
+ name: 'calculator',
151
+ description: 'Do math',
152
+ input_schema: {
153
+ type: 'object',
154
+ properties: { expression: { type: 'string' } },
155
+ },
156
+ },
157
+ ],
158
+ tool_choice: { type: 'auto' },
159
+ stream: true,
160
+ };
161
+ const req = codec.decodeRequest(body);
162
+ expect(req.model).toBe('claude-sonnet-4-20250514');
163
+ expect(req.transport).toBe('anthropic');
164
+ expect(req.messages).toHaveLength(1);
165
+ expect(req.messages[0]).toEqual({ role: 'user', content: 'Hello' });
166
+ expect(req.tools).toHaveLength(1);
167
+ expect(req.tools[0].input_schema).toBeDefined();
168
+ expect(req.toolChoice).toEqual({ type: 'auto' });
169
+ expect(req.stream).toBe(true);
170
+ });
171
+ it('preserves Anthropic tool content blocks as rawContent', () => {
172
+ const req = codec.decodeRequest({
173
+ messages: [
174
+ {
175
+ role: 'assistant',
176
+ content: [
177
+ { type: 'text', text: 'I will write it.' },
178
+ { type: 'tool_use', id: 'toolu_1', name: 'Write', input: { file_path: '/tmp/odyssey.md' } },
179
+ ],
180
+ },
181
+ {
182
+ role: 'user',
183
+ content: [
184
+ { type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' },
185
+ ],
186
+ },
187
+ ],
188
+ });
189
+ expect(req.messages[0]).toMatchObject({
190
+ role: 'assistant',
191
+ content: 'I will write it.',
192
+ rawContent: [
193
+ { type: 'text', text: 'I will write it.' },
194
+ { type: 'tool_use', id: 'toolu_1', name: 'Write', input: { file_path: '/tmp/odyssey.md' } },
195
+ ],
196
+ });
197
+ expect(req.messages[1]).toMatchObject({
198
+ role: 'user',
199
+ content: '',
200
+ rawContent: [
201
+ { type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' },
202
+ ],
203
+ });
204
+ });
205
+ });
206
+ describe('encodeResult', () => {
207
+ it('encodes tool calls as Anthropic tool_use content blocks', () => {
208
+ const encoded = codec.encodeResult(makeResult({
209
+ text: '',
210
+ finishReason: 'tool_calls',
211
+ toolCalls: [{
212
+ id: 'toolu_write_file',
213
+ name: 'Write',
214
+ arguments: JSON.stringify({ file_path: '/tmp/odyssey.md', content: '# Odyssey' }),
215
+ }],
216
+ }));
217
+ expect(encoded.stop_reason).toBe('tool_use');
218
+ expect(encoded.content).toContainEqual({
219
+ type: 'tool_use',
220
+ id: 'toolu_write_file',
221
+ name: 'Write',
222
+ input: { file_path: '/tmp/odyssey.md', content: '# Odyssey' },
223
+ });
224
+ });
225
+ });
226
+ describe('normalizeTools', () => {
227
+ it('converts input_schema to parameters', () => {
228
+ const tools = [
229
+ {
230
+ name: 'fetch_url',
231
+ description: 'Fetch a URL',
232
+ input_schema: {
233
+ type: 'object',
234
+ properties: { url: { type: 'string' } },
235
+ },
236
+ },
237
+ ];
238
+ const normalized = codec.normalizeTools(tools);
239
+ expect(normalized).toHaveLength(1);
240
+ expect(normalized[0].name).toBe('fetch_url');
241
+ expect(normalized[0].description).toBe('Fetch a URL');
242
+ expect(normalized[0].parameters).toEqual({
243
+ type: 'object',
244
+ properties: { url: { type: 'string' } },
245
+ });
246
+ });
247
+ });
248
+ describe('denormalizeTools', () => {
249
+ it('converts parameters back to input_schema', () => {
250
+ const tools = [
251
+ {
252
+ name: 'write_file',
253
+ description: 'Write a file',
254
+ parameters: {
255
+ type: 'object',
256
+ properties: { path: { type: 'string' }, content: { type: 'string' } },
257
+ },
258
+ },
259
+ ];
260
+ const denormalized = codec.denormalizeTools(tools);
261
+ expect(denormalized).toHaveLength(1);
262
+ expect(denormalized[0].name).toBe('write_file');
263
+ expect(denormalized[0].description).toBe('Write a file');
264
+ expect(denormalized[0].input_schema).toEqual({
265
+ type: 'object',
266
+ properties: { path: { type: 'string' }, content: { type: 'string' } },
267
+ });
268
+ });
269
+ it('provides default input_schema when parameters is undefined', () => {
270
+ const tools = [{ name: 'no_params' }];
271
+ const denormalized = codec.denormalizeTools(tools);
272
+ expect(denormalized[0].input_schema).toEqual({ type: 'object', properties: {} });
273
+ });
274
+ });
275
+ describe('extractCostRecord', () => {
276
+ it('extracts cost record with cache tokens from raw costRecord', () => {
277
+ const result = makeResult({
278
+ costRecord: {
279
+ inputTokens: 10,
280
+ outputTokens: 5,
281
+ cacheReadTokens: 100,
282
+ cacheWriteTokens: 50,
283
+ },
284
+ });
285
+ const cost = codec.extractCostRecord(result);
286
+ expect(cost).toBeDefined();
287
+ expect(cost.inputTokens).toBe(10);
288
+ expect(cost.outputTokens).toBe(5);
289
+ expect(cost.cacheReadTokens).toBe(100);
290
+ expect(cost.cacheWriteTokens).toBe(50);
291
+ expect(cost.provider).toBe('anthropic');
292
+ expect(cost.model).toBe('test-model');
293
+ });
294
+ it('returns cost record without cache tokens when costRecord is absent', () => {
295
+ const result = makeResult();
296
+ const cost = codec.extractCostRecord(result);
297
+ expect(cost).toBeDefined();
298
+ expect(cost.cacheReadTokens).toBeUndefined();
299
+ expect(cost.cacheWriteTokens).toBeUndefined();
300
+ });
301
+ });
302
+ });
303
+ /* ========================================================================== */
304
+ /* Google Codec */
305
+ /* ========================================================================== */
306
+ describe('GoogleCodec', () => {
307
+ const codec = new GoogleCodec();
308
+ describe('decodeRequest', () => {
309
+ it('decodes Google contents format with role mapping (model -> assistant)', () => {
310
+ const body = {
311
+ model: 'gemini-2.5-pro',
312
+ contents: [
313
+ { role: 'user', parts: [{ text: 'Hello' }] },
314
+ { role: 'model', parts: [{ text: 'Hi there' }] },
315
+ ],
316
+ tools: [
317
+ {
318
+ functionDeclarations: [
319
+ {
320
+ name: 'search',
321
+ description: 'Search the web',
322
+ parameters: { type: 'object', properties: { query: { type: 'string' } } },
323
+ },
324
+ ],
325
+ },
326
+ ],
327
+ };
328
+ const req = codec.decodeRequest(body);
329
+ expect(req.model).toBe('gemini-2.5-pro');
330
+ expect(req.transport).toBe('google');
331
+ expect(req.messages).toHaveLength(2);
332
+ expect(req.messages[0]).toEqual({ role: 'user', content: 'Hello' });
333
+ expect(req.messages[1]).toEqual({ role: 'assistant', content: 'Hi there' });
334
+ expect(req.tools).toHaveLength(1);
335
+ expect(req.tools[0].name).toBe('search');
336
+ expect(req.stream).toBe(false);
337
+ });
338
+ it('handles missing contents gracefully', () => {
339
+ const req = codec.decodeRequest({ model: 'gemini-2.5-flash' });
340
+ expect(req.messages).toEqual([]);
341
+ expect(req.tools).toBeUndefined();
342
+ });
343
+ });
344
+ describe('normalizeTools', () => {
345
+ it('converts functionDeclarations wrapper to NormalizedToolDefinition[]', () => {
346
+ const tools = [
347
+ {
348
+ functionDeclarations: [
349
+ {
350
+ name: 'get_weather',
351
+ description: 'Get weather',
352
+ parameters: { type: 'object', properties: { city: { type: 'string' } } },
353
+ },
354
+ {
355
+ name: 'get_time',
356
+ description: 'Get current time',
357
+ },
358
+ ],
359
+ },
360
+ ];
361
+ const normalized = codec.normalizeTools(tools);
362
+ expect(normalized).toHaveLength(2);
363
+ expect(normalized[0].name).toBe('get_weather');
364
+ expect(normalized[0].description).toBe('Get weather');
365
+ expect(normalized[0].parameters).toEqual({
366
+ type: 'object',
367
+ properties: { city: { type: 'string' } },
368
+ });
369
+ expect(normalized[1].name).toBe('get_time');
370
+ expect(normalized[1].parameters).toBeUndefined();
371
+ });
372
+ it('handles direct function declaration objects with name field', () => {
373
+ const tools = [
374
+ { name: 'direct_fn', description: 'Direct function' },
375
+ ];
376
+ const normalized = codec.normalizeTools(tools);
377
+ expect(normalized).toHaveLength(1);
378
+ expect(normalized[0].name).toBe('direct_fn');
379
+ });
380
+ });
381
+ describe('extractCostRecord', () => {
382
+ it('extracts cost record from Google usage', () => {
383
+ const result = makeResult();
384
+ const cost = codec.extractCostRecord(result);
385
+ expect(cost).toBeDefined();
386
+ expect(cost.inputTokens).toBe(10);
387
+ expect(cost.outputTokens).toBe(5);
388
+ expect(cost.provider).toBe('google');
389
+ expect(cost.model).toBe('test-model');
390
+ });
391
+ });
392
+ });
393
+ /* ========================================================================== */
394
+ /* Codec Registry (getCodec) */
395
+ /* ========================================================================== */
396
+ describe('getCodec', () => {
397
+ it('returns correct codec for openai-chat', () => {
398
+ const codec = getCodec('openai-chat');
399
+ expect(codec).toBeDefined();
400
+ expect(codec.transportId).toBe('openai-chat');
401
+ });
402
+ it('returns correct codec for anthropic', () => {
403
+ const codec = getCodec('anthropic');
404
+ expect(codec).toBeDefined();
405
+ expect(codec.transportId).toBe('anthropic');
406
+ });
407
+ it('returns correct codec for google', () => {
408
+ const codec = getCodec('google');
409
+ expect(codec).toBeDefined();
410
+ expect(codec.transportId).toBe('google');
411
+ });
412
+ it('returns correct codec for openai-responses', () => {
413
+ const codec = getCodec('openai-responses');
414
+ expect(codec).toBeDefined();
415
+ expect(codec.transportId).toBe('openai-responses');
416
+ });
417
+ it('returns correct codec for bedrock-converse and bedrock alias', () => {
418
+ expect(getCodec('bedrock-converse')?.transportId).toBe('bedrock-converse');
419
+ expect(getCodec('bedrock')?.transportId).toBe('bedrock-converse');
420
+ });
421
+ it('resolves codecs from atlas TransportDescriptor-like objects', () => {
422
+ const codec = getCodecForDescriptor({
423
+ transportId: 'transport-runtime:openai-responses',
424
+ codecCapabilities: {
425
+ supportsTools: true,
426
+ supportsStreaming: true,
427
+ supportsTokenCounting: false,
428
+ costTracking: true,
429
+ toolSchemaFormat: 'openai',
430
+ },
431
+ });
432
+ expect(codec?.transportId).toBe('openai-responses');
433
+ });
434
+ it('registers plugin codecs with deterministic aliases', () => {
435
+ const codec = {
436
+ transportId: 'plugin-custom',
437
+ capabilities: {
438
+ supportsTools: false,
439
+ supportsStreaming: false,
440
+ supportsTokenCounting: false,
441
+ costTracking: false,
442
+ toolSchemaFormat: 'none',
443
+ },
444
+ decodeRequest(body) {
445
+ return {
446
+ model: 'plugin-model',
447
+ transport: 'plugin-custom',
448
+ messages: [],
449
+ stream: false,
450
+ raw: body,
451
+ };
452
+ },
453
+ encodeResult(result) {
454
+ return result;
455
+ },
456
+ encodeStreamChunk(event) {
457
+ return JSON.stringify(event);
458
+ },
459
+ };
460
+ const registeredIds = registerCodec(codec, {
461
+ aliases: ['transport-runtime:plugin-custom', 'plugin-custom-completions'],
462
+ });
463
+ expect(registeredIds).toEqual([
464
+ 'plugin-custom',
465
+ 'transport-runtime:plugin-custom',
466
+ 'plugin-custom-completions',
467
+ ]);
468
+ expect(getCodec('plugin-custom')).toBe(codec);
469
+ expect(getCodec('transport-runtime:plugin-custom')).toBe(codec);
470
+ expect(getCodec('plugin-custom-completions')).toBe(codec);
471
+ expect(listRegisteredCodecs()).toContain(codec);
472
+ });
473
+ it('rejects duplicate plugin codec registrations unless explicitly overridden', () => {
474
+ const first = {
475
+ transportId: 'plugin-duplicate',
476
+ capabilities: {
477
+ supportsTools: false,
478
+ supportsStreaming: false,
479
+ supportsTokenCounting: false,
480
+ costTracking: false,
481
+ toolSchemaFormat: 'none',
482
+ },
483
+ decodeRequest(body) {
484
+ return {
485
+ model: 'first',
486
+ transport: 'plugin-duplicate',
487
+ messages: [],
488
+ stream: false,
489
+ raw: body,
490
+ };
491
+ },
492
+ encodeResult(result) {
493
+ return result;
494
+ },
495
+ encodeStreamChunk(event) {
496
+ return JSON.stringify(event);
497
+ },
498
+ };
499
+ const second = {
500
+ transportId: 'plugin-duplicate',
501
+ capabilities: {
502
+ supportsTools: false,
503
+ supportsStreaming: false,
504
+ supportsTokenCounting: false,
505
+ costTracking: false,
506
+ toolSchemaFormat: 'none',
507
+ },
508
+ decodeRequest(body) {
509
+ return {
510
+ model: 'replacement',
511
+ transport: 'plugin-duplicate',
512
+ messages: [],
513
+ stream: false,
514
+ raw: body,
515
+ };
516
+ },
517
+ encodeResult(result) {
518
+ return result;
519
+ },
520
+ encodeStreamChunk(event) {
521
+ return JSON.stringify(event);
522
+ },
523
+ };
524
+ registerCodec(first);
525
+ expect(() => registerCodec(second)).toThrow(/already registered/);
526
+ registerCodec(second, { override: true });
527
+ expect(getCodec('plugin-duplicate')).toBe(second);
528
+ });
529
+ it('returns undefined for unknown transport', () => {
530
+ const codec = getCodec('unknown-transport');
531
+ expect(codec).toBeUndefined();
532
+ });
533
+ it('returns undefined for empty string', () => {
534
+ const codec = getCodec('');
535
+ expect(codec).toBeUndefined();
536
+ });
537
+ });
538
+ describe('OpenAiResponsesCodec', () => {
539
+ const codec = new OpenAiResponsesCodec();
540
+ it('round-trips input text, tools, result usage, and SSE chunks', () => {
541
+ const request = codec.decodeRequest({
542
+ model: 'gpt-4.1',
543
+ input: [
544
+ { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] },
545
+ ],
546
+ tools: [
547
+ {
548
+ type: 'function',
549
+ name: 'lookup',
550
+ description: 'Lookup data',
551
+ parameters: { type: 'object', properties: { id: { type: 'string' } } },
552
+ },
553
+ ],
554
+ stream: true,
555
+ });
556
+ expect(request.input).toBe('hello');
557
+ expect(request.messages).toEqual([{ role: 'user', content: 'hello' }]);
558
+ expect(codec.normalizeTools(request.tools)[0]).toMatchObject({ name: 'lookup' });
559
+ const encoded = codec.encodeResult(makeResult());
560
+ expect(encoded.object).toBe('response');
561
+ expect(encoded.usage.input_tokens).toBe(10);
562
+ expect(encoded.usage.output_tokens).toBe(5);
563
+ expect(codec.encodeStreamChunk({ type: 'text-delta', text: 'hi' })).toContain('response.output_text.delta');
564
+ expect(codec.encodeStreamChunk({ type: 'done', finishReason: 'stop' })).toContain('response.completed');
565
+ });
566
+ it('preserves Responses function call and output items for cross-provider tool loops', () => {
567
+ const request = codec.decodeRequest({
568
+ model: 'gpt-5-codex',
569
+ input: [
570
+ {
571
+ type: 'message',
572
+ role: 'user',
573
+ content: [{ type: 'input_text', text: 'write the odyssey artifact' }],
574
+ },
575
+ {
576
+ type: 'function_call',
577
+ call_id: 'toolu_write_file',
578
+ name: 'Write',
579
+ arguments: JSON.stringify({ file_path: '/tmp/odyssey.md', content: '# Odyssey' }),
580
+ },
581
+ {
582
+ type: 'function_call_output',
583
+ call_id: 'toolu_write_file',
584
+ output: 'created',
585
+ },
586
+ ],
587
+ });
588
+ expect(request.messages).toEqual([
589
+ {
590
+ role: 'user',
591
+ content: 'write the odyssey artifact',
592
+ },
593
+ {
594
+ role: 'assistant',
595
+ content: '',
596
+ rawContent: [{
597
+ type: 'tool_use',
598
+ id: 'toolu_write_file',
599
+ name: 'Write',
600
+ input: { file_path: '/tmp/odyssey.md', content: '# Odyssey' },
601
+ }],
602
+ },
603
+ {
604
+ role: 'tool',
605
+ content: 'created',
606
+ rawContent: [{
607
+ type: 'tool_result',
608
+ tool_use_id: 'toolu_write_file',
609
+ content: 'created',
610
+ }],
611
+ },
612
+ ]);
613
+ expect(request.input).toBe('write the odyssey artifact');
614
+ });
615
+ });
616
+ describe('BedrockConverseCodec', () => {
617
+ const codec = new BedrockConverseCodec();
618
+ it('round-trips Bedrock Converse messages, result usage, and NDJSON chunks', () => {
619
+ const request = codec.decodeRequest({
620
+ modelId: 'anthropic.claude-sonnet',
621
+ messages: [{ role: 'user', content: [{ text: 'hello' }] }],
622
+ toolConfig: {
623
+ tools: [
624
+ {
625
+ toolSpec: {
626
+ name: 'lookup',
627
+ description: 'Lookup data',
628
+ inputSchema: { json: { type: 'object', properties: { id: { type: 'string' } } } },
629
+ },
630
+ },
631
+ ],
632
+ },
633
+ });
634
+ expect(request.model).toBe('anthropic.claude-sonnet');
635
+ expect(request.messages).toEqual([{ role: 'user', content: 'hello' }]);
636
+ expect(codec.normalizeTools(request.tools)[0]).toMatchObject({ name: 'lookup' });
637
+ const encoded = codec.encodeResult(makeResult());
638
+ expect(encoded.usage.inputTokens).toBe(10);
639
+ expect(encoded.usage.outputTokens).toBe(5);
640
+ expect(codec.encodeStreamChunk({ type: 'text-delta', text: 'hi' })).toContain('contentBlockDelta');
641
+ expect(codec.encodeStreamChunk({ type: 'done', finishReason: 'stop' })).toContain('messageStop');
642
+ });
643
+ });
644
+ describe('tool schema translation and usage normalization', () => {
645
+ it('converts OpenAI tools to Anthropic and Google schema shapes', () => {
646
+ const openAiTools = [
647
+ {
648
+ type: 'function',
649
+ function: {
650
+ name: 'search',
651
+ description: 'Search',
652
+ parameters: { type: 'object', properties: { q: { type: 'string' } } },
653
+ },
654
+ },
655
+ ];
656
+ expect(convertTools(openAiTools, 'openai', 'anthropic')).toEqual([
657
+ {
658
+ name: 'search',
659
+ description: 'Search',
660
+ input_schema: { type: 'object', properties: { q: { type: 'string' } } },
661
+ },
662
+ ]);
663
+ expect(convertTools(openAiTools, 'openai', 'google')).toEqual([
664
+ {
665
+ functionDeclarations: [
666
+ {
667
+ name: 'search',
668
+ description: 'Search',
669
+ parameters: { type: 'object', properties: { q: { type: 'string' } } },
670
+ },
671
+ ],
672
+ },
673
+ ]);
674
+ });
675
+ it('normalizes OpenAI, Anthropic, Google, and Bedrock usage fields', () => {
676
+ expect(normalizeUsage({ prompt_tokens: 7, completion_tokens: 3 })).toEqual({
677
+ promptTokens: 7,
678
+ completionTokens: 3,
679
+ totalTokens: 10,
680
+ });
681
+ expect(normalizeUsage({ input_tokens: 11, output_tokens: 5 })).toEqual({
682
+ promptTokens: 11,
683
+ completionTokens: 5,
684
+ totalTokens: 16,
685
+ });
686
+ expect(normalizeUsage({ promptTokenCount: 13, candidatesTokenCount: 8 })).toEqual({
687
+ promptTokens: 13,
688
+ completionTokens: 8,
689
+ totalTokens: 21,
690
+ });
691
+ expect(normalizeUsage({ inputTokens: 17, outputTokens: 9 })).toEqual({
692
+ promptTokens: 17,
693
+ completionTokens: 9,
694
+ totalTokens: 26,
695
+ });
696
+ });
697
+ });
698
+ //# sourceMappingURL=codecs.test.js.map