openllmprovider 0.1.0

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 (91) hide show
  1. package/README.md +192 -0
  2. package/dist/auth/index.cjs +6 -0
  3. package/dist/auth/index.d.cts +3 -0
  4. package/dist/auth/index.d.mts +3 -0
  5. package/dist/auth/index.mjs +3 -0
  6. package/dist/auto-C2hXJY13.d.cts +33 -0
  7. package/dist/auto-C2hXJY13.d.cts.map +1 -0
  8. package/dist/auto-CBqNYBXs.mjs +48 -0
  9. package/dist/auto-CBqNYBXs.mjs.map +1 -0
  10. package/dist/auto-CInerwvs.d.mts +33 -0
  11. package/dist/auto-CInerwvs.d.mts.map +1 -0
  12. package/dist/auto-D77wgMqO.cjs +59 -0
  13. package/dist/auto-D77wgMqO.cjs.map +1 -0
  14. package/dist/file-DB-rxfzi.mjs +77 -0
  15. package/dist/file-DB-rxfzi.mjs.map +1 -0
  16. package/dist/file-DZ7FGcSW.cjs +73 -0
  17. package/dist/file-DZ7FGcSW.cjs.map +1 -0
  18. package/dist/index.cjs +1909 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +1239 -0
  21. package/dist/index.d.cts.map +1 -0
  22. package/dist/index.d.mts +1241 -0
  23. package/dist/index.d.mts.map +1 -0
  24. package/dist/index.mjs +1891 -0
  25. package/dist/index.mjs.map +1 -0
  26. package/dist/logger-BsHpI_fH.mjs +11 -0
  27. package/dist/logger-BsHpI_fH.mjs.map +1 -0
  28. package/dist/logger-jRimlMFR.cjs +69 -0
  29. package/dist/logger-jRimlMFR.cjs.map +1 -0
  30. package/dist/plugin/index.cjs +29 -0
  31. package/dist/plugin/index.cjs.map +1 -0
  32. package/dist/plugin/index.d.cts +10 -0
  33. package/dist/plugin/index.d.cts.map +1 -0
  34. package/dist/plugin/index.d.mts +10 -0
  35. package/dist/plugin/index.d.mts.map +1 -0
  36. package/dist/plugin/index.mjs +25 -0
  37. package/dist/plugin/index.mjs.map +1 -0
  38. package/dist/plugin-BkeUu5LW.d.mts +46 -0
  39. package/dist/plugin-BkeUu5LW.d.mts.map +1 -0
  40. package/dist/plugin-wK7RmJhZ.d.cts +46 -0
  41. package/dist/plugin-wK7RmJhZ.d.cts.map +1 -0
  42. package/dist/resolver-BA7LWSJO.mjs +645 -0
  43. package/dist/resolver-BA7LWSJO.mjs.map +1 -0
  44. package/dist/resolver-BMTvzTt9.cjs +662 -0
  45. package/dist/resolver-BMTvzTt9.cjs.map +1 -0
  46. package/dist/resolver-MgJryMWG.d.cts +75 -0
  47. package/dist/resolver-MgJryMWG.d.cts.map +1 -0
  48. package/dist/resolver-_gfXzr_S.d.mts +76 -0
  49. package/dist/resolver-_gfXzr_S.d.mts.map +1 -0
  50. package/dist/storage/index.cjs +7 -0
  51. package/dist/storage/index.d.cts +12 -0
  52. package/dist/storage/index.d.cts.map +1 -0
  53. package/dist/storage/index.d.mts +12 -0
  54. package/dist/storage/index.d.mts.map +1 -0
  55. package/dist/storage/index.mjs +4 -0
  56. package/package.json +137 -0
  57. package/src/auth/.gitkeep +0 -0
  58. package/src/auth/index.ts +10 -0
  59. package/src/auth/resolver.ts +46 -0
  60. package/src/auth/scanners.ts +462 -0
  61. package/src/auth/store.ts +357 -0
  62. package/src/catalog/.gitkeep +0 -0
  63. package/src/catalog/catalog.ts +302 -0
  64. package/src/catalog/index.ts +17 -0
  65. package/src/catalog/mapper.ts +129 -0
  66. package/src/catalog/merger.ts +99 -0
  67. package/src/index.ts +37 -0
  68. package/src/logger.ts +7 -0
  69. package/src/plugin/.gitkeep +0 -0
  70. package/src/plugin/anthropic.test.ts +505 -0
  71. package/src/plugin/anthropic.ts +324 -0
  72. package/src/plugin/codex.ts +656 -0
  73. package/src/plugin/copilot.ts +161 -0
  74. package/src/plugin/google.ts +454 -0
  75. package/src/plugin/index.ts +30 -0
  76. package/src/provider/.gitkeep +0 -0
  77. package/src/provider/bundled.ts +59 -0
  78. package/src/provider/index.ts +249 -0
  79. package/src/provider/state.ts +163 -0
  80. package/src/storage/.gitkeep +0 -0
  81. package/src/storage/auto.ts +32 -0
  82. package/src/storage/file.ts +84 -0
  83. package/src/storage/index.ts +10 -0
  84. package/src/storage/memory.ts +23 -0
  85. package/src/types/.gitkeep +0 -0
  86. package/src/types/auth.ts +18 -0
  87. package/src/types/errors.ts +87 -0
  88. package/src/types/index.ts +26 -0
  89. package/src/types/model.ts +88 -0
  90. package/src/types/plugin.ts +49 -0
  91. package/src/types/provider.ts +48 -0
