@ex-machina/opencode-anthropic-auth 1.1.1 → 1.2.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.
@@ -8,3 +8,7 @@ export declare const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
8
8
  export declare const OAUTH_SCOPES: string[];
9
9
  export declare const TOOL_PREFIX = "mcp_";
10
10
  export declare const REQUIRED_BETAS: string[];
11
+ export declare const OPENCODE_IDENTITY = "You are OpenCode, the best coding agent on the planet.";
12
+ export declare const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
13
+ export declare const USER_AGENT = "claude-cli/2.1.2 (external, cli)";
14
+ export declare const PRESERVED_TAIL_MARKERS: string[];
package/dist/constants.js CHANGED
@@ -18,3 +18,11 @@ export const REQUIRED_BETAS = [
18
18
  'oauth-2025-04-20',
19
19
  'interleaved-thinking-2025-05-14',
20
20
  ];
21
+ export const OPENCODE_IDENTITY = 'You are OpenCode, the best coding agent on the planet.';
22
+ export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
23
+ export const USER_AGENT = 'claude-cli/2.1.2 (external, cli)';
24
+ export const PRESERVED_TAIL_MARKERS = [
25
+ '\nInstructions from: ',
26
+ '\nInstructions from command: ',
27
+ '# Code References',
28
+ ];
package/dist/index.js CHANGED
@@ -1,16 +1,8 @@
1
1
  import { authorize, exchange } from './auth';
2
2
  import { CLIENT_ID, TOKEN_URL } from './constants';
