@ai-sdk/gateway 0.0.0-64aae7dd-20260114144918 → 0.0.0-98261322-20260122142521

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 (47) hide show
  1. package/CHANGELOG.md +49 -4
  2. package/dist/index.d.mts +20 -10
  3. package/dist/index.d.ts +20 -10
  4. package/dist/index.js +62 -25
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +62 -25
  7. package/dist/index.mjs.map +1 -1
  8. package/docs/00-ai-gateway.mdx +625 -0
  9. package/package.json +12 -5
  10. package/src/errors/as-gateway-error.ts +33 -0
  11. package/src/errors/create-gateway-error.test.ts +590 -0
  12. package/src/errors/create-gateway-error.ts +132 -0
  13. package/src/errors/extract-api-call-response.test.ts +270 -0
  14. package/src/errors/extract-api-call-response.ts +15 -0
  15. package/src/errors/gateway-authentication-error.ts +84 -0
  16. package/src/errors/gateway-error-types.test.ts +278 -0
  17. package/src/errors/gateway-error.ts +47 -0
  18. package/src/errors/gateway-internal-server-error.ts +33 -0
  19. package/src/errors/gateway-invalid-request-error.ts +33 -0
  20. package/src/errors/gateway-model-not-found-error.ts +47 -0
  21. package/src/errors/gateway-rate-limit-error.ts +33 -0
  22. package/src/errors/gateway-response-error.ts +42 -0
  23. package/src/errors/index.ts +16 -0
  24. package/src/errors/parse-auth-method.test.ts +136 -0
  25. package/src/errors/parse-auth-method.ts +23 -0
  26. package/src/gateway-config.ts +7 -0
  27. package/src/gateway-embedding-model-settings.ts +22 -0
  28. package/src/gateway-embedding-model.test.ts +213 -0
  29. package/src/gateway-embedding-model.ts +109 -0
  30. package/src/gateway-fetch-metadata.test.ts +774 -0
  31. package/src/gateway-fetch-metadata.ts +127 -0
  32. package/src/gateway-image-model-settings.ts +12 -0
  33. package/src/gateway-image-model.test.ts +823 -0
  34. package/src/gateway-image-model.ts +145 -0
  35. package/src/gateway-language-model-settings.ts +159 -0
  36. package/src/gateway-language-model.test.ts +1485 -0
  37. package/src/gateway-language-model.ts +212 -0
  38. package/src/gateway-model-entry.ts +58 -0
  39. package/src/gateway-provider-options.ts +66 -0
  40. package/src/gateway-provider.test.ts +1210 -0
  41. package/src/gateway-provider.ts +284 -0
  42. package/src/gateway-tools.ts +15 -0
  43. package/src/index.ts +27 -0
  44. package/src/tool/perplexity-search.ts +294 -0
  45. package/src/vercel-environment.test.ts +65 -0
  46. package/src/vercel-environment.ts +6 -0
  47. package/src/version.ts +6 -0