@@ -0,0 +1,505 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
2
+ import type { AuthCredential } from '../types/plugin.js'
3
+ import { anthropicPlugin } from './anthropic.js'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Anthropic plugin supports two auth modes. These tests verify the loader
7
+ // behavior for each:
8
+ //
9
+ // 1. API key auth (type: 'api')
10
+ // - The loader returns {} immediately — no custom SDK config needed.
11
+ // - The SDK uses the `key` field via the standard x-api-key header.
12
+ //
13
+ // 2. OAuth auth (type: 'oauth')
14
+ // - The loader inspects the credential and may:
15
+ // a. Refresh an expired access_token using the refresh_token
16
+ // b. Use a token directly as apiKey if it looks like an API key (sk-ant-*)
17
+ // c. Exchange an OAuth access_token for a real API key via Anthropic API
18
+ // d. Fall back to Bearer token auth with a custom fetch wrapper
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const originalFetch = globalThis.fetch
22
+
23
+ beforeEach(() => {
24
+ // Reset fetch mock before each test
25
+ globalThis.fetch = originalFetch
26
+ })
27
+
28
+ afterEach(() => {
29
+ globalThis.fetch = originalFetch
30
+ })
31
+
32
+ function makeGetAuth(credential: AuthCredential) {
33
+ return async () => credential
34
+ }
35
+
36
+ // =====================================================================
37
+ // 1. API key auth
38
+ // =====================================================================
39
+ describe('anthropic loader — API key auth', () => {
40
+ it('returns empty config for type=api (SDK handles x-api-key automatically)', async () => {
41
+ const getAuth = makeGetAuth({
42
+ type: 'api',
43
+ key: 'sk-ant-api03-test-key',
44
+ })
45
+
46
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
47
+ expect(result).toEqual({})
48
+ })
49
+
50
+ it('returns empty config for type=wellknown', async () => {
51
+ const getAuth = makeGetAuth({
52
+ type: 'wellknown',
53
+ key: 'sk-ant-api03-discovered-key',
54
+ })
55
+
56
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
57
+ expect(result).toEqual({})
58
+ })
59
+ })
60
+
61
+ // =====================================================================
62
+ // 2. OAuth auth
63
+ // =====================================================================
64
+ describe('anthropic loader — OAuth auth', () => {
65
+ it('uses token as apiKey when it looks like an API key (sk-ant-api03-*)', async () => {
66
+ // After OAuth flow, the token was already exchanged for an API key and
67
+ // stored back in `key`. On next load, it looks like an API key directly.
68
+ const getAuth = makeGetAuth({
69
+ type: 'oauth',
70
+ key: 'sk-ant-api03-already-exchanged',
71
+ expires: Date.now() + 3600_000, // not expired
72
+ })
73
+
74
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
75
+ expect(result).toEqual({ apiKey: 'sk-ant-api03-already-exchanged' })
76
+ })
77
+
78
+ it('exchanges OAuth access_token for API key via create_api_key endpoint', async () => {
79
+ const getAuth = makeGetAuth({
80
+ type: 'oauth',
81
+ key: 'sk-ant-oat-valid-access-token',
82
+ refresh: 'sk-ant-ort-valid-refresh-token',
83
+ expires: Date.now() + 3600_000, // not expired
84
+ })
85
+
86
+ // Mock the API key exchange endpoint
87
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
88
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
89
+ if (urlStr.includes('create_api_key')) {
90
+ return new Response(JSON.stringify({ raw_key: 'sk-ant-api03-exchanged-key' }), {
91
+ status: 200,
92
+ headers: { 'Content-Type': 'application/json' },
93
+ })
94
+ }
95
+ throw new Error(`Unexpected fetch: ${urlStr}`)
96
+ }) as unknown as typeof fetch
97
+
98
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
99
+ expect(result).toEqual({ apiKey: 'sk-ant-api03-exchanged-key' })
100
+ })
101
+
102
+ it('refreshes expired token before exchanging for API key', async () => {
103
+ const getAuth = makeGetAuth({
104
+ type: 'oauth',
105
+ key: 'sk-ant-oat-expired-access-token',
106
+ refresh: 'sk-ant-ort-valid-refresh-token',
107
+ expires: Date.now() - 1000, // already expired
108
+ })
109
+
110
+ const fetchCalls: string[] = []
111
+
112
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
113
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
114
+ fetchCalls.push(urlStr)
115
+
116
+ // Token refresh endpoint
117
+ if (urlStr.includes('/v1/oauth/token')) {
118
+ return new Response(
119
+ JSON.stringify({
120
+ access_token: 'sk-ant-oat-refreshed-token',
121
+ refresh_token: 'sk-ant-ort-new-refresh',
122
+ expires_in: 3600,
123
+ }),
124
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
125
+ )
126
+ }
127
+
128
+ // API key exchange endpoint (called with refreshed token)
129
+ if (urlStr.includes('create_api_key')) {
130
+ return new Response(JSON.stringify({ raw_key: 'sk-ant-api03-from-refreshed' }), {
131
+ status: 200,
132
+ headers: { 'Content-Type': 'application/json' },
133
+ })
134
+ }
135
+
136
+ throw new Error(`Unexpected fetch: ${urlStr}`)
137
+ }) as unknown as typeof fetch
138
+
139
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
140
+
141
+ // Should have called refresh first, then exchange
142
+ expect(fetchCalls).toHaveLength(2)
143
+ expect(fetchCalls[0]).toContain('/v1/oauth/token')
144
+ expect(fetchCalls[1]).toContain('create_api_key')
145
+ expect(result).toEqual({ apiKey: 'sk-ant-api03-from-refreshed' })
146
+ })
147
+
148
+ it('falls back to Bearer auth when API key exchange fails', async () => {
149
+ const credential: AuthCredential = {
150
+ type: 'oauth',
151
+ key: 'sk-ant-oat-valid-but-exchange-fails',
152
+ expires: Date.now() + 3600_000,
153
+ }
154
+ const getAuth = makeGetAuth(credential)
155
+
156
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
157
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
158
+
159
+ // API key exchange fails
160
+ if (urlStr.includes('create_api_key')) {
161
+ return new Response('Forbidden', { status: 403 })
162
+ }
163
+
164
+ // The fallback custom fetch — just return a dummy response
165
+ return new Response('ok', { status: 200 })
166
+ }) as unknown as typeof fetch
167
+
168
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
169
+
170
+ // Should return headers + custom fetch (Bearer fallback)
171
+ expect(result).toHaveProperty('headers')
172
+ expect(result).toHaveProperty('fetch')
173
+ expect((result.headers as Record<string, string>)['anthropic-beta']).toBeDefined()
174
+ })
175
+
176
+ it('falls back to Bearer auth when token is empty after refresh failure', async () => {
177
+ const getAuth = makeGetAuth({
178
+ type: 'oauth',
179
+ key: undefined, // no access token
180
+ refresh: 'sk-ant-ort-valid-refresh-token',
181
+ expires: Date.now() - 1000, // expired
182
+ })
183
+
184
+ globalThis.fetch = mock(async () => {
185
+ // Refresh fails
186
+ return new Response('Server Error', { status: 500 })
187
+ }) as unknown as typeof fetch
188
+
189
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
190
+
191
+ // No valid token — falls through to Bearer fallback
192
+ expect(result).toHaveProperty('headers')
193
+ expect(result).toHaveProperty('fetch')
194
+ })
195
+
196
+ it('does not refresh when token is not expired', async () => {
197
+ const getAuth = makeGetAuth({
198
+ type: 'oauth',
199
+ key: 'sk-ant-oat-still-valid',
200
+ refresh: 'sk-ant-ort-should-not-be-used',
201
+ expires: Date.now() + 3600_000, // still valid
202
+ })
203
+
204
+ const fetchCalls: string[] = []
205
+
206
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
207
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
208
+ fetchCalls.push(urlStr)
209
+
210
+ if (urlStr.includes('create_api_key')) {
211
+ return new Response(JSON.stringify({ raw_key: 'sk-ant-api03-from-valid' }), {
212
+ status: 200,
213
+ headers: { 'Content-Type': 'application/json' },
214
+ })
215
+ }
216
+
217
+ throw new Error(`Unexpected fetch: ${urlStr}`)
218
+ }) as unknown as typeof fetch
219
+
220
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
221
+
222
+ // Should NOT call refresh endpoint — only API key exchange
223
+ expect(fetchCalls).toHaveLength(1)
224
+ expect(fetchCalls[0]).toContain('create_api_key')
225
+ expect(result).toEqual({ apiKey: 'sk-ant-api03-from-valid' })
226
+ })
227
+ })
228
+
229
+
230
+ // =====================================================================
231
+ // Bearer fallback: per-request token refresh (the bug that was fixed)
232
+ // =====================================================================
233
+ describe('anthropic loader — Bearer fallback refresh', () => {
234
+ it('refreshes expired token inside fallback fetch (not just at loader setup)', async () => {
235
+ // This is the core bug scenario: token is expired, API key exchange fails,
236
+ // the Bearer fallback fetch MUST refresh the token per-request (like google/codex).
237
+ // Previously, the fallback just used getAuth().key directly — the old expired token.
238
+ const storedAuth: AuthCredential = {
239
+ type: 'oauth',
240
+ key: 'sk-ant-oat-expired-token',
241
+ refresh: 'sk-ant-ort-valid-refresh',
242
+ expires: Date.now() - 1000,
243
+ }
244
+ const getAuth = makeGetAuth(storedAuth)
245
+
246
+ let phase: 'loader' | 'fetch' = 'loader'
247
+ const fetchCalls: string[] = []
248
+
249
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
250
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
251
+ fetchCalls.push(urlStr)
252
+
253
+ if (urlStr.includes('/v1/oauth/token')) {
254
+ return new Response(
255
+ JSON.stringify({
256
+ access_token: 'sk-ant-oat-fresh-token',
257
+ refresh_token: 'sk-ant-ort-new-refresh',
258
+ expires_in: 3600,
259
+ }),
260
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
261
+ )
262
+ }
263
+
264
+ if (urlStr.includes('create_api_key')) {
265
+ return new Response('Forbidden', { status: 403 })
266
+ }
267
+
268
+ if (phase === 'fetch') {
269
+ // Actual API call — verify it uses the refreshed token
270
+ const authHeader = init?.headers instanceof Headers
271
+ ? init.headers.get('Authorization')
272
+ : undefined
273
+ expect(authHeader).toBe('Bearer sk-ant-oat-fresh-token')
274
+ return new Response('ok', { status: 200 })
275
+ }
276
+
277
+ throw new Error(`Unexpected fetch: ${urlStr}`)
278
+ }) as unknown as typeof fetch
279
+
280
+ // Step 1: loader runs — refresh + exchange fail → Bearer fallback returned
281
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
282
+ expect(result).toHaveProperty('fetch')
283
+ const customFetch = result.fetch as typeof globalThis.fetch
284
+
285
+ // Step 2: simulate an API call through the fallback fetch
286
+ phase = 'fetch'
287
+ fetchCalls.length = 0
288
+ await customFetch('https://api.anthropic.com/v1/messages', {})
289
+
290
+ // The fallback fetch should have refreshed the token before the API call
291
+ expect(fetchCalls[0]).toContain('/v1/oauth/token')
292
+ })
293
+
294
+ it('uses non-expired token as-is in fallback fetch (no unnecessary refresh)', async () => {
295
+ // exchange fails → Bearer fallback, but token is still valid → no refresh in fetch
296
+ const storedAuth: AuthCredential = {
297
+ type: 'oauth',
298
+ key: 'sk-ant-oat-still-valid',
299
+ expires: Date.now() + 3600_000,
300
+ }
301
+ const getAuth = makeGetAuth(storedAuth)
302
+
303
+ let phase: 'loader' | 'fetch' = 'loader'
304
+ const fetchCalls: string[] = []
305
+
306
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
307
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
308
+ fetchCalls.push(urlStr)
309
+
310
+ if (urlStr.includes('create_api_key')) {
311
+ return new Response('Forbidden', { status: 403 })
312
+ }
313
+
314
+ if (phase === 'fetch') {
315
+ const authHeader = init?.headers instanceof Headers
316
+ ? init.headers.get('Authorization')
317
+ : undefined
318
+ expect(authHeader).toBe('Bearer sk-ant-oat-still-valid')
319
+ return new Response('ok', { status: 200 })
320
+ }
321
+
322
+ throw new Error(`Unexpected fetch: ${urlStr}`)
323
+ }) as unknown as typeof fetch
324
+
325
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
326
+ expect(result).toHaveProperty('fetch')
327
+ const customFetch = result.fetch as typeof globalThis.fetch
328
+
329
+ phase = 'fetch'
330
+ fetchCalls.length = 0
331
+ await customFetch('https://api.anthropic.com/v1/messages', {})
332
+
333
+ // No refresh call — only the actual API call
334
+ expect(fetchCalls.every((u) => !u.includes('/v1/oauth/token'))).toBe(true)
335
+ })
336
+ })
337
+ // =====================================================================
338
+ // Token type detection helpers
339
+ // =====================================================================
340
+ describe('anthropic loader — token type detection', () => {
341
+ it('recognizes sk-ant-oat-* as OAuth token (not API key)', async () => {
342
+ const getAuth = makeGetAuth({
343
+ type: 'oauth',
344
+ key: 'sk-ant-oat-some-oauth-token',
345
+ expires: Date.now() + 3600_000,
346
+ })
347
+
348
+ // If it were treated as API key, it would return { apiKey: ... } without
349
+ // calling create_api_key. Since it's an OAuth token, it should call the
350
+ // exchange endpoint.
351
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
352
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
353
+ if (urlStr.includes('create_api_key')) {
354
+ return new Response(JSON.stringify({ raw_key: 'sk-ant-api03-exchanged' }), {
355
+ status: 200,
356
+ headers: { 'Content-Type': 'application/json' },
357
+ })
358
+ }
359
+ throw new Error(`Unexpected fetch: ${urlStr}`)
360
+ }) as unknown as typeof fetch
361
+
362
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
363
+ expect(result).toEqual({ apiKey: 'sk-ant-api03-exchanged' })
364
+ })
365
+
366
+ it('recognizes sk-ant-api03-* as direct API key (skips exchange)', async () => {
367
+ const getAuth = makeGetAuth({
368
+ type: 'oauth',
369
+ key: 'sk-ant-api03-direct-key',
370
+ expires: Date.now() + 3600_000,
371
+ })
372
+
373
+ // fetch should never be called since the token is recognized as an API key
374
+ globalThis.fetch = mock(async () => {
375
+ throw new Error('fetch should not be called for API key tokens')
376
+ }) as unknown as typeof fetch
377
+
378
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, async () => {})
379
+ expect(result).toEqual({ apiKey: 'sk-ant-api03-direct-key' })
380
+ })
381
+ })
382
+
383
+
384
+ // =====================================================================
385
+ // setAuth persistence: verify refreshed tokens are written back
386
+ // =====================================================================
387
+ describe('anthropic loader — setAuth persistence', () => {
388
+ it('calls setAuth with refreshed credential when token is expired (loader path)', async () => {
389
+ const getAuth = makeGetAuth({
390
+ type: 'oauth',
391
+ key: 'sk-ant-oat-expired',
392
+ refresh: 'sk-ant-ort-refresh',
393
+ expires: Date.now() - 1000,
394
+ })
395
+
396
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
397
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
398
+ if (urlStr.includes('/v1/oauth/token')) {
399
+ return new Response(
400
+ JSON.stringify({
401
+ access_token: 'sk-ant-oat-new-access',
402
+ refresh_token: 'sk-ant-ort-new-refresh',
403
+ expires_in: 7200,
404
+ }),
405
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
406
+ )
407
+ }
408
+ if (urlStr.includes('create_api_key')) {
409
+ return new Response(JSON.stringify({ raw_key: 'sk-ant-api03-exchanged' }), {
410
+ status: 200,
411
+ headers: { 'Content-Type': 'application/json' },
412
+ })
413
+ }
414
+ throw new Error(`Unexpected fetch: ${urlStr}`)
415
+ }) as unknown as typeof fetch
416
+
417
+ const persisted: AuthCredential[] = []
418
+ const setAuth = async (cred: AuthCredential) => { persisted.push(cred) }
419
+
420
+ await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, setAuth)
421
+
422
+ expect(persisted).toHaveLength(1)
423
+ expect(persisted[0].key).toBe('sk-ant-oat-new-access')
424
+ expect(persisted[0].refresh).toBe('sk-ant-ort-new-refresh')
425
+ expect(persisted[0].type).toBe('oauth')
426
+ expect(typeof persisted[0].expires).toBe('number')
427
+ })
428
+
429
+ it('calls setAuth in Bearer fallback fetch when token is refreshed', async () => {
430
+ const storedAuth: AuthCredential = {
431
+ type: 'oauth',
432
+ key: 'sk-ant-oat-expired-bearer',
433
+ refresh: 'sk-ant-ort-refresh-bearer',
434
+ expires: Date.now() - 1000,
435
+ }
436
+ const getAuth = makeGetAuth(storedAuth)
437
+
438
+ let phase: 'loader' | 'fetch' = 'loader'
439
+
440
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
441
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
442
+ if (urlStr.includes('/v1/oauth/token')) {
443
+ return new Response(
444
+ JSON.stringify({
445
+ access_token: 'sk-ant-oat-bearer-refreshed',
446
+ refresh_token: 'sk-ant-ort-bearer-new',
447
+ expires_in: 3600,
448
+ }),
449
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
450
+ )
451
+ }
452
+ if (urlStr.includes('create_api_key')) {
453
+ return new Response('Forbidden', { status: 403 })
454
+ }
455
+ if (phase === 'fetch') {
456
+ return new Response('ok', { status: 200 })
457
+ }
458
+ throw new Error(`Unexpected fetch: ${urlStr}`)
459
+ }) as unknown as typeof fetch
460
+
461
+ const persisted: AuthCredential[] = []
462
+ const setAuth = async (cred: AuthCredential) => { persisted.push(cred) }
463
+
464
+ // Loader: refresh + exchange fail → Bearer fallback
465
+ const result = await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, setAuth)
466
+ const loaderPersistCount = persisted.length
467
+ expect(loaderPersistCount).toBe(1) // refresh during loader setup
468
+
469
+ // Fetch: triggers another refresh (getAuth returns expired token again)
470
+ phase = 'fetch'
471
+ const customFetch = result.fetch as typeof globalThis.fetch
472
+ await customFetch('https://api.anthropic.com/v1/messages', {})
473
+
474
+ expect(persisted.length).toBeGreaterThan(loaderPersistCount)
475
+ expect(persisted[persisted.length - 1].key).toBe('sk-ant-oat-bearer-refreshed')
476
+ })
477
+
478
+ it('does not call setAuth when token is not expired', async () => {
479
+ const getAuth = makeGetAuth({
480
+ type: 'oauth',
481
+ key: 'sk-ant-oat-still-good',
482
+ refresh: 'sk-ant-ort-not-needed',
483
+ expires: Date.now() + 3600_000,
484
+ })
485
+
486
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
487
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url
488
+ if (urlStr.includes('create_api_key')) {
489
+ return new Response(JSON.stringify({ raw_key: 'sk-ant-api03-from-valid' }), {
490
+ status: 200,
491
+ headers: { 'Content-Type': 'application/json' },
492
+ })
493
+ }
494
+ throw new Error(`Unexpected fetch: ${urlStr}`)
495
+ }) as unknown as typeof fetch
496
+
497
+ const persisted: AuthCredential[] = []
498
+ const setAuth = async (cred: AuthCredential) => { persisted.push(cred) }
499
+
500
+ await anthropicPlugin.loader(getAuth, { id: 'anthropic' }, setAuth)
501
+
502
+ // No refresh happened, so setAuth should not be called
503
+ expect(persisted).toHaveLength(0)
504
+ })
505
+ })