3
- import { createStrippedStream, isInsecure, mergeHeaders, prefixToolNames, rewriteUrl, setOAuthHeaders, } from './transform';
3
+ import { createStrippedStream, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from './transform';
4
4
  export const AnthropicAuthPlugin = async ({ client }) => {
5
5
  return {
6
- 'experimental.chat.system.transform': (input, output) => {
7
- const prefix = "You are Claude Code, Anthropic's official CLI for Claude.";
8
- if (input.model?.providerID === 'anthropic') {
9
- output.system.unshift(prefix);
10
- if (output.system[1])
11
- output.system[1] = `${prefix}\n\n${output.system[1]}`;
12
- }
13
- },
14
6
  auth: {
15
7
  provider: 'anthropic',
16
8
  async loader(getAuth, provider) {
@@ -65,7 +57,8 @@ export const AnthropicAuthPlugin = async ({ client }) => {
65
57
  await response.body?.cancel();
66
58
  continue;
67
59
  }
68
- throw new Error(`Token refresh failed: ${response.status}`);
60
+ const body = await response.text().catch(() => '');
61
+ throw new Error(`Token refresh failed: ${response.status} — ${body}`);
69
62
  }
70
63
  const json = (await response.json());
71
64
  // biome-ignore lint/suspicious/noExplicitAny: SDK types don't expose auth.set
@@ -110,7 +103,7 @@ export const AnthropicAuthPlugin = async ({ client }) => {
110
103
  setOAuthHeaders(requestHeaders, auth.access);
111
104
  let body = init?.body;
112
105
  if (body && typeof body === 'string') {
113
- body = prefixToolNames(body);
106
+ body = rewriteRequestBody(body);
114
107
  }
115
108
  const rewritten = rewriteUrl(input);
116
109
  const response = await fetch(rewritten.input, {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test';
2
+ import { authorize, exchange } from '../auth';
3
+ import { CLIENT_ID, CODE_CALLBACK_URL, OAUTH_SCOPES } from '../constants';
4
+ afterEach(() => {
5
+ mock.restore();
6
+ });
7
+ describe('authorize', () => {
8
+ test('returns the hosted callback URL for max mode', async () => {
9
+ const result = await authorize('max');
10
+ expect(result.url).toBeString();
11
+ expect(result.redirectUri).toBe(CODE_CALLBACK_URL);
12
+ expect(result.verifier).toBeString();
13
+ const url = new URL(result.url);
14
+ expect(url.origin).toBe('https://claude.ai');
15
+ expect(url.pathname).toBe('/oauth/authorize');
16
+ expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
17
+ });
18
+ test('returns the hosted callback URL for console mode', async () => {
19
+ const result = await authorize('console');
20
+ const url = new URL(result.url);
21
+ expect(url.origin).toBe('https://platform.claude.com');
22
+ expect(url.pathname).toBe('/oauth/authorize');
23
+ expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
24
+ });
25
+ test('sets required OAuth query params', async () => {
26
+ const result = await authorize('max');
27
+ const url = new URL(result.url);
28
+ expect(url.searchParams.get('code')).toBe('true');
29
+ expect(url.searchParams.get('client_id')).toBe(CLIENT_ID);
30
+ expect(url.searchParams.get('response_type')).toBe('code');
31
+ expect(url.searchParams.get('redirect_uri')).toBe(CODE_CALLBACK_URL);
32
+ expect(url.searchParams.get('scope')).toBe(OAUTH_SCOPES.join(' '));
33
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
34
+ expect(url.searchParams.get('state')).toBe(result.state);
35
+ });
36
+ test('does not use localhost', async () => {
37
+ const result = await authorize('max');
38
+ expect(result.redirectUri).not.toContain('localhost');
39
+ expect(result.url).not.toContain('localhost');
40
+ });
41
+ });
42
+ describe('exchange', () => {
43
+ test('accepts code#state format', async () => {
44
+ let capturedBody;
45
+ spyOn(globalThis, 'fetch').mockImplementation(((_input, init) => {
46
+ capturedBody = init?.body;
47
+ return Promise.resolve(new Response(JSON.stringify({
48
+ refresh_token: 'r',
49
+ access_token: 'a',
50
+ expires_in: 3600,
51
+ }), { status: 200 }));
52
+ }));
53
+ const result = await exchange('mycode#mystate', 'myverifier', CODE_CALLBACK_URL, 'mystate');
54
+ expect(result.type).toBe('success');
55
+ const body = JSON.parse(capturedBody);
56
+ expect(body.code).toBe('mycode');
57
+ expect(body.state).toBe('mystate');
58
+ expect(body.redirect_uri).toBe(CODE_CALLBACK_URL);
59
+ });
60
+ test('accepts a full callback URL', async () => {
61
+ let capturedBody;
62
+ spyOn(globalThis, 'fetch').mockImplementation(((_input, init) => {
63
+ capturedBody = init?.body;
64
+ return Promise.resolve(new Response(JSON.stringify({
65
+ refresh_token: 'r',
66
+ access_token: 'a',
67
+ expires_in: 3600,
68
+ }), { status: 200 }));
69
+ }));
70
+ await exchange('https://platform.claude.com/oauth/code/callback?code=mycode&state=mystate', 'myverifier', CODE_CALLBACK_URL, 'mystate');
71
+ const body = JSON.parse(capturedBody);
72
+ expect(body.code).toBe('mycode');
73
+ expect(body.state).toBe('mystate');
74
+ });
75
+ test('returns failed on invalid callback input', async () => {
76
+ const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((() => Promise.resolve(new Response(null))));
77
+ const result = await exchange('not-a-callback', 'verifier', CODE_CALLBACK_URL);
78
+ expect(result.type).toBe('failed');
79
+ expect(fetchSpy).not.toHaveBeenCalled();
80
+ });
81
+ test('returns failed on state mismatch', async () => {
82
+ const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation((() => Promise.resolve(new Response(null))));
83
+ const result = await exchange('code#wrong', 'verifier', CODE_CALLBACK_URL, 'expected');
84
+ expect(result.type).toBe('failed');
85
+ expect(fetchSpy).not.toHaveBeenCalled();
86
+ });
87
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,481 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { AnthropicAuthPlugin } from '../index';
3
+ // Minimal mock of the OpenCode plugin client
4
+ function createMockClient() {
5
+ return {
6
+ auth: {
7
+ set: mock(() => Promise.resolve()),
8
+ },
9
+ };
10
+ }
11
+ async function getPlugin(client) {
12
+ return AnthropicAuthPlugin({
13
+ // @ts-expect-error: minimal mock for testing
14
+ client: client ?? createMockClient(),
15
+ });
16
+ }
17
+ describe('AnthropicAuthPlugin', () => {
18
+ test('returns an object with auth properties', async () => {
19
+ const plugin = await getPlugin();
20
+ expect(plugin.auth).toBeDefined();
21
+ expect(plugin.auth.provider).toBe('anthropic');
22
+ expect(plugin.auth.loader).toBeFunction();
23
+ expect(plugin.auth.methods).toBeArray();
24
+ });
25
+ });
26
+ describe('auth.methods', () => {
27
+ test('has three auth methods', async () => {
28
+ const plugin = await getPlugin();
29
+ expect(plugin.auth.methods).toHaveLength(3);
30
+ });
31
+ test('first method is Claude Pro/Max OAuth with code flow', async () => {
32
+ const plugin = await getPlugin();
33
+ const method = plugin.auth.methods[0];
34
+ expect(method.label).toBe('Claude Pro/Max');
35
+ expect(method.type).toBe('oauth');
36
+ expect(method.authorize).toBeFunction();
37
+ });
38
+ test('second method is Create an API Key OAuth with code flow', async () => {
39
+ const plugin = await getPlugin();
40
+ const method = plugin.auth.methods[1];
41
+ expect(method.label).toBe('Create an API Key');
42
+ expect(method.type).toBe('oauth');
43
+ expect(method.authorize).toBeFunction();
44
+ });
45
+ test('third method is manual API key', async () => {
46
+ const plugin = await getPlugin();
47
+ const method = plugin.auth.methods[2];
48
+ expect(method.label).toBe('Manually enter API Key');
49
+ expect(method.type).toBe('api');
50
+ expect(method.provider).toBe('anthropic');
51
+ });
52
+ });
53
+ describe('auth.loader', () => {
54
+ const originalFetch = globalThis.fetch;
55
+ const originalSetTimeout = globalThis.setTimeout;
56
+ beforeEach(() => {
57
+ globalThis.fetch = originalFetch;
58
+ globalThis.setTimeout = originalSetTimeout;
59
+ });
60
+ afterEach(() => {
61
+ globalThis.fetch = originalFetch;
62
+ globalThis.setTimeout = originalSetTimeout;
63
+ });
64
+ test('returns empty object for non-oauth auth', async () => {
65
+ const plugin = await getPlugin();
66
+ const result = await plugin.auth.loader(() => Promise.resolve({ type: 'api' }), { models: {} });
67
+ expect(result).toEqual({});
68
+ });
69
+ test('zeros out model costs for oauth auth', async () => {
70
+ const plugin = await getPlugin();
71
+ const models = {
72
+ 'claude-3': {
73
+ cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
74
+ },
75
+ };
76
+ await plugin.auth.loader(() => Promise.resolve({
77
+ type: 'oauth',
78
+ access: 'token',
79
+ refresh: 'refresh',
80
+ expires: Date.now() + 100000,
81
+ }), { models });
82
+ expect(models['claude-3'].cost).toEqual({
83
+ input: 0,
84
+ output: 0,
85
+ cache: { read: 0, write: 0 },
86
+ });
87
+ });
88
+ test('returns fetch wrapper for oauth auth', async () => {
89
+ const plugin = await getPlugin();
90
+ const result = await plugin.auth.loader(() => Promise.resolve({
91
+ type: 'oauth',
92
+ access: 'token',
93
+ refresh: 'refresh',
94
+ expires: Date.now() + 100000,
95
+ }), { models: {} });
96
+ expect(result.apiKey).toBe('');
97
+ expect(result.fetch).toBeFunction();
98
+ });
99
+ test('fetch wrapper sets OAuth headers and prefixes tools', async () => {
100
+ let capturedHeaders;
101
+ let capturedBody;
102
+ globalThis.fetch = mock((input, init) => {
103
+ capturedHeaders = init?.headers;
104
+ capturedBody = init?.body;
105
+ return Promise.resolve(new Response(null, { status: 200 }));
106
+ });
107
+ const plugin = await getPlugin();
108
+ const result = await plugin.auth.loader(() => Promise.resolve({
109
+ type: 'oauth',
110
+ access: 'my-access-token',
111
+ refresh: 'refresh',
112
+ expires: Date.now() + 100000,
113
+ }), { models: {} });
114
+ const body = JSON.stringify({
115
+ tools: [{ name: 'bash', type: 'function' }],
116
+ messages: [{ role: 'user', content: 'hello world test message' }],
117
+ system: 'You are a helpful assistant.',
118
+ });
119
+ await result.fetch('https://api.anthropic.com/v1/messages', {
120
+ method: 'POST',
121
+ body,
122
+ });
123
+ expect(capturedHeaders).toBeDefined();
124
+ expect(capturedHeaders.get('authorization')).toBe('Bearer my-access-token');
125
+ expect(capturedHeaders.get('x-api-key')).toBeNull();
126
+ expect(capturedHeaders.get('anthropic-beta')).toContain('oauth-2025-04-20');
127
+ const parsedBody = JSON.parse(capturedBody);
128
+ // Tool name should be prefixed
129
+ expect(parsedBody.tools[0].name).toBe('mcp_bash');
130
+ // System prompt should be rewritten with Claude Code identity
131
+ expect(parsedBody.system[0].text).toContain("You are Claude Code, Anthropic's official CLI for Claude.");
132
+ });
133
+ test('fetch wrapper refreshes expired token', async () => {
134
+ const fetchCalls = [];
135
+ globalThis.fetch = mock((input, init) => {
136
+ const url = typeof input === 'string'
137
+ ? input
138
+ : input instanceof URL
139
+ ? input.toString()
140
+ : input.url;
141
+ fetchCalls.push({ url, body: init?.body });
142
+ if (url.includes('/v1/oauth/token')) {
143
+ return Promise.resolve(new Response(JSON.stringify({
144
+ refresh_token: 'new-refresh',
145
+ access_token: 'new-access',
146
+ expires_in: 3600,
147
+ }), { status: 200 }));
148
+ }
149
+ return Promise.resolve(new Response(null, { status: 200 }));
150
+ });
151
+ const mockClient = createMockClient();
152
+ const plugin = await getPlugin(mockClient);
153
+ const result = await plugin.auth.loader(() => Promise.resolve({
154
+ type: 'oauth',
155
+ access: 'expired-token',
156
+ refresh: 'old-refresh',
157
+ expires: Date.now() - 1000, // expired
158
+ }), { models: {} });
159
+ await result.fetch('https://api.anthropic.com/v1/messages', {
160
+ method: 'POST',
161
+ body: '{}',
162
+ });
163
+ // Should have called token endpoint first
164
+ const tokenCall = fetchCalls.find((c) => c.url.includes('/v1/oauth/token'));
165
+ expect(tokenCall).toBeDefined();
166
+ const tokenBody = JSON.parse(tokenCall.body);
167
+ expect(tokenBody.grant_type).toBe('refresh_token');
168
+ expect(tokenBody.refresh_token).toBe('old-refresh');
169
+ // Should have called client.auth.set with new tokens
170
+ expect(mockClient.auth.set).toHaveBeenCalled();
171
+ });
172
+ test('fetch wrapper retries transient token refresh failures', async () => {
173
+ let tokenRefreshCalls = 0;
174
+ const setTimeoutMock = mock((handler) => {
175
+ handler();
176
+ return 0;
177
+ });
178
+ // @ts-expect-error — mock override for testing
179
+ globalThis.setTimeout = setTimeoutMock;
180
+ globalThis.fetch = mock((input) => {
181
+ const url = typeof input === 'string'
182
+ ? input
183
+ : input instanceof URL
184
+ ? input.toString()
185
+ : input.url;
186
+ if (url.includes('/v1/oauth/token')) {
187
+ tokenRefreshCalls += 1;
188
+ if (tokenRefreshCalls === 1) {
189
+ return Promise.resolve(new Response('Temporary failure', { status: 500 }));
190
+ }
191
+ return Promise.resolve(new Response(JSON.stringify({
192
+ refresh_token: 'new-refresh',
193
+ access_token: 'new-access',
194
+ expires_in: 3600,
195
+ }), { status: 200 }));
196
+ }
197
+ return Promise.resolve(new Response(null, { status: 200 }));
198
+ });
199
+ const mockClient = createMockClient();
200
+ const plugin = await getPlugin(mockClient);
201
+ const result = await plugin.auth.loader(() => Promise.resolve({
202
+ type: 'oauth',
203
+ access: 'expired',
204
+ refresh: 'refresh',
205
+ expires: Date.now() - 1000,
206
+ }), { models: {} });
207
+ await result.fetch('https://api.anthropic.com/v1/messages', {
208
+ method: 'POST',
209
+ body: '{}',
210
+ });
211
+ expect(tokenRefreshCalls).toBe(2);
212
+ expect(setTimeoutMock).toHaveBeenCalledTimes(1);
213
+ expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), 500);
214
+ expect(mockClient.auth.set).toHaveBeenCalledTimes(1);
215
+ });
216
+ test('fetch wrapper does not retry non-transient token refresh failures', async () => {
217
+ let tokenRefreshCalls = 0;
218
+ globalThis.fetch = mock((input) => {
219
+ const url = typeof input === 'string'
220
+ ? input
221
+ : input instanceof URL
222
+ ? input.toString()
223
+ : input.url;
224
+ if (url.includes('/v1/oauth/token')) {
225
+ tokenRefreshCalls += 1;
226
+ return Promise.resolve(new Response('Forbidden', { status: 403 }));
227
+ }
228
+ return Promise.resolve(new Response(null, { status: 200 }));
229
+ });
230
+ const plugin = await getPlugin();
231
+ const result = await plugin.auth.loader(() => Promise.resolve({
232
+ type: 'oauth',
233
+ access: 'expired',
234
+ refresh: 'refresh',
235
+ expires: Date.now() - 1000,
236
+ }), { models: {} });
237
+ expect(result.fetch('https://api.anthropic.com/v1/messages', {
238
+ method: 'POST',
239
+ body: '{}',
240
+ })).rejects.toThrow('Token refresh failed: 403');
241
+ expect(tokenRefreshCalls).toBe(1);
242
+ });
243
+ test('fetch wrapper strips tool prefix from streaming response', async () => {
244
+ const encoder = new TextEncoder();
245
+ const responseStream = new ReadableStream({
246
+ start(controller) {
247
+ controller.enqueue(encoder.encode('data: {"content_block":{"type":"tool_use","name":"mcp_bash"}}\n\n'));
248
+ controller.close();
249
+ },
250
+ });
251
+ globalThis.fetch = mock(() => Promise.resolve(new Response(responseStream, { status: 200 })));
252
+ const plugin = await getPlugin();
253
+ const result = await plugin.auth.loader(() => Promise.resolve({
254
+ type: 'oauth',
255
+ access: 'token',
256
+ refresh: 'refresh',
257
+ expires: Date.now() + 100000,
258
+ }), { models: {} });
259
+ const response = await result.fetch('https://api.anthropic.com/v1/messages', {
260
+ method: 'POST',
261
+ body: '{}',
262
+ });
263
+ const text = await response.text();
264
+ expect(text).toContain('"name": "bash"');
265
+ expect(text).not.toContain('mcp_bash');
266
+ });
267
+ test('concurrent expired token refresh should deduplicate to a single token request', async () => {
268
+ let tokenRefreshCount = 0;
269
+ // @ts-expect-error — mock override for testing
270
+ globalThis.setTimeout = mock((handler) => {
271
+ handler();
272
+ return 0;
273
+ });
274
+ globalThis.fetch = mock((input, init) => {
275
+ const url = typeof input === 'string'
276
+ ? input
277
+ : input instanceof URL
278
+ ? input.toString()
279
+ : input.url;
280
+ if (url.includes('/v1/oauth/token')) {
281
+ tokenRefreshCount++;
282
+ return Promise.resolve(new Response(JSON.stringify({
283
+ refresh_token: 'new-refresh',
284
+ access_token: 'new-access',
285
+ expires_in: 3600,
286
+ }), { status: 200 }));
287
+ }
288
+ return Promise.resolve(new Response(null, { status: 200 }));
289
+ });
290
+ const mockClient = createMockClient();
291
+ const plugin = await getPlugin(mockClient);
292
+ const result = await plugin.auth.loader(() => Promise.resolve({
293
+ type: 'oauth',
294
+ access: 'expired-token',
295
+ refresh: 'old-refresh',
296
+ expires: Date.now() - 1000,
297
+ }), { models: {} });
298
+ // Fire 5 concurrent requests, all with expired token
299
+ await Promise.all([
300
+ result.fetch('https://api.anthropic.com/v1/messages', {
301
+ method: 'POST',
302
+ body: '{}',
303
+ }),
304
+ result.fetch('https://api.anthropic.com/v1/messages', {
305
+ method: 'POST',
306
+ body: '{}',
307
+ }),
308
+ result.fetch('https://api.anthropic.com/v1/messages', {
309
+ method: 'POST',
310
+ body: '{}',
311
+ }),
312
+ result.fetch('https://api.anthropic.com/v1/messages', {
313
+ method: 'POST',
314
+ body: '{}',
315
+ }),
316
+ result.fetch('https://api.anthropic.com/v1/messages', {
317
+ method: 'POST',
318
+ body: '{}',
319
+ }),
320
+ ]);
321
+ // With deduplication, only ONE refresh request should be made, not 5
322
+ expect(tokenRefreshCount).toBe(1);
323
+ });
324
+ test('concurrent refresh with token rotation should not cause cascading failures', async () => {
325
+ const usedRefreshTokens = new Set();
326
+ // @ts-expect-error — mock override for testing
327
+ globalThis.setTimeout = mock((handler) => {
328
+ handler();
329
+ return 0;
330
+ });
331
+ globalThis.fetch = mock((input, init) => {
332
+ const url = typeof input === 'string'
333
+ ? input
334
+ : input instanceof URL
335
+ ? input.toString()
336
+ : input.url;
337
+ if (url.includes('/v1/oauth/token')) {
338
+ const body = JSON.parse(init?.body);
339
+ const refreshToken = body.refresh_token;
340
+ // Simulate refresh token rotation: first use succeeds, subsequent uses
341
+ // return 401 because the old token has been invalidated
342
+ if (usedRefreshTokens.has(refreshToken)) {
343
+ return Promise.resolve(new Response(JSON.stringify({ error: 'invalid_grant' }), {
344
+ status: 401,
345
+ }));
346
+ }
347
+ usedRefreshTokens.add(refreshToken);
348
+ return Promise.resolve(new Response(JSON.stringify({
349
+ refresh_token: 'rotated-refresh',
350
+ access_token: 'new-access',
351
+ expires_in: 3600,
352
+ }), { status: 200 }));
353
+ }
354
+ return Promise.resolve(new Response(null, { status: 200 }));
355
+ });
356
+ const mockClient = createMockClient();
357
+ const plugin = await getPlugin(mockClient);
358
+ const result = await plugin.auth.loader(() => Promise.resolve({
359
+ type: 'oauth',
360
+ access: 'expired-token',
361
+ refresh: 'old-refresh',
362
+ expires: Date.now() - 1000,
363
+ }), { models: {} });
364
+ // Fire 5 concurrent requests — ALL should succeed because only one refresh
365
+ // fires and the rest reuse its result
366
+ const outcomes = await Promise.all([
367
+ result
368
+ .fetch('https://api.anthropic.com/v1/messages', {
369
+ method: 'POST',
370
+ body: '{}',
371
+ })
372
+ .then(() => 'ok', () => 'fail'),
373
+ result
374
+ .fetch('https://api.anthropic.com/v1/messages', {
375
+ method: 'POST',
376
+ body: '{}',
377
+ })
378
+ .then(() => 'ok', () => 'fail'),
379
+ result
380
+ .fetch('https://api.anthropic.com/v1/messages', {
381
+ method: 'POST',
382
+ body: '{}',
383
+ })
384
+ .then(() => 'ok', () => 'fail'),
385
+ result
386
+ .fetch('https://api.anthropic.com/v1/messages', {
387
+ method: 'POST',
388
+ body: '{}',
389
+ })
390
+ .then(() => 'ok', () => 'fail'),
391
+ result
392
+ .fetch('https://api.anthropic.com/v1/messages', {
393
+ method: 'POST',
394
+ body: '{}',
395
+ })
396
+ .then(() => 'ok', () => 'fail'),
397
+ ]);
398
+ // With deduplication, all callers share the single successful refresh.
399
+ // Without it, 4 out of 5 get 401 from the rotated-away token → cascading failures.
400
+ expect(outcomes).toEqual(['ok', 'ok', 'ok', 'ok', 'ok']);
401
+ });
402
+ test('concurrent refresh should persist tokens exactly once', async () => {
403
+ // @ts-expect-error — mock override for testing
404
+ globalThis.setTimeout = mock((handler) => {
405
+ handler();
406
+ return 0;
407
+ });
408
+ globalThis.fetch = mock((input) => {
409
+ const url = typeof input === 'string'
410
+ ? input
411
+ : input instanceof URL
412
+ ? input.toString()
413
+ : input.url;
414
+ if (url.includes('/v1/oauth/token')) {
415
+ return Promise.resolve(new Response(JSON.stringify({
416
+ refresh_token: 'new-refresh',
417
+ access_token: 'new-access',
418
+ expires_in: 3600,
419
+ }), { status: 200 }));
420
+ }
421
+ return Promise.resolve(new Response(null, { status: 200 }));
422
+ });
423
+ const mockClient = createMockClient();
424
+ const plugin = await getPlugin(mockClient);
425
+ const result = await plugin.auth.loader(() => Promise.resolve({
426
+ type: 'oauth',
427
+ access: 'expired-token',
428
+ refresh: 'old-refresh',
429
+ expires: Date.now() - 1000,
430
+ }), { models: {} });
431
+ await Promise.all([
432
+ result.fetch('https://api.anthropic.com/v1/messages', {
433
+ method: 'POST',
434
+ body: '{}',
435
+ }),
436
+ result.fetch('https://api.anthropic.com/v1/messages', {
437
+ method: 'POST',
438
+ body: '{}',
439
+ }),
440
+ result.fetch('https://api.anthropic.com/v1/messages', {
441
+ method: 'POST',
442
+ body: '{}',
443
+ }),
444
+ result.fetch('https://api.anthropic.com/v1/messages', {
445
+ method: 'POST',
446
+ body: '{}',
447
+ }),
448
+ result.fetch('https://api.anthropic.com/v1/messages', {
449
+ method: 'POST',
450
+ body: '{}',
451
+ }),
452
+ ]);
453
+ // With deduplication, client.auth.set should be called exactly once.
454
+ // Without it, each concurrent refresh calls auth.set independently → 5 calls.
455
+ expect(mockClient.auth.set).toHaveBeenCalledTimes(1);
456
+ });
457
+ test('fetch wrapper adds beta=true to /v1/messages URL', async () => {
458
+ let capturedUrl;
459
+ globalThis.fetch = mock((input) => {
460
+ capturedUrl =
461
+ typeof input === 'string'
462
+ ? input
463
+ : input instanceof URL
464
+ ? input.toString()
465
+ : input.url;
466
+ return Promise.resolve(new Response(null, { status: 200 }));
467
+ });
468
+ const plugin = await getPlugin();
469
+ const result = await plugin.auth.loader(() => Promise.resolve({
470
+ type: 'oauth',
471
+ access: 'token',
472
+ refresh: 'refresh',
473
+ expires: Date.now() + 100000,
474
+ }), { models: {} });
475
+ await result.fetch('https://api.anthropic.com/v1/messages', {
476
+ method: 'POST',
477
+ body: '{}',
478
+ });
479
+ expect(capturedUrl).toContain('beta=true');
480
+ });
481
+ });
@@ -0,0 +1 @@
1
+ export {};