@@ -0,0 +1,774 @@
1
+ import { createTestServer } from '@ai-sdk/test-server/with-vitest';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { GatewayFetchMetadata } from './gateway-fetch-metadata';
4
+ import type { FetchFunction } from '@ai-sdk/provider-utils';
5
+ import {
6
+ GatewayAuthenticationError,
7
+ GatewayInternalServerError,
8
+ GatewayRateLimitError,
9
+ GatewayResponseError,
10
+ GatewayError,
11
+ } from './errors';
12
+
13
+ function createBasicMetadataFetcher({
14
+ headers,
15
+ fetch,
16
+ }: {
17
+ headers?: () => Record<string, string>;
18
+ fetch?: FetchFunction;
19
+ } = {}) {
20
+ return new GatewayFetchMetadata({
21
+ baseURL: 'https://api.example.com',
22
+ headers: headers ?? (() => ({ Authorization: 'Bearer test-token' })),
23
+ fetch,
24
+ });
25
+ }
26
+
27
+ describe('GatewayFetchMetadata', () => {
28
+ const mockModelEntry = {
29
+ id: 'model-1',
30
+ name: 'Model One',
31
+ description: 'A test model',
32
+ pricing: {
33
+ input: '0.000001',
34
+ output: '0.000002',
35
+ },
36
+ specification: {
37
+ specificationVersion: 'v3' as const,
38
+ provider: 'test-provider',
39
+ modelId: 'model-1',
40
+ },
41
+ };
42
+
43
+ const mockModelEntryWithoutPricing = {
44
+ id: 'model-2',
45
+ name: 'Model Two',
46
+ specification: {
47
+ specificationVersion: 'v3' as const,
48
+ provider: 'test-provider',
49
+ modelId: 'model-2',
50
+ },
51
+ };
52
+
53
+ const server = createTestServer({
54
+ 'https://api.example.com/*': {
55
+ response: {
56
+ type: 'json-value',
57
+ body: {
58
+ models: [mockModelEntry],
59
+ },
60
+ },
61
+ },
62
+ });
63
+
64
+ describe('getAvailableModels', () => {
65
+ it('should fetch available models from the correct endpoint', async () => {
66
+ const metadata = createBasicMetadataFetcher();
67
+
68
+ const result = await metadata.getAvailableModels();
69
+
70
+ expect(server.calls[0].requestMethod).toBe('GET');
71
+ expect(server.calls[0].requestUrl).toBe('https://api.example.com/config');
72
+ expect(result).toEqual({
73
+ models: [mockModelEntry],
74
+ });
75
+ });
76
+
77
+ it('should handle models with pricing information', async () => {
78
+ server.urls['https://api.example.com/*'].response = {
79
+ type: 'json-value',
80
+ body: {
81
+ models: [mockModelEntry],
82
+ },
83
+ };
84
+
85
+ const metadata = createBasicMetadataFetcher();
86
+ const result = await metadata.getAvailableModels();
87
+
88
+ expect(result.models[0]).toEqual(mockModelEntry);
89
+ expect(result.models[0].pricing).toEqual({
90
+ input: '0.000001',
91
+ output: '0.000002',
92
+ });
93
+ });
94
+
95
+ it('should map cache pricing fields to SDK names when present', async () => {
96
+ const gatewayEntryWithCache = {
97
+ ...mockModelEntry,
98
+ pricing: {
99
+ input: '0.000003',
100
+ output: '0.000015',
101
+ input_cache_read: '0.0000003',
102
+ input_cache_write: '0.00000375',
103
+ },
104
+ };
105
+
106
+ server.urls['https://api.example.com/*'].response = {
107
+ type: 'json-value',
108
+ body: {
109
+ models: [gatewayEntryWithCache],
110
+ },
111
+ };
112
+
113
+ const metadata = createBasicMetadataFetcher();
114
+ const result = await metadata.getAvailableModels();
115
+
116
+ expect(result.models[0].pricing).toEqual({
117
+ input: '0.000003',
118
+ output: '0.000015',
119
+ cachedInputTokens: '0.0000003',
120
+ cacheCreationInputTokens: '0.00000375',
121
+ });
122
+ const pricing = result.models[0].pricing;
123
+ expect(pricing).toBeDefined();
124
+ if (pricing) {
125
+ expect('input_cache_read' in pricing).toBe(false);
126
+ expect('input_cache_write' in pricing).toBe(false);
127
+ }
128
+ });
129
+
130
+ it('should handle models without pricing information', async () => {
131
+ server.urls['https://api.example.com/*'].response = {
132
+ type: 'json-value',
133
+ body: {
134
+ models: [mockModelEntryWithoutPricing],
135
+ },
136
+ };
137
+
138
+ const metadata = createBasicMetadataFetcher();
139
+ const result = await metadata.getAvailableModels();
140
+
141
+ expect(result.models[0]).toEqual(mockModelEntryWithoutPricing);
142
+ expect(result.models[0].pricing).toBeUndefined();
143
+ });
144
+
145
+ it('should handle mixed models with and without pricing', async () => {
146
+ server.urls['https://api.example.com/*'].response = {
147
+ type: 'json-value',
148
+ body: {
149
+ models: [mockModelEntry, mockModelEntryWithoutPricing],
150
+ },
151
+ };
152
+
153
+ const metadata = createBasicMetadataFetcher();
154
+ const result = await metadata.getAvailableModels();
155
+
156
+ expect(result.models).toHaveLength(2);
157
+ expect(result.models[0].pricing).toEqual({
158
+ input: '0.000001',
159
+ output: '0.000002',
160
+ });
161
+ expect(result.models[1].pricing).toBeUndefined();
162
+ });
163
+
164
+ it('should handle models with description', async () => {
165
+ const modelWithDescription = {
166
+ ...mockModelEntry,
167
+ description: 'A powerful language model',
168
+ };
169
+
170
+ server.urls['https://api.example.com/*'].response = {
171
+ type: 'json-value',
172
+ body: {
173
+ models: [modelWithDescription],
174
+ },
175
+ };
176
+
177
+ const metadata = createBasicMetadataFetcher();
178
+ const result = await metadata.getAvailableModels();
179
+
180
+ expect(result.models[0].description).toBe('A powerful language model');
181
+ });
182
+
183
+ it('should accept top-level modelType when present', async () => {
184
+ server.urls['https://api.example.com/*'].response = {
185
+ type: 'json-value',
186
+ body: {
187
+ models: [
188
+ {
189
+ ...mockModelEntry,
190
+ modelType: 'language',
191
+ },
192
+ ],
193
+ },
194
+ };
195
+
196
+ const metadata = createBasicMetadataFetcher();
197
+ const result = await metadata.getAvailableModels();
198
+ expect(result.models[0].modelType).toBe('language');
199
+ });
200
+
201
+ it('should reject invalid top-level modelType values', async () => {
202
+ server.urls['https://api.example.com/*'].response = {
203
+ type: 'json-value',
204
+ body: {
205
+ models: [
206
+ {
207
+ id: 'model-invalid-type',
208
+ name: 'Invalid Type Model',
209
+ specification: {
210
+ specificationVersion: 'v3' as const,
211
+ provider: 'test-provider',
212
+ modelId: 'model-invalid-type',
213
+ },
214
+ modelType: 'text',
215
+ },
216
+ ],
217
+ },
218
+ };
219
+
220
+ const metadata = createBasicMetadataFetcher();
221
+ await expect(metadata.getAvailableModels()).rejects.toThrow();
222
+ });
223
+
224
+ it('should pass headers correctly', async () => {
225
+ const metadata = createBasicMetadataFetcher({
226
+ headers: () => ({
227
+ Authorization: 'Bearer custom-token',
228
+ 'Custom-Header': 'custom-value',
229
+ }),
230
+ });
231
+
232
+ await metadata.getAvailableModels();
233
+
234
+ expect(server.calls[0].requestHeaders).toEqual({
235
+ authorization: 'Bearer custom-token',
236
+ 'custom-header': 'custom-value',
237
+ });
238
+ });
239
+
240
+ it('should handle API errors', async () => {
241
+ server.urls['https://api.example.com/*'].response = {
242
+ type: 'error',
243
+ status: 401,
244
+ body: JSON.stringify({
245
+ error: {
246
+ message: 'Unauthorized',
247
+ type: 'authentication_error',
248
+ },
249
+ }),
250
+ };
251
+
252
+ const metadata = createBasicMetadataFetcher();
253
+
254
+ try {
255
+ await metadata.getAvailableModels();
256
+ expect.fail('Should have thrown an error');
257
+ } catch (error) {
258
+ expect(GatewayAuthenticationError.isInstance(error)).toBe(true);
259
+ const authError = error as GatewayAuthenticationError;
260
+ expect(authError.message).toContain('No authentication provided');
261
+ expect(authError.type).toBe('authentication_error');
262
+ expect(authError.statusCode).toBe(401);
263
+ }
264
+ });
265
+
266
+ it('should convert API call errors to Gateway errors', async () => {
267
+ server.urls['https://api.example.com/*'].response = {
268
+ type: 'error',
269
+ status: 403,
270
+ body: JSON.stringify({
271
+ error: {
272
+ message: 'Forbidden access',
273
+ type: 'authentication_error',
274
+ },
275
+ }),
276
+ };
277
+
278
+ const metadata = createBasicMetadataFetcher();
279
+
280
+ try {
281
+ await metadata.getAvailableModels();
282
+ expect.fail('Should have thrown an error');
283
+ } catch (error) {
284
+ expect(GatewayAuthenticationError.isInstance(error)).toBe(true);
285
+ const authError = error as GatewayAuthenticationError;
286
+ expect(authError.message).toContain('No authentication provided');
287
+ expect(authError.type).toBe('authentication_error');
288
+ expect(authError.statusCode).toBe(403);
289
+ }
290
+ });
291
+
292
+ it('should handle malformed JSON error responses', async () => {
293
+ server.urls['https://api.example.com/*'].response = {
294
+ type: 'error',
295
+ status: 500,
296
+ body: '{ invalid json',
297
+ };
298
+
299
+ const metadata = createBasicMetadataFetcher();
300
+
301
+ try {
302
+ await metadata.getAvailableModels();
303
+ expect.fail('Should have thrown an error');
304
+ } catch (error) {
305
+ expect(GatewayResponseError.isInstance(error)).toBe(true);
306
+ const responseError = error as GatewayResponseError;
307
+ expect(responseError.statusCode).toBe(500);
308
+ expect(responseError.type).toBe('response_error');
309
+ }
310
+ });
311
+
312
+ it('should handle malformed response data', async () => {
313
+ server.urls['https://api.example.com/*'].response = {
314
+ type: 'json-value',
315
+ body: {
316
+ invalid: 'response',
317
+ },
318
+ };
319
+
320
+ const metadata = createBasicMetadataFetcher();
321
+
322
+ await expect(metadata.getAvailableModels()).rejects.toThrow();
323
+ });
324
+
325
+ it('should reject models with invalid pricing format', async () => {
326
+ server.urls['https://api.example.com/*'].response = {
327
+ type: 'json-value',
328
+ body: {
329
+ models: [
330
+ {
331
+ id: 'model-1',
332
+ name: 'Model One',
333
+ pricing: {
334
+ input: 123, // Should be string, not number
335
+ output: '0.000002',
336
+ },
337
+ specification: {
338
+ specificationVersion: 'v3',
339
+ provider: 'test-provider',
340
+ modelId: 'model-1',
341
+ },
342
+ },
343
+ ],
344
+ },
345
+ };
346
+
347
+ const metadata = createBasicMetadataFetcher();
348
+
349
+ await expect(metadata.getAvailableModels()).rejects.toThrow();
350
+ });
351
+
352
+ it('should not double-wrap existing Gateway errors', async () => {
353
+ // Create a Gateway error and verify it doesn't get wrapped
354
+ const existingError = new GatewayAuthenticationError({
355
+ message: 'Already wrapped',
356
+ statusCode: 401,
357
+ });
358
+
359
+ // Test the catch block logic directly
360
+ try {
361
+ throw existingError;
362
+ } catch (error: unknown) {
363
+ if (GatewayError.isInstance(error)) {
364
+ expect(error).toBe(existingError); // Should be the same instance
365
+ expect(error.message).toBe('Already wrapped');
366
+ return;
367
+ }
368
+ throw new Error('Should not reach here');
369
+ }
370
+ });
371
+
372
+ it('should handle various server error types', async () => {
373
+ // Test rate limit error
374
+ server.urls['https://api.example.com/*'].response = {
375
+ type: 'error',
376
+ status: 429,
377
+ body: JSON.stringify({
378
+ error: {
379
+ message: 'Rate limit exceeded',
380
+ type: 'rate_limit_exceeded',
381
+ },
382
+ }),
383
+ };
384
+
385
+ const metadata = createBasicMetadataFetcher();
386
+
387
+ try {
388
+ await metadata.getAvailableModels();
389
+ expect.fail('Should have thrown an error');
390
+ } catch (error) {
391
+ expect(GatewayRateLimitError.isInstance(error)).toBe(true);
392
+ const rateLimitError = error as GatewayRateLimitError;
393
+ expect(rateLimitError.message).toBe('Rate limit exceeded');
394
+ expect(rateLimitError.type).toBe('rate_limit_exceeded');
395
+ expect(rateLimitError.statusCode).toBe(429);
396
+ }
397
+ });
398
+
399
+ it('should handle internal server errors', async () => {
400
+ server.urls['https://api.example.com/*'].response = {
401
+ type: 'error',
402
+ status: 500,
403
+ body: JSON.stringify({
404
+ error: {
405
+ message: 'Database connection failed',
406
+ type: 'internal_server_error',
407
+ },
408
+ }),
409
+ };
410
+
411
+ const metadata = createBasicMetadataFetcher();
412
+
413
+ try {
414
+ await metadata.getAvailableModels();
415
+ expect.fail('Should have thrown an error');
416
+ } catch (error) {
417
+ expect(GatewayInternalServerError.isInstance(error)).toBe(true);
418
+ const serverError = error as GatewayInternalServerError;
419
+ expect(serverError.message).toBe('Database connection failed');
420
+ expect(serverError.type).toBe('internal_server_error');
421
+ expect(serverError.statusCode).toBe(500);
422
+ }
423
+ });
424
+
425
+ it('should preserve error cause chain', async () => {
426
+ server.urls['https://api.example.com/*'].response = {
427
+ type: 'error',
428
+ status: 401,
429
+ body: JSON.stringify({
430
+ error: {
431
+ message: 'Token expired',
432
+ type: 'authentication_error',
433
+ },
434
+ }),
435
+ };
436
+
437
+ const metadata = createBasicMetadataFetcher();
438
+
439
+ try {
440
+ await metadata.getAvailableModels();
441
+ expect.fail('Should have thrown an error');
442
+ } catch (error) {
443
+ expect(GatewayAuthenticationError.isInstance(error)).toBe(true);
444
+ const authError = error as GatewayAuthenticationError;
445
+ expect(authError.cause).toBeDefined();
446
+ }
447
+ });
448
+
449
+ it('should use custom fetch function when provided', async () => {
450
+ const customModelEntry = {
451
+ id: 'custom-model-1',
452
+ name: 'Custom Model One',
453
+ description: 'Custom model description',
454
+ pricing: {
455
+ input: '0.000005',
456
+ output: '0.000010',
457
+ },
458
+ specification: {
459
+ specificationVersion: 'v3' as const,
460
+ provider: 'custom-provider',
461
+ modelId: 'custom-model-1',
462
+ },
463
+ };
464
+
465
+ const mockFetch = vi.fn().mockResolvedValue(
466
+ new Response(
467
+ JSON.stringify({
468
+ models: [customModelEntry],
469
+ }),
470
+ {
471
+ status: 200,
472
+ headers: {
473
+ 'Content-Type': 'application/json',
474
+ },
475
+ },
476
+ ),
477
+ );
478
+
479
+ const metadata = createBasicMetadataFetcher({
480
+ fetch: mockFetch,
481
+ });
482
+
483
+ const result = await metadata.getAvailableModels();
484
+
485
+ expect(mockFetch).toHaveBeenCalled();
486
+ expect(result).toEqual({
487
+ models: [customModelEntry],
488
+ });
489
+ });
490
+
491
+ it('should handle empty response', async () => {
492
+ server.urls['https://api.example.com/*'].response = {
493
+ type: 'json-value',
494
+ body: {
495
+ models: [],
496
+ },
497
+ };
498
+
499
+ const metadata = createBasicMetadataFetcher();
500
+ const result = await metadata.getAvailableModels();
501
+
502
+ expect(result).toEqual({
503
+ models: [],
504
+ });
505
+ });
506
+ });
507
+
508
+ describe('getCredits', () => {
509
+ it('should fetch credits from the correct endpoint', async () => {
510
+ server.urls['https://api.example.com/*'].response = {
511
+ type: 'json-value',
512
+ body: {
513
+ balance: '150.50',
514
+ total_used: '75.25',
515
+ },
516
+ };
517
+
518
+ const metadata = createBasicMetadataFetcher();
519
+ const result = await metadata.getCredits();
520
+
521
+ expect(result).toEqual({
522
+ balance: '150.50',
523
+ totalUsed: '75.25',
524
+ });
525
+ });
526
+
527
+ it('should pass headers correctly to credits endpoint', async () => {
528
+ server.urls['https://api.example.com/*'].response = {
529
+ type: 'json-value',
530
+ body: {
531
+ balance: '100.00',
532
+ total_used: '50.00',
533
+ },
534
+ };
535
+
536
+ const metadata = createBasicMetadataFetcher({
537
+ headers: () => ({
538
+ Authorization: 'Bearer custom-token',
539
+ 'Custom-Header': 'custom-value',
540
+ }),
541
+ });
542
+
543
+ const result = await metadata.getCredits();
544
+
545
+ expect(server.calls[0].requestHeaders).toEqual({
546
+ authorization: 'Bearer custom-token',
547
+ 'custom-header': 'custom-value',
548
+ });
549
+ expect(result).toEqual({
550
+ balance: '100.00',
551
+ totalUsed: '50.00',
552
+ });
553
+ });
554
+
555
+ it('should handle API errors for credits endpoint', async () => {
556
+ server.urls['https://api.example.com/*'].response = {
557
+ type: 'error',
558
+ status: 401,
559
+ body: JSON.stringify({
560
+ error: {
561
+ type: 'authentication_error',
562
+ message: 'Invalid API key',
563
+ },
564
+ }),
565
+ };
566
+
567
+ const metadata = createBasicMetadataFetcher();
568
+
569
+ await expect(metadata.getCredits()).rejects.toThrow(
570
+ GatewayAuthenticationError,
571
+ );
572
+ });
573
+
574
+ it('should handle rate limit errors for credits endpoint', async () => {
575
+ server.urls['https://api.example.com/*'].response = {
576
+ type: 'error',
577
+ status: 429,
578
+ body: JSON.stringify({
579
+ error: {
580
+ type: 'rate_limit_exceeded',
581
+ message: 'Rate limit exceeded',
582
+ },
583
+ }),
584
+ };
585
+
586
+ const metadata = createBasicMetadataFetcher();
587
+
588
+ await expect(metadata.getCredits()).rejects.toThrow(
589
+ GatewayRateLimitError,
590
+ );
591
+ });
592
+
593
+ it('should handle internal server errors for credits endpoint', async () => {
594
+ server.urls['https://api.example.com/*'].response = {
595
+ type: 'error',
596
+ status: 500,
597
+ body: JSON.stringify({
598
+ error: {
599
+ type: 'internal_server_error',
600
+ message: 'Database unavailable',
601
+ },
602
+ }),
603
+ };
604
+
605
+ const metadata = createBasicMetadataFetcher();
606
+
607
+ await expect(metadata.getCredits()).rejects.toThrow(
608
+ GatewayInternalServerError,
609
+ );
610
+ });
611
+
612
+ it('should handle malformed credits response', async () => {
613
+ server.urls['https://api.example.com/*'].response = {
614
+ type: 'json-value',
615
+ body: {
616
+ balance: 'not-a-number',
617
+ total_used: '75.25',
618
+ },
619
+ };
620
+
621
+ const metadata = createBasicMetadataFetcher();
622
+ const result = await metadata.getCredits();
623
+
624
+ expect(result).toEqual({
625
+ balance: 'not-a-number',
626
+ totalUsed: '75.25',
627
+ });
628
+ });
629
+
630
+ it('should use custom fetch function when provided', async () => {
631
+ const customFetch = vi.fn().mockResolvedValue(
632
+ new Response(
633
+ JSON.stringify({
634
+ balance: '200.00',
635
+ total_used: '100.50',
636
+ }),
637
+ {
638
+ status: 200,
639
+ headers: {
640
+ 'Content-Type': 'application/json',
641
+ },
642
+ },
643
+ ),
644
+ );
645
+
646
+ const metadata = createBasicMetadataFetcher({
647
+ fetch: customFetch as unknown as FetchFunction,
648
+ });
649
+
650
+ const result = await metadata.getCredits();
651
+
652
+ expect(result).toEqual({
653
+ balance: '200.00',
654
+ totalUsed: '100.50',
655
+ });
656
+
657
+ expect(customFetch).toHaveBeenCalledWith(
658
+ 'https://api.example.com/v1/credits',
659
+ expect.objectContaining({
660
+ method: 'GET',
661
+ headers: expect.objectContaining({
662
+ authorization: 'Bearer test-token',
663
+ }),
664
+ }),
665
+ );
666
+ });
667
+
668
+ it('should convert API call errors to Gateway errors', async () => {
669
+ server.urls['https://api.example.com/*'].response = {
670
+ type: 'error',
671
+ status: 403,
672
+ body: JSON.stringify({
673
+ error: {
674
+ message: 'Forbidden access',
675
+ type: 'authentication_error',
676
+ },
677
+ }),
678
+ };
679
+
680
+ const metadata = createBasicMetadataFetcher();
681
+
682
+ try {
683
+ await metadata.getCredits();
684
+ expect.fail('Should have thrown an error');
685
+ } catch (error) {
686
+ expect(GatewayAuthenticationError.isInstance(error)).toBe(true);
687
+ const authError = error as GatewayAuthenticationError;
688
+ expect(authError.message).toContain('No authentication provided');
689
+ expect(authError.type).toBe('authentication_error');
690
+ expect(authError.statusCode).toBe(403);
691
+ }
692
+ });
693
+
694
+ it('should handle malformed JSON error responses', async () => {
695
+ server.urls['https://api.example.com/*'].response = {
696
+ type: 'error',
697
+ status: 500,
698
+ body: '{ invalid json',
699
+ };
700
+
701
+ const metadata = createBasicMetadataFetcher();
702
+
703
+ try {
704
+ await metadata.getCredits();
705
+ expect.fail('Should have thrown an error');
706
+ } catch (error) {
707
+ expect(GatewayResponseError.isInstance(error)).toBe(true);
708
+ const responseError = error as GatewayResponseError;
709
+ expect(responseError.statusCode).toBe(500);
710
+ expect(responseError.type).toBe('response_error');
711
+ }
712
+ });
713
+
714
+ it('should not double-wrap existing Gateway errors', async () => {
715
+ const existingError = new GatewayAuthenticationError({
716
+ message: 'Already wrapped',
717
+ statusCode: 401,
718
+ });
719
+
720
+ try {
721
+ throw existingError;
722
+ } catch (error: unknown) {
723
+ if (GatewayError.isInstance(error)) {
724
+ expect(error).toBe(existingError);
725
+ expect(error.message).toBe('Already wrapped');
726
+ return;
727
+ }
728
+ throw new Error('Should not reach here');
729
+ }
730
+ });
731
+
732
+ it('should preserve error cause chain', async () => {
733
+ server.urls['https://api.example.com/*'].response = {
734
+ type: 'error',
735
+ status: 401,
736
+ body: JSON.stringify({
737
+ error: {
738
+ message: 'Token expired',
739
+ type: 'authentication_error',
740
+ },
741
+ }),
742
+ };
743
+
744
+ const metadata = createBasicMetadataFetcher();
745
+
746
+ try {
747
+ await metadata.getCredits();
748
+ expect.fail('Should have thrown an error');
749
+ } catch (error) {
750
+ expect(GatewayAuthenticationError.isInstance(error)).toBe(true);
751
+ const authError = error as GatewayAuthenticationError;
752
+ expect(authError.cause).toBeDefined();
753
+ }
754
+ });
755
+
756
+ it('should handle empty response', async () => {
757
+ server.urls['https://api.example.com/*'].response = {
758
+ type: 'json-value',
759
+ body: {
760
+ balance: '0.00',
761
+ total_used: '0.00',
762
+ },
763
+ };
764
+
765
+ const metadata = createBasicMetadataFetcher();
766
+ const result = await metadata.getCredits();
767
+
768
+ expect(result).toEqual({
769
+ balance: '0.00',
770
+ totalUsed: '0.00',
771
+ });
772
+ });
773
+ });
774
+ });