opencode-anthropic-fix 0.0.14
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.
- package/.github/workflows/publish.yml +31 -0
- package/.husky/pre-commit +2 -0
- package/.husky/pre-push +3 -0
- package/.planning/CLI-ENHANCEMENTS.md +221 -0
- package/.planning/codebase/ARCHITECTURE.md +201 -0
- package/.planning/codebase/CONCERNS.md +219 -0
- package/.planning/codebase/CONVENTIONS.md +335 -0
- package/.planning/codebase/INTEGRATIONS.md +215 -0
- package/.planning/codebase/STACK.md +160 -0
- package/.planning/codebase/STRUCTURE.md +244 -0
- package/.planning/codebase/TESTING.md +456 -0
- package/.prettierignore +3 -0
- package/.prettierrc +10 -0
- package/CONTRIBUTING.md +525 -0
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/agents.md +22 -0
- package/bun.lock +43 -0
- package/cli.mjs +1773 -0
- package/cli.test.mjs +1797 -0
- package/docs/mimese-http-header-system-prompt.md +326 -0
- package/docs/plans/afcw-upstream-watcher.json +495 -0
- package/eslint.config.mjs +69 -0
- package/index.mjs +2209 -0
- package/index.test.mjs +2690 -0
- package/lib/accounts.mjs +670 -0
- package/lib/accounts.test.mjs +782 -0
- package/lib/backoff.mjs +217 -0
- package/lib/backoff.test.mjs +296 -0
- package/lib/config.mjs +366 -0
- package/lib/config.test.mjs +307 -0
- package/lib/oauth.mjs +103 -0
- package/lib/rotation.mjs +287 -0
- package/lib/rotation.test.mjs +346 -0
- package/lib/storage.mjs +265 -0
- package/lib/storage.test.mjs +470 -0
- package/nul +1 -0
- package/package.json +40 -0
- package/script/publish.ts +29 -0
- package/scripts/build.mjs +34 -0
- package/scripts/install.mjs +319 -0
package/index.test.mjs
ADDED
|
@@ -0,0 +1,2690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the plugin lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* These test the wiring in index.mjs — the ordering of authorize → callback → loader,
|
|
5
|
+
* the accountManager initialization, and the fetch interceptor retry loop.
|
|
6
|
+
*
|
|
7
|
+
* We mock external dependencies (fetch, PKCE, readline, storage fs) but exercise
|
|
8
|
+
* the real plugin code paths.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Mocks — must be set up before importing the module under test
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
// Mock readline (used by promptAccountMenu / promptManageAccounts)
|
|
17
|
+
vi.mock("node:readline/promises", () => ({
|
|
18
|
+
createInterface: vi.fn(() => ({
|
|
19
|
+
question: vi.fn().mockResolvedValue("a"),
|
|
20
|
+
close: vi.fn(),
|
|
21
|
+
})),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Mock storage — we control what's on "disk"
|
|
25
|
+
vi.mock("./lib/storage.mjs", async (importOriginal) => {
|
|
26
|
+
const original = await importOriginal();
|
|
27
|
+
return {
|
|
28
|
+
...original,
|
|
29
|
+
loadAccounts: vi.fn().mockResolvedValue(null),
|
|
30
|
+
saveAccounts: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
clearAccounts: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Mock config — always return defaults
|
|
36
|
+
vi.mock("./lib/config.mjs", async (importOriginal) => {
|
|
37
|
+
const original = await importOriginal();
|
|
38
|
+
return {
|
|
39
|
+
...original,
|
|
40
|
+
loadConfig: vi.fn(() => ({
|
|
41
|
+
...original.DEFAULT_CONFIG,
|
|
42
|
+
signature_emulation: {
|
|
43
|
+
...original.DEFAULT_CONFIG.signature_emulation,
|
|
44
|
+
fetch_claude_code_version_on_startup: false,
|
|
45
|
+
},
|
|
46
|
+
override_model_limits: {
|
|
47
|
+
...original.DEFAULT_CONFIG.override_model_limits,
|
|
48
|
+
},
|
|
49
|
+
})),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Mock global fetch for OAuth token exchange and API requests
|
|
54
|
+
const mockFetch = vi.fn();
|
|
55
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
56
|
+
|
|
57
|
+
import { AnthropicAuthPlugin } from "./index.mjs";
|
|
58
|
+
import { saveAccounts, loadAccounts, clearAccounts } from "./lib/storage.mjs";
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
delete process.env.DISABLE_INTERLEAVED_THINKING;
|
|
62
|
+
delete process.env.USE_API_CONTEXT_MANAGEMENT;
|
|
63
|
+
delete process.env.TENGU_MARBLE_ANVIL;
|
|
64
|
+
delete process.env.TENGU_TOOL_PEAR;
|
|
65
|
+
delete process.env.TENGU_SCARF_COFFEE;
|
|
66
|
+
delete process.env.ANTHROPIC_BETAS;
|
|
67
|
+
delete process.env.ANTHROPIC_CUSTOM_HEADERS;
|
|
68
|
+
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
|
69
|
+
delete process.env.CLAUDE_CODE_ENTRYPOINT;
|
|
70
|
+
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
|
|
71
|
+
delete process.env.CLAUDE_AGENT_SDK_VERSION;
|
|
72
|
+
delete process.env.CLAUDE_AGENT_SDK_CLIENT_APP;
|
|
73
|
+
delete process.env.CLAUDE_CODE_CONTAINER_ID;
|
|
74
|
+
delete process.env.CLAUDE_CODE_REMOTE_SESSION_ID;
|
|
75
|
+
delete process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION;
|
|
76
|
+
delete process.env.OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT;
|
|
77
|
+
process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID = "test-signature-user";
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Helpers
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
function makeClient() {
|
|
85
|
+
return {
|
|
86
|
+
auth: {
|
|
87
|
+
set: vi.fn().mockResolvedValue(undefined),
|
|
88
|
+
},
|
|
89
|
+
session: {
|
|
90
|
+
prompt: vi.fn().mockResolvedValue(undefined),
|
|
91
|
+
},
|
|
92
|
+
tui: {
|
|
93
|
+
showToast: vi.fn().mockResolvedValue(undefined),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeProvider() {
|
|
99
|
+
return {
|
|
100
|
+
models: {
|
|
101
|
+
"claude-sonnet": {
|
|
102
|
+
id: "claude-sonnet",
|
|
103
|
+
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
|
|
104
|
+
limit: { context: 200_000, output: 8192 },
|
|
105
|
+
},
|
|
106
|
+
"claude-opus-4-6": {
|
|
107
|
+
id: "claude-opus-4-6",
|
|
108
|
+
cost: { input: 15, output: 75, cache: { read: 1.5, write: 18.75 } },
|
|
109
|
+
limit: { context: 200_000, output: 32_000 },
|
|
110
|
+
},
|
|
111
|
+
"claude-sonnet-4-1m": {
|
|
112
|
+
id: "claude-sonnet-4-1m",
|
|
113
|
+
cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } },
|
|
114
|
+
limit: { context: 200_000, output: 16_000 },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build a stored account object with sensible defaults.
|
|
122
|
+
* Override any field by passing partial overrides.
|
|
123
|
+
*/
|
|
124
|
+
function makeStoredAccount(overrides = {}) {
|
|
125
|
+
return {
|
|
126
|
+
refreshToken: "refresh-1",
|
|
127
|
+
addedAt: 1000,
|
|
128
|
+
lastUsed: 0,
|
|
129
|
+
enabled: true,
|
|
130
|
+
rateLimitResetTimes: {},
|
|
131
|
+
consecutiveFailures: 0,
|
|
132
|
+
lastFailureTime: null,
|
|
133
|
+
...overrides,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Build a stored accounts data structure with N accounts. */
|
|
138
|
+
function makeAccountsData(accountOverrides = [{}], extra = {}) {
|
|
139
|
+
return {
|
|
140
|
+
version: 1,
|
|
141
|
+
accounts: accountOverrides.map((o, i) =>
|
|
142
|
+
makeStoredAccount({ refreshToken: `refresh-${i + 1}`, addedAt: (i + 1) * 1000, ...o }),
|
|
143
|
+
),
|
|
144
|
+
activeIndex: 0,
|
|
145
|
+
...extra,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Bootstrap a plugin with loaded accounts and return the fetch interceptor.
|
|
151
|
+
* Accepts an array of account overrides (one per account) and optional auth overrides.
|
|
152
|
+
*/
|
|
153
|
+
async function setupFetchFn(client, accountOverrides = [{}], authOverrides = {}) {
|
|
154
|
+
const data = makeAccountsData(accountOverrides);
|
|
155
|
+
loadAccounts.mockResolvedValue(data);
|
|
156
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
157
|
+
|
|
158
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
159
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
160
|
+
type: "oauth",
|
|
161
|
+
refresh: data.accounts[0].refreshToken,
|
|
162
|
+
access: `access-1`,
|
|
163
|
+
expires: Date.now() + 3600_000,
|
|
164
|
+
...authOverrides,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
168
|
+
return result.fetch;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Shorthand for a successful token refresh mock response */
|
|
172
|
+
function mockTokenRefresh(token = "access-new", refresh = "refresh-new") {
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
json: async () => ({
|
|
176
|
+
access_token: token,
|
|
177
|
+
refresh_token: refresh,
|
|
178
|
+
expires_in: 3600,
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Plugin lifecycle: authorize → callback → loader ordering
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
describe("plugin lifecycle", () => {
|
|
188
|
+
let client;
|
|
189
|
+
|
|
190
|
+
beforeEach(() => {
|
|
191
|
+
vi.resetAllMocks();
|
|
192
|
+
client = makeClient();
|
|
193
|
+
loadAccounts.mockResolvedValue(null);
|
|
194
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("authorize callback creates accounts file on first login (accountManager starts null)", async () => {
|
|
198
|
+
// Mock the OAuth token exchange response
|
|
199
|
+
mockFetch.mockResolvedValueOnce({
|
|
200
|
+
ok: true,
|
|
201
|
+
json: async () => ({
|
|
202
|
+
access_token: "access-from-oauth",
|
|
203
|
+
refresh_token: "refresh-from-oauth",
|
|
204
|
+
expires_in: 3600,
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
209
|
+
const method = plugin.auth.methods[0]; // "Claude Pro/Max (multi-account)"
|
|
210
|
+
|
|
211
|
+
// Step 1: authorize() — returns URL + callback
|
|
212
|
+
const authResult = await method.authorize();
|
|
213
|
+
expect(authResult.url).toContain("claude.ai/oauth/authorize");
|
|
214
|
+
expect(authResult.method).toBe("code");
|
|
215
|
+
|
|
216
|
+
// Step 2: callback() — user pastes the code
|
|
217
|
+
// At this point, loader() has NOT been called yet, so accountManager is null.
|
|
218
|
+
const credentials = await authResult.callback("auth-code#state");
|
|
219
|
+
|
|
220
|
+
expect(credentials.type).toBe("success");
|
|
221
|
+
expect(credentials.refresh).toBe("refresh-from-oauth");
|
|
222
|
+
expect(credentials.access).toBe("access-from-oauth");
|
|
223
|
+
|
|
224
|
+
// KEY ASSERTION: saveAccounts must have been called — the accounts file is created
|
|
225
|
+
expect(saveAccounts).toHaveBeenCalledTimes(1);
|
|
226
|
+
expect(saveAccounts).toHaveBeenCalledWith(
|
|
227
|
+
expect.objectContaining({
|
|
228
|
+
version: 1,
|
|
229
|
+
accounts: expect.arrayContaining([
|
|
230
|
+
expect.objectContaining({
|
|
231
|
+
refreshToken: "refresh-from-oauth",
|
|
232
|
+
enabled: true,
|
|
233
|
+
}),
|
|
234
|
+
]),
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("loader bootstraps from auth.json and saves accounts file immediately", async () => {
|
|
240
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
241
|
+
const provider = makeProvider();
|
|
242
|
+
|
|
243
|
+
// Simulate OpenCode calling loader() after auth is stored
|
|
244
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
245
|
+
type: "oauth",
|
|
246
|
+
refresh: "existing-refresh",
|
|
247
|
+
access: "existing-access",
|
|
248
|
+
expires: Date.now() + 3600_000,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const result = await plugin.auth.loader(getAuth, provider);
|
|
252
|
+
|
|
253
|
+
// Should have saved the bootstrapped account immediately
|
|
254
|
+
expect(saveAccounts).toHaveBeenCalledTimes(1);
|
|
255
|
+
expect(saveAccounts).toHaveBeenCalledWith(
|
|
256
|
+
expect.objectContaining({
|
|
257
|
+
version: 1,
|
|
258
|
+
accounts: expect.arrayContaining([
|
|
259
|
+
expect.objectContaining({
|
|
260
|
+
refreshToken: "existing-refresh",
|
|
261
|
+
}),
|
|
262
|
+
]),
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Should return fetch interceptor
|
|
267
|
+
expect(result.fetch).toBeTypeOf("function");
|
|
268
|
+
|
|
269
|
+
// Model costs should be zeroed
|
|
270
|
+
expect(provider.models["claude-sonnet"].cost.input).toBe(0);
|
|
271
|
+
expect(provider.models["claude-sonnet"].cost.output).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("second login adds to existing account pool", async () => {
|
|
275
|
+
// First login already happened — accounts file exists
|
|
276
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ refreshToken: "first-refresh", lastUsed: 2000 }]));
|
|
277
|
+
|
|
278
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
279
|
+
|
|
280
|
+
// Run loader first (simulating normal startup after first login)
|
|
281
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
282
|
+
type: "oauth",
|
|
283
|
+
refresh: "first-refresh",
|
|
284
|
+
access: "first-access",
|
|
285
|
+
expires: Date.now() + 3600_000,
|
|
286
|
+
});
|
|
287
|
+
await plugin.auth.loader(getAuth, makeProvider());
|
|
288
|
+
|
|
289
|
+
// Reset mock to track only the second login's save
|
|
290
|
+
saveAccounts.mockClear();
|
|
291
|
+
|
|
292
|
+
// Now simulate second OAuth login
|
|
293
|
+
// loadAccounts returns existing accounts — but accountManager is already loaded,
|
|
294
|
+
// so the menu check (stored && stored.accounts.length > 0 && accountManager) is true.
|
|
295
|
+
// We need to mock readline to return "a" (add) — already done in mock setup.
|
|
296
|
+
|
|
297
|
+
// Mock the OAuth exchange for second account
|
|
298
|
+
mockFetch.mockResolvedValueOnce({
|
|
299
|
+
ok: true,
|
|
300
|
+
json: async () => ({
|
|
301
|
+
access_token: "second-access",
|
|
302
|
+
refresh_token: "second-refresh",
|
|
303
|
+
expires_in: 3600,
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const method = plugin.auth.methods[0];
|
|
308
|
+
const authResult = await method.authorize();
|
|
309
|
+
const credentials = await authResult.callback("second-code#state");
|
|
310
|
+
|
|
311
|
+
expect(credentials.type).toBe("success");
|
|
312
|
+
expect(credentials.refresh).toBe("second-refresh");
|
|
313
|
+
|
|
314
|
+
// Should have saved with BOTH accounts
|
|
315
|
+
expect(saveAccounts).toHaveBeenCalled();
|
|
316
|
+
const savedData = saveAccounts.mock.calls[saveAccounts.mock.calls.length - 1][0];
|
|
317
|
+
expect(savedData.accounts).toHaveLength(2);
|
|
318
|
+
expect(savedData.accounts[0].refreshToken).toBe("first-refresh");
|
|
319
|
+
expect(savedData.accounts[1].refreshToken).toBe("second-refresh");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("loader returns empty object for non-oauth auth", async () => {
|
|
323
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
324
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
325
|
+
type: "api",
|
|
326
|
+
key: "sk-ant-xxx",
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
330
|
+
expect(result).toEqual({});
|
|
331
|
+
expect(saveAccounts).not.toHaveBeenCalled();
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Slash command hooks (/anthropic)
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
describe("slash commands", () => {
|
|
340
|
+
let client;
|
|
341
|
+
let plugin;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Run /anthropic command hook and return the last session message.
|
|
345
|
+
* @param {string} args
|
|
346
|
+
* @returns {Promise<string>}
|
|
347
|
+
*/
|
|
348
|
+
async function runAnthropic(args) {
|
|
349
|
+
client.session.prompt.mockClear();
|
|
350
|
+
await expect(
|
|
351
|
+
plugin["command.execute.before"]({
|
|
352
|
+
command: "anthropic",
|
|
353
|
+
arguments: args,
|
|
354
|
+
sessionID: "session-1",
|
|
355
|
+
}),
|
|
356
|
+
).rejects.toThrow("__ANTHROPIC_COMMAND_HANDLED__");
|
|
357
|
+
|
|
358
|
+
const calls = client.session.prompt.mock.calls;
|
|
359
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
360
|
+
return calls[calls.length - 1][0].body.parts[0].text;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
beforeEach(async () => {
|
|
364
|
+
vi.resetAllMocks();
|
|
365
|
+
client = makeClient();
|
|
366
|
+
loadAccounts.mockResolvedValue(null);
|
|
367
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
368
|
+
plugin = await AnthropicAuthPlugin({ client });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("registers /anthropic command in config hook", async () => {
|
|
372
|
+
const cfg = { command: {} };
|
|
373
|
+
await plugin.config(cfg);
|
|
374
|
+
expect(cfg.command.anthropic).toEqual(
|
|
375
|
+
expect.objectContaining({
|
|
376
|
+
template: "/anthropic",
|
|
377
|
+
description: expect.stringContaining("Manage Anthropic"),
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("shows list view by default", async () => {
|
|
383
|
+
const text = await runAnthropic("");
|
|
384
|
+
expect(text).toContain("▣ Anthropic (error)");
|
|
385
|
+
expect(text).toContain("No accounts configured");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("routes usage alias to CLI list", async () => {
|
|
389
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ refreshToken: "refresh-1", enabled: true }]));
|
|
390
|
+
const text = await runAnthropic("usage");
|
|
391
|
+
expect(text).toContain("Anthropic Multi-Account Status");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("routes switch through CLI command surface", async () => {
|
|
395
|
+
loadAccounts.mockResolvedValue(
|
|
396
|
+
makeAccountsData([
|
|
397
|
+
{ refreshToken: "refresh-1", enabled: true, email: "a@example.com" },
|
|
398
|
+
{ refreshToken: "refresh-2", enabled: true, email: "b@example.com" },
|
|
399
|
+
]),
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const text = await runAnthropic("switch 2");
|
|
403
|
+
expect(text).toContain("Switched");
|
|
404
|
+
expect(saveAccounts).toHaveBeenCalledWith(expect.objectContaining({ activeIndex: 1 }));
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("starts and completes login OAuth flow", async () => {
|
|
408
|
+
let text = await runAnthropic("login");
|
|
409
|
+
expect(text).toContain("Anthropic OAuth");
|
|
410
|
+
expect(text).toContain("Started login flow");
|
|
411
|
+
expect(text).toContain("claude.ai/oauth/authorize");
|
|
412
|
+
|
|
413
|
+
mockFetch.mockResolvedValueOnce({
|
|
414
|
+
ok: true,
|
|
415
|
+
json: async () => ({
|
|
416
|
+
access_token: "access-from-login",
|
|
417
|
+
refresh_token: "refresh-from-login",
|
|
418
|
+
expires_in: 3600,
|
|
419
|
+
account: { email_address: "new@example.com" },
|
|
420
|
+
}),
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
text = await runAnthropic("login complete test-code#test-state");
|
|
424
|
+
expect(text).toContain("Added account #1");
|
|
425
|
+
expect(client.auth.set).toHaveBeenCalledWith(
|
|
426
|
+
expect.objectContaining({
|
|
427
|
+
path: { id: "anthropic" },
|
|
428
|
+
body: expect.objectContaining({
|
|
429
|
+
type: "oauth",
|
|
430
|
+
refresh: "refresh-from-login",
|
|
431
|
+
access: "access-from-login",
|
|
432
|
+
}),
|
|
433
|
+
}),
|
|
434
|
+
);
|
|
435
|
+
expect(saveAccounts).toHaveBeenCalledWith(
|
|
436
|
+
expect.objectContaining({
|
|
437
|
+
version: 1,
|
|
438
|
+
accounts: expect.arrayContaining([
|
|
439
|
+
expect.objectContaining({
|
|
440
|
+
refreshToken: "refresh-from-login",
|
|
441
|
+
access: "access-from-login",
|
|
442
|
+
email: "new@example.com",
|
|
443
|
+
}),
|
|
444
|
+
]),
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("expires pending slash OAuth flow after TTL", async () => {
|
|
450
|
+
await runAnthropic("login");
|
|
451
|
+
|
|
452
|
+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 11 * 60 * 1000);
|
|
453
|
+
try {
|
|
454
|
+
const text = await runAnthropic("login complete stale-code#state");
|
|
455
|
+
expect(text).toContain("expired");
|
|
456
|
+
} finally {
|
|
457
|
+
nowSpy.mockRestore();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("starts and completes reauth OAuth flow", async () => {
|
|
462
|
+
const stored = makeAccountsData([
|
|
463
|
+
{
|
|
464
|
+
refreshToken: "refresh-1",
|
|
465
|
+
access: "old-access",
|
|
466
|
+
expires: Date.now() + 1000,
|
|
467
|
+
enabled: false,
|
|
468
|
+
consecutiveFailures: 3,
|
|
469
|
+
lastFailureTime: Date.now(),
|
|
470
|
+
rateLimitResetTimes: { anthropic: Date.now() + 60_000 },
|
|
471
|
+
},
|
|
472
|
+
]);
|
|
473
|
+
loadAccounts.mockResolvedValue(stored);
|
|
474
|
+
|
|
475
|
+
let text = await runAnthropic("reauth 1");
|
|
476
|
+
expect(text).toContain("Started reauth 1 flow");
|
|
477
|
+
|
|
478
|
+
mockFetch.mockResolvedValueOnce({
|
|
479
|
+
ok: true,
|
|
480
|
+
json: async () => ({
|
|
481
|
+
access_token: "fresh-access",
|
|
482
|
+
refresh_token: "fresh-refresh",
|
|
483
|
+
expires_in: 3600,
|
|
484
|
+
account: { email_address: "reauth@example.com" },
|
|
485
|
+
}),
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
text = await runAnthropic("reauth complete another-code#state");
|
|
489
|
+
expect(text).toContain("Re-authenticated account #1");
|
|
490
|
+
expect(client.auth.set).toHaveBeenCalledWith(
|
|
491
|
+
expect.objectContaining({
|
|
492
|
+
path: { id: "anthropic" },
|
|
493
|
+
body: expect.objectContaining({
|
|
494
|
+
type: "oauth",
|
|
495
|
+
refresh: "fresh-refresh",
|
|
496
|
+
access: "fresh-access",
|
|
497
|
+
}),
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
const saved = saveAccounts.mock.calls[saveAccounts.mock.calls.length - 1][0];
|
|
502
|
+
expect(saved.accounts[0]).toEqual(
|
|
503
|
+
expect.objectContaining({
|
|
504
|
+
refreshToken: "fresh-refresh",
|
|
505
|
+
access: "fresh-access",
|
|
506
|
+
email: "reauth@example.com",
|
|
507
|
+
enabled: true,
|
|
508
|
+
consecutiveFailures: 0,
|
|
509
|
+
lastFailureTime: null,
|
|
510
|
+
rateLimitResetTimes: {},
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("returns without handling non-anthropic commands", async () => {
|
|
516
|
+
await expect(
|
|
517
|
+
plugin["command.execute.before"]({
|
|
518
|
+
command: "usage",
|
|
519
|
+
arguments: "",
|
|
520
|
+
sessionID: "session-1",
|
|
521
|
+
}),
|
|
522
|
+
).resolves.toBeUndefined();
|
|
523
|
+
expect(client.session.prompt).not.toHaveBeenCalled();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// Fetch interceptor
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
describe("fetch interceptor", () => {
|
|
532
|
+
let client;
|
|
533
|
+
let fetchFn;
|
|
534
|
+
|
|
535
|
+
beforeEach(async () => {
|
|
536
|
+
vi.resetAllMocks();
|
|
537
|
+
delete process.env.DISABLE_INTERLEAVED_THINKING;
|
|
538
|
+
delete process.env.USE_API_CONTEXT_MANAGEMENT;
|
|
539
|
+
delete process.env.TENGU_MARBLE_ANVIL;
|
|
540
|
+
delete process.env.TENGU_TOOL_PEAR;
|
|
541
|
+
delete process.env.TENGU_SCARF_COFFEE;
|
|
542
|
+
delete process.env.ANTHROPIC_BETAS;
|
|
543
|
+
delete process.env.ANTHROPIC_CUSTOM_HEADERS;
|
|
544
|
+
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
|
545
|
+
delete process.env.CLAUDE_CODE_ENTRYPOINT;
|
|
546
|
+
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
|
|
547
|
+
delete process.env.CLAUDE_AGENT_SDK_VERSION;
|
|
548
|
+
delete process.env.CLAUDE_AGENT_SDK_CLIENT_APP;
|
|
549
|
+
delete process.env.CLAUDE_CODE_CONTAINER_ID;
|
|
550
|
+
delete process.env.CLAUDE_CODE_REMOTE_SESSION_ID;
|
|
551
|
+
delete process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION;
|
|
552
|
+
delete process.env.OPENCODE_ANTHROPIC_PROMPT_COMPACTION;
|
|
553
|
+
process.env.OPENCODE_ANTHROPIC_SIGNATURE_USER_ID = "test-signature-user";
|
|
554
|
+
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER;
|
|
555
|
+
delete process.env.CLAUDE_CODE_ENTRYPOINT;
|
|
556
|
+
|
|
557
|
+
client = makeClient();
|
|
558
|
+
loadAccounts.mockResolvedValue(null);
|
|
559
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
560
|
+
|
|
561
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
562
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
563
|
+
type: "oauth",
|
|
564
|
+
refresh: "test-refresh",
|
|
565
|
+
access: "test-access",
|
|
566
|
+
expires: Date.now() + 3600_000,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
570
|
+
fetchFn = result.fetch;
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("adds Bearer auth header and required beta headers", async () => {
|
|
574
|
+
mockFetch.mockResolvedValueOnce(
|
|
575
|
+
new Response('data: {"type":"message_start"}\n\n', {
|
|
576
|
+
status: 200,
|
|
577
|
+
headers: { "content-type": "text/event-stream" },
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
582
|
+
method: "POST",
|
|
583
|
+
headers: { "content-type": "application/json" },
|
|
584
|
+
body: JSON.stringify({ messages: [] }),
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
588
|
+
const headers = init.headers;
|
|
589
|
+
expect(headers.get("authorization")).toBe("Bearer test-access");
|
|
590
|
+
expect(headers.get("anthropic-beta")).toContain("oauth-2025-04-20");
|
|
591
|
+
expect(headers.get("anthropic-beta")).toContain("claude-code-20250219");
|
|
592
|
+
expect(headers.get("user-agent")).toContain("claude-cli");
|
|
593
|
+
expect(headers.get("x-app")).toBe("cli");
|
|
594
|
+
expect(headers.get("x-stainless-lang")).toBe("js");
|
|
595
|
+
expect(headers.has("x-api-key")).toBe(false);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("adds ?beta=true to /v1/messages URL", async () => {
|
|
599
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
600
|
+
|
|
601
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
602
|
+
method: "POST",
|
|
603
|
+
body: JSON.stringify({ messages: [] }),
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const [input] = mockFetch.mock.calls[0];
|
|
607
|
+
const url = input instanceof URL ? input : new URL(input.toString());
|
|
608
|
+
expect(url.searchParams.get("beta")).toBe("true");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("transforms system prompt: OpenCode → Claude Code, opencode → Claude", async () => {
|
|
612
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
613
|
+
|
|
614
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
615
|
+
method: "POST",
|
|
616
|
+
body: JSON.stringify({
|
|
617
|
+
system: [{ type: "text", text: "You are OpenCode, an opencode assistant." }],
|
|
618
|
+
messages: [],
|
|
619
|
+
}),
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
623
|
+
const body = JSON.parse(init.body);
|
|
624
|
+
const payloadText = body.system.map((item) => item.text);
|
|
625
|
+
expect(payloadText).toContain("You are Claude Code, an Claude assistant.");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("redacts opencode mentions inside path-like text in system prompt", async () => {
|
|
629
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
630
|
+
|
|
631
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
632
|
+
method: "POST",
|
|
633
|
+
body: JSON.stringify({
|
|
634
|
+
system: [{ type: "text", text: "Working dir: /Users/rmk/projects/opencode-auth" }],
|
|
635
|
+
messages: [],
|
|
636
|
+
}),
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
640
|
+
const body = JSON.parse(init.body);
|
|
641
|
+
const payloadText = body.system.map((item) => item.text);
|
|
642
|
+
expect(payloadText).toContain("Working dir: /Users/rmk/projects/Claude-auth");
|
|
643
|
+
expect(payloadText.join("\n")).not.toMatch(/opencode/i);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("compacts verbose system instructions in minimal mode (default)", async () => {
|
|
647
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
648
|
+
|
|
649
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
650
|
+
method: "POST",
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
messages: [],
|
|
653
|
+
system: [
|
|
654
|
+
{
|
|
655
|
+
type: "text",
|
|
656
|
+
text: "You are Claude Code, Anthropic's official CLI for Claude.\n\nHeader\n<example>remove me</example>\nRule A\nRule A\n\n\nRule B",
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
}),
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
663
|
+
const body = JSON.parse(init.body);
|
|
664
|
+
const userBlock = body.system[2].text;
|
|
665
|
+
expect(userBlock).not.toContain("<example>");
|
|
666
|
+
expect(userBlock).toContain("Rule A");
|
|
667
|
+
expect(userBlock).toContain("Rule B");
|
|
668
|
+
expect(userBlock).not.toContain("\n\n\n");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("deduplicates nested repeated instruction blocks in minimal mode", async () => {
|
|
672
|
+
const repeated =
|
|
673
|
+
"## Model Delegation Protocol\nPreset: openai\nDelegate with Task(subagent_type='fast|medium|heavy', prompt='...').";
|
|
674
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
675
|
+
|
|
676
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
677
|
+
method: "POST",
|
|
678
|
+
body: JSON.stringify({
|
|
679
|
+
messages: [{ role: "user", content: "hello" }],
|
|
680
|
+
system: [
|
|
681
|
+
{
|
|
682
|
+
type: "text",
|
|
683
|
+
text: `You are Claude Code, the best coding agent on the planet.\n\n${repeated}\n\nOther section`,
|
|
684
|
+
},
|
|
685
|
+
{ type: "text", text: repeated },
|
|
686
|
+
],
|
|
687
|
+
}),
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
691
|
+
const body = JSON.parse(init.body);
|
|
692
|
+
const joined = body.system.map((item) => item.text).join("\n\n");
|
|
693
|
+
const occurrences = (joined.match(/## Model Delegation Protocol/g) || []).length;
|
|
694
|
+
expect(occurrences).toBe(1);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("uses compact dedicated prompt for title-generator requests", async () => {
|
|
698
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
699
|
+
|
|
700
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
701
|
+
method: "POST",
|
|
702
|
+
body: JSON.stringify({
|
|
703
|
+
messages: [{ role: "user", content: "test" }],
|
|
704
|
+
system: [
|
|
705
|
+
{
|
|
706
|
+
type: "text",
|
|
707
|
+
text: "You are a title generator. You output ONLY a thread title. Nothing else.\n\n<task>\nGenerate a brief title that would help the user find this conversation later.\n</task>",
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
type: "text",
|
|
711
|
+
text: "## Model Delegation Protocol\nPreset: openai\nDelegate with Task(subagent_type='fast|medium|heavy', prompt='...').",
|
|
712
|
+
},
|
|
713
|
+
],
|
|
714
|
+
}),
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
718
|
+
const body = JSON.parse(init.body);
|
|
719
|
+
const textBlocks = body.system.map((item) => item.text);
|
|
720
|
+
|
|
721
|
+
expect(textBlocks.join("\n")).not.toContain("Model Delegation Protocol");
|
|
722
|
+
expect(textBlocks.join("\n")).not.toContain("Delegate with Task(");
|
|
723
|
+
expect(textBlocks.join("\n")).toContain("You are a title generator. You output ONLY a thread title. Nothing else.");
|
|
724
|
+
expect(textBlocks.join("\n")).toContain("- Keep the title at or below 50 characters.");
|
|
725
|
+
expect(textBlocks.join("\n")).not.toContain("<task>");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("preserves verbose system instructions when prompt compaction is off", async () => {
|
|
729
|
+
const configModule = await import("./lib/config.mjs");
|
|
730
|
+
configModule.loadConfig.mockReturnValueOnce({
|
|
731
|
+
...configModule.loadConfig(),
|
|
732
|
+
signature_emulation: {
|
|
733
|
+
...configModule.loadConfig().signature_emulation,
|
|
734
|
+
prompt_compaction: "off",
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
739
|
+
const result = await plugin.auth.loader(
|
|
740
|
+
vi.fn().mockResolvedValue({
|
|
741
|
+
type: "oauth",
|
|
742
|
+
refresh: "test-refresh",
|
|
743
|
+
access: "test-access",
|
|
744
|
+
expires: Date.now() + 3600_000,
|
|
745
|
+
}),
|
|
746
|
+
makeProvider(),
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
750
|
+
await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
751
|
+
method: "POST",
|
|
752
|
+
body: JSON.stringify({
|
|
753
|
+
messages: [],
|
|
754
|
+
system: [{ type: "text", text: "Header\n<example>keep me</example>\nRule A\nRule A" }],
|
|
755
|
+
}),
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
759
|
+
const body = JSON.parse(init.body);
|
|
760
|
+
expect(body.system[2].text).toContain("<example>keep me</example>");
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("prefixes tool names with mcp_ in request", async () => {
|
|
764
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
765
|
+
|
|
766
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
767
|
+
method: "POST",
|
|
768
|
+
body: JSON.stringify({
|
|
769
|
+
tools: [{ name: "read_file", description: "Read a file" }],
|
|
770
|
+
messages: [
|
|
771
|
+
{
|
|
772
|
+
role: "assistant",
|
|
773
|
+
content: [{ type: "tool_use", name: "read_file", id: "t1", input: {} }],
|
|
774
|
+
},
|
|
775
|
+
],
|
|
776
|
+
}),
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
780
|
+
const body = JSON.parse(init.body);
|
|
781
|
+
expect(body.tools[0].name).toBe("mcp_read_file");
|
|
782
|
+
expect(body.messages[0].content[0].name).toBe("mcp_read_file");
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("strips mcp_ prefix from tool names in response stream", async () => {
|
|
786
|
+
const responseBody =
|
|
787
|
+
'data: {"type":"content_block_start","content_block":{"type":"tool_use","name":"mcp_read_file"}}\n\n';
|
|
788
|
+
mockFetch.mockResolvedValueOnce(
|
|
789
|
+
new Response(responseBody, {
|
|
790
|
+
status: 200,
|
|
791
|
+
headers: { "content-type": "text/event-stream" },
|
|
792
|
+
}),
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
const response = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
796
|
+
method: "POST",
|
|
797
|
+
body: JSON.stringify({ messages: [] }),
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
const text = await response.text();
|
|
801
|
+
expect(text).toContain('"name": "read_file"');
|
|
802
|
+
expect(text).not.toContain("mcp_read_file");
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("does not show account usage toast for non-message endpoints", async () => {
|
|
806
|
+
mockFetch.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
|
807
|
+
|
|
808
|
+
await fetchFn("https://api.anthropic.com/v1/models", {
|
|
809
|
+
method: "GET",
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
expect(client.tui.showToast).not.toHaveBeenCalled();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("shows account usage toast on first messages request", async () => {
|
|
816
|
+
mockFetch.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
|
817
|
+
|
|
818
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
819
|
+
method: "POST",
|
|
820
|
+
body: JSON.stringify({ messages: [] }),
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
expect(client.tui.showToast).toHaveBeenCalledWith({
|
|
824
|
+
body: { message: expect.stringContaining("Account 1"), variant: "info" },
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("does not repeat toast when account stays the same", async () => {
|
|
829
|
+
mockFetch
|
|
830
|
+
.mockResolvedValueOnce(new Response("{}", { status: 200 }))
|
|
831
|
+
.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
|
832
|
+
|
|
833
|
+
// First request — should toast
|
|
834
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
835
|
+
method: "POST",
|
|
836
|
+
body: JSON.stringify({ messages: [] }),
|
|
837
|
+
});
|
|
838
|
+
expect(client.tui.showToast).toHaveBeenCalledTimes(1);
|
|
839
|
+
|
|
840
|
+
// Second request, same account — no new toast
|
|
841
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
842
|
+
method: "POST",
|
|
843
|
+
body: JSON.stringify({ messages: [] }),
|
|
844
|
+
});
|
|
845
|
+
expect(client.tui.showToast).toHaveBeenCalledTimes(1);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("retries on 429 with a different account", async () => {
|
|
849
|
+
// Set up two accounts — the auth fallback provides access token for account 1.
|
|
850
|
+
// Account 2 will need a token refresh before it can be used.
|
|
851
|
+
vi.resetAllMocks();
|
|
852
|
+
const fetchFn = await setupFetchFn(client, [{}, {}]);
|
|
853
|
+
|
|
854
|
+
// First API request: 429 (account 1 has access token from auth fallback)
|
|
855
|
+
mockFetch.mockResolvedValueOnce(
|
|
856
|
+
new Response(JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limit exceeded" } }), {
|
|
857
|
+
status: 429,
|
|
858
|
+
headers: { "retry-after": "0" },
|
|
859
|
+
}),
|
|
860
|
+
);
|
|
861
|
+
// Token refresh for account 2 (no access token yet)
|
|
862
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("access-2", "refresh-2"));
|
|
863
|
+
// Retry API request with account 2: 200
|
|
864
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
865
|
+
|
|
866
|
+
const response = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
867
|
+
method: "POST",
|
|
868
|
+
body: JSON.stringify({ messages: [] }),
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
expect(response.status).toBe(200);
|
|
872
|
+
// 3 calls: first API (429), token refresh for account 2, retry API (200)
|
|
873
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
// System prompt transform
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
|
|
881
|
+
describe("system prompt transform", () => {
|
|
882
|
+
it("does not prepend Claude Code prefix in emulation mode", async () => {
|
|
883
|
+
const client = makeClient();
|
|
884
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
885
|
+
|
|
886
|
+
const output = { system: ["You are a helpful assistant."] };
|
|
887
|
+
plugin["experimental.chat.system.transform"]({ model: { providerID: "anthropic" } }, output);
|
|
888
|
+
|
|
889
|
+
expect(output.system).toEqual(["You are a helpful assistant."]);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("does not modify system for non-anthropic provider", async () => {
|
|
893
|
+
const client = makeClient();
|
|
894
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
895
|
+
|
|
896
|
+
const output = { system: ["You are a helpful assistant."] };
|
|
897
|
+
plugin["experimental.chat.system.transform"]({ model: { providerID: "openai" } }, output);
|
|
898
|
+
|
|
899
|
+
expect(output.system).toEqual(["You are a helpful assistant."]);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it("keeps legacy prefix behavior when emulation is disabled", async () => {
|
|
903
|
+
const configModule = await import("./lib/config.mjs");
|
|
904
|
+
configModule.loadConfig.mockReturnValueOnce({
|
|
905
|
+
...configModule.loadConfig(),
|
|
906
|
+
signature_emulation: {
|
|
907
|
+
enabled: false,
|
|
908
|
+
fetch_claude_code_version_on_startup: false,
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const client = makeClient();
|
|
913
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
914
|
+
|
|
915
|
+
const output = { system: ["You are a helpful assistant."] };
|
|
916
|
+
plugin["experimental.chat.system.transform"]({ model: { providerID: "anthropic" } }, output);
|
|
917
|
+
|
|
918
|
+
expect(output.system[0]).toBe("You are Claude Code, Anthropic's official CLI for Claude.");
|
|
919
|
+
expect(output.system[1]).toContain("You are Claude Code");
|
|
920
|
+
expect(output.system[1]).toContain("You are a helpful assistant.");
|
|
921
|
+
});
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// ---------------------------------------------------------------------------
|
|
925
|
+
// Fetch interceptor — token refresh paths
|
|
926
|
+
// ---------------------------------------------------------------------------
|
|
927
|
+
|
|
928
|
+
describe("fetch interceptor — token refresh", () => {
|
|
929
|
+
let client;
|
|
930
|
+
|
|
931
|
+
beforeEach(() => {
|
|
932
|
+
vi.resetAllMocks();
|
|
933
|
+
client = makeClient();
|
|
934
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
it("refreshes expired account token before making API request", async () => {
|
|
938
|
+
// Set up one account with an EXPIRED token
|
|
939
|
+
loadAccounts.mockResolvedValue(makeAccountsData());
|
|
940
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
941
|
+
|
|
942
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
943
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
944
|
+
type: "oauth",
|
|
945
|
+
refresh: "refresh-1",
|
|
946
|
+
access: "expired-access",
|
|
947
|
+
expires: Date.now() - 1000, // expired
|
|
948
|
+
});
|
|
949
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
950
|
+
|
|
951
|
+
// Token refresh call
|
|
952
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("fresh-access", "refresh-1-rotated"));
|
|
953
|
+
// Actual API call
|
|
954
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
955
|
+
|
|
956
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
957
|
+
method: "POST",
|
|
958
|
+
body: JSON.stringify({ messages: [] }),
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
expect(response.status).toBe(200);
|
|
962
|
+
// 2 calls: token refresh + API request
|
|
963
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
964
|
+
|
|
965
|
+
// First call should be the token refresh
|
|
966
|
+
const [refreshUrl, refreshInit] = mockFetch.mock.calls[0];
|
|
967
|
+
expect(refreshUrl).toBe("https://console.anthropic.com/v1/oauth/token");
|
|
968
|
+
expect(JSON.parse(refreshInit.body).grant_type).toBe("refresh_token");
|
|
969
|
+
|
|
970
|
+
// Second call should use the fresh token
|
|
971
|
+
const [, apiInit] = mockFetch.mock.calls[1];
|
|
972
|
+
expect(apiInit.headers.get("authorization")).toBe("Bearer fresh-access");
|
|
973
|
+
|
|
974
|
+
// Should persist updated tokens (client.auth.set called during refresh)
|
|
975
|
+
expect(client.auth.set).toHaveBeenCalledWith(
|
|
976
|
+
expect.objectContaining({
|
|
977
|
+
body: expect.objectContaining({
|
|
978
|
+
access: "fresh-access",
|
|
979
|
+
refresh: "refresh-1-rotated",
|
|
980
|
+
}),
|
|
981
|
+
}),
|
|
982
|
+
);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("coalesces concurrent refreshes for the same account", async () => {
|
|
986
|
+
loadAccounts.mockResolvedValue(makeAccountsData());
|
|
987
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
988
|
+
|
|
989
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
990
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
991
|
+
type: "oauth",
|
|
992
|
+
refresh: "refresh-1",
|
|
993
|
+
access: "expired-access",
|
|
994
|
+
expires: Date.now() - 1000,
|
|
995
|
+
});
|
|
996
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
997
|
+
|
|
998
|
+
let resolveRefresh;
|
|
999
|
+
const refreshPromise = new Promise((resolve) => {
|
|
1000
|
+
resolveRefresh = resolve;
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
mockFetch.mockImplementation((url) => {
|
|
1004
|
+
const s = String(url);
|
|
1005
|
+
if (s.includes("/v1/oauth/token")) {
|
|
1006
|
+
return refreshPromise;
|
|
1007
|
+
}
|
|
1008
|
+
return Promise.resolve(new Response('{"content":[]}', { status: 200 }));
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
const p1 = result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1012
|
+
method: "POST",
|
|
1013
|
+
body: JSON.stringify({ messages: [] }),
|
|
1014
|
+
});
|
|
1015
|
+
const p2 = result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1016
|
+
method: "POST",
|
|
1017
|
+
body: JSON.stringify({ messages: [] }),
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
// Let both requests reach refresh path
|
|
1021
|
+
await Promise.resolve();
|
|
1022
|
+
|
|
1023
|
+
resolveRefresh(mockTokenRefresh("fresh-access", "refresh-1-rotated"));
|
|
1024
|
+
|
|
1025
|
+
const [r1, r2] = await Promise.all([p1, p2]);
|
|
1026
|
+
expect(r1.status).toBe(200);
|
|
1027
|
+
expect(r2.status).toBe(200);
|
|
1028
|
+
|
|
1029
|
+
const refreshCalls = mockFetch.mock.calls.filter(([u]) => String(u).includes("/v1/oauth/token"));
|
|
1030
|
+
expect(refreshCalls).toHaveLength(1);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("disables account on 401 token refresh failure and retries with next account", async () => {
|
|
1034
|
+
// Two accounts — first will fail refresh, second will succeed
|
|
1035
|
+
loadAccounts.mockResolvedValue(
|
|
1036
|
+
makeAccountsData([{ refreshToken: "revoked-refresh" }, { refreshToken: "good-refresh" }]),
|
|
1037
|
+
);
|
|
1038
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1039
|
+
|
|
1040
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1041
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1042
|
+
type: "oauth",
|
|
1043
|
+
refresh: "revoked-refresh",
|
|
1044
|
+
access: "expired-access",
|
|
1045
|
+
expires: Date.now() - 1000, // expired — forces refresh
|
|
1046
|
+
});
|
|
1047
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1048
|
+
|
|
1049
|
+
// Account 1 token refresh: 401 (revoked)
|
|
1050
|
+
mockFetch.mockResolvedValueOnce({
|
|
1051
|
+
ok: false,
|
|
1052
|
+
status: 401,
|
|
1053
|
+
json: async () => ({ error: "invalid_grant" }),
|
|
1054
|
+
});
|
|
1055
|
+
// Account 2 token refresh (also no access token)
|
|
1056
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("good-access", "good-refresh"));
|
|
1057
|
+
// API call with account 2
|
|
1058
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1059
|
+
|
|
1060
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1061
|
+
method: "POST",
|
|
1062
|
+
body: JSON.stringify({ messages: [] }),
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
expect(response.status).toBe(200);
|
|
1066
|
+
// 3 calls: failed refresh (acct 1), successful refresh (acct 2), API call
|
|
1067
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it("disables account on 400 invalid_grant refresh failure and retries with next account", async () => {
|
|
1071
|
+
loadAccounts.mockResolvedValue(
|
|
1072
|
+
makeAccountsData([{ refreshToken: "bad-refresh" }, { refreshToken: "good-refresh" }]),
|
|
1073
|
+
);
|
|
1074
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1075
|
+
|
|
1076
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1077
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1078
|
+
type: "oauth",
|
|
1079
|
+
refresh: "bad-refresh",
|
|
1080
|
+
access: "expired-access",
|
|
1081
|
+
expires: Date.now() - 1000,
|
|
1082
|
+
});
|
|
1083
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1084
|
+
|
|
1085
|
+
// Account 1 refresh fails with 400 invalid_grant
|
|
1086
|
+
mockFetch.mockResolvedValueOnce({
|
|
1087
|
+
ok: false,
|
|
1088
|
+
status: 400,
|
|
1089
|
+
text: async () => JSON.stringify({ error: "invalid_grant" }),
|
|
1090
|
+
});
|
|
1091
|
+
// Account 2 refresh succeeds
|
|
1092
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("good-access", "good-refresh"));
|
|
1093
|
+
// API call with account 2
|
|
1094
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1095
|
+
|
|
1096
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1097
|
+
method: "POST",
|
|
1098
|
+
body: JSON.stringify({ messages: [] }),
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
expect(response.status).toBe(200);
|
|
1102
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it("fails over to next account on transient refresh failure", async () => {
|
|
1106
|
+
loadAccounts.mockResolvedValue(
|
|
1107
|
+
makeAccountsData([{ refreshToken: "transient-refresh" }, { refreshToken: "good-refresh" }]),
|
|
1108
|
+
);
|
|
1109
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1110
|
+
|
|
1111
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1112
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1113
|
+
type: "oauth",
|
|
1114
|
+
refresh: "transient-refresh",
|
|
1115
|
+
access: "expired-access",
|
|
1116
|
+
expires: Date.now() - 1000,
|
|
1117
|
+
});
|
|
1118
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1119
|
+
|
|
1120
|
+
// Account 1 refresh fails transiently (500) -> should fail over
|
|
1121
|
+
mockFetch.mockResolvedValueOnce({
|
|
1122
|
+
ok: false,
|
|
1123
|
+
status: 500,
|
|
1124
|
+
text: async () => "server error",
|
|
1125
|
+
});
|
|
1126
|
+
// Account 2 refresh succeeds
|
|
1127
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("good-access", "good-refresh"));
|
|
1128
|
+
// API call succeeds on account 2
|
|
1129
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1130
|
+
|
|
1131
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1132
|
+
method: "POST",
|
|
1133
|
+
body: JSON.stringify({ messages: [] }),
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
expect(response.status).toBe(200);
|
|
1137
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
// ---------------------------------------------------------------------------
|
|
1142
|
+
// Fetch interceptor — edge conditions
|
|
1143
|
+
// ---------------------------------------------------------------------------
|
|
1144
|
+
|
|
1145
|
+
describe("fetch interceptor — edge conditions", () => {
|
|
1146
|
+
let client;
|
|
1147
|
+
|
|
1148
|
+
beforeEach(() => {
|
|
1149
|
+
vi.resetAllMocks();
|
|
1150
|
+
client = makeClient();
|
|
1151
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it("fails fast when all accounts are disabled", async () => {
|
|
1155
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ refreshToken: "disabled-refresh", enabled: false }]));
|
|
1156
|
+
|
|
1157
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1158
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1159
|
+
type: "oauth",
|
|
1160
|
+
refresh: "opencode-refresh",
|
|
1161
|
+
access: "opencode-access",
|
|
1162
|
+
expires: Date.now() + 3600_000,
|
|
1163
|
+
});
|
|
1164
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1165
|
+
|
|
1166
|
+
await expect(
|
|
1167
|
+
result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1168
|
+
method: "POST",
|
|
1169
|
+
body: JSON.stringify({ messages: [] }),
|
|
1170
|
+
}),
|
|
1171
|
+
).rejects.toThrow("No enabled Anthropic accounts available");
|
|
1172
|
+
|
|
1173
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// ---------------------------------------------------------------------------
|
|
1178
|
+
// Fetch interceptor — account exhaustion and service-wide errors
|
|
1179
|
+
// ---------------------------------------------------------------------------
|
|
1180
|
+
|
|
1181
|
+
describe("fetch interceptor — account exhaustion", () => {
|
|
1182
|
+
let client;
|
|
1183
|
+
|
|
1184
|
+
beforeEach(() => {
|
|
1185
|
+
vi.resetAllMocks();
|
|
1186
|
+
client = makeClient();
|
|
1187
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it("throws when single account fails token refresh (transient skip, no more accounts)", async () => {
|
|
1191
|
+
// Single account that fails token refresh with a transient error
|
|
1192
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ refreshToken: "bad-refresh" }]));
|
|
1193
|
+
|
|
1194
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1195
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1196
|
+
type: "oauth",
|
|
1197
|
+
refresh: "bad-refresh",
|
|
1198
|
+
access: "expired-access",
|
|
1199
|
+
expires: Date.now() - 1000, // expired — forces refresh
|
|
1200
|
+
});
|
|
1201
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1202
|
+
|
|
1203
|
+
// Refresh fails with transient 500
|
|
1204
|
+
mockFetch.mockResolvedValueOnce({
|
|
1205
|
+
ok: false,
|
|
1206
|
+
status: 500,
|
|
1207
|
+
text: async () => "server error",
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
await expect(
|
|
1211
|
+
result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1212
|
+
method: "POST",
|
|
1213
|
+
body: JSON.stringify({ messages: [] }),
|
|
1214
|
+
}),
|
|
1215
|
+
).rejects.toThrow(/Token refresh failed|No available Anthropic account|All accounts exhausted/);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it("throws when only account gets rate-limited (no more accounts to try)", async () => {
|
|
1219
|
+
const fetchFn = await setupFetchFn(client);
|
|
1220
|
+
|
|
1221
|
+
// 429 — account-specific, but no other accounts to try
|
|
1222
|
+
mockFetch.mockResolvedValueOnce(
|
|
1223
|
+
new Response(JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limit exceeded" } }), {
|
|
1224
|
+
status: 429,
|
|
1225
|
+
headers: { "retry-after": "30" },
|
|
1226
|
+
}),
|
|
1227
|
+
);
|
|
1228
|
+
|
|
1229
|
+
await expect(
|
|
1230
|
+
fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1231
|
+
method: "POST",
|
|
1232
|
+
body: JSON.stringify({ messages: [] }),
|
|
1233
|
+
}),
|
|
1234
|
+
).rejects.toThrow(/All accounts exhausted|No available Anthropic account/);
|
|
1235
|
+
|
|
1236
|
+
// Only 1 fetch call — no retry (no other accounts)
|
|
1237
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it.each([
|
|
1241
|
+
{ status: 529, errorType: "overloaded_error", errorMsg: "Server is overloaded" },
|
|
1242
|
+
{ status: 503, errorType: "service_unavailable", errorMsg: "temporarily unavailable" },
|
|
1243
|
+
{ status: 500, errorType: "internal_error", errorMsg: "internal server error" },
|
|
1244
|
+
])("returns $status directly without switching accounts", async ({ status, errorType, errorMsg }) => {
|
|
1245
|
+
const fetchFn = await setupFetchFn(client, [{}, {}]);
|
|
1246
|
+
|
|
1247
|
+
mockFetch.mockResolvedValueOnce(
|
|
1248
|
+
new Response(JSON.stringify({ error: { type: errorType, message: errorMsg } }), { status }),
|
|
1249
|
+
);
|
|
1250
|
+
|
|
1251
|
+
const response = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1252
|
+
method: "POST",
|
|
1253
|
+
body: JSON.stringify({ messages: [] }),
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
expect(response.status).toBe(status);
|
|
1257
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
it("handles 400 account-specific error (billing) by switching accounts", async () => {
|
|
1261
|
+
const fetchFn = await setupFetchFn(client, [{}, {}]);
|
|
1262
|
+
|
|
1263
|
+
// Account 1: 400 with billing language — account-specific
|
|
1264
|
+
mockFetch.mockResolvedValueOnce(
|
|
1265
|
+
new Response(
|
|
1266
|
+
JSON.stringify({
|
|
1267
|
+
error: {
|
|
1268
|
+
type: "invalid_request_error",
|
|
1269
|
+
message: "This request would exceed your account's rate limit. Please try again later.",
|
|
1270
|
+
},
|
|
1271
|
+
}),
|
|
1272
|
+
{ status: 400 },
|
|
1273
|
+
),
|
|
1274
|
+
);
|
|
1275
|
+
// Token refresh for account 2
|
|
1276
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("access-2", "refresh-2"));
|
|
1277
|
+
// API call with account 2: success
|
|
1278
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1279
|
+
|
|
1280
|
+
const response = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1281
|
+
method: "POST",
|
|
1282
|
+
body: JSON.stringify({ messages: [] }),
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
expect(response.status).toBe(200);
|
|
1286
|
+
// 3 calls: first API (400), token refresh (acct 2), retry API (200)
|
|
1287
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it("handles 400 non-account-specific error by returning directly", async () => {
|
|
1291
|
+
const fetchFn = await setupFetchFn(client, [{}, {}]);
|
|
1292
|
+
|
|
1293
|
+
// 400 with generic error (not account-specific) — should NOT switch
|
|
1294
|
+
mockFetch.mockResolvedValueOnce(
|
|
1295
|
+
new Response(JSON.stringify({ error: { type: "invalid_request_error", message: "Invalid model specified" } }), {
|
|
1296
|
+
status: 400,
|
|
1297
|
+
}),
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
const response = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1301
|
+
method: "POST",
|
|
1302
|
+
body: JSON.stringify({ messages: [] }),
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
// Should return the 400 directly — not switch accounts
|
|
1306
|
+
expect(response.status).toBe(400);
|
|
1307
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it("handles 403 permission_error by switching accounts", async () => {
|
|
1311
|
+
const fetchFn = await setupFetchFn(client, [{}, {}]);
|
|
1312
|
+
|
|
1313
|
+
// Account 1 fails with structured permission error
|
|
1314
|
+
mockFetch.mockResolvedValueOnce(
|
|
1315
|
+
new Response(JSON.stringify({ error: { type: "permission_error", message: "Forbidden" } }), { status: 403 }),
|
|
1316
|
+
);
|
|
1317
|
+
// Refresh account 2 token
|
|
1318
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("access-2", "refresh-2"));
|
|
1319
|
+
// Retry with account 2 succeeds
|
|
1320
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1321
|
+
|
|
1322
|
+
const response = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1323
|
+
method: "POST",
|
|
1324
|
+
body: JSON.stringify({ messages: [] }),
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
expect(response.status).toBe(200);
|
|
1328
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
1329
|
+
expect(client.tui.showToast).toHaveBeenCalledWith(
|
|
1330
|
+
expect.objectContaining({
|
|
1331
|
+
body: expect.objectContaining({
|
|
1332
|
+
message: expect.stringContaining("permission denied"),
|
|
1333
|
+
variant: "warning",
|
|
1334
|
+
}),
|
|
1335
|
+
}),
|
|
1336
|
+
);
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
it("clears token state on 401 so next attempt refreshes token", async () => {
|
|
1340
|
+
vi.useFakeTimers();
|
|
1341
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
1342
|
+
|
|
1343
|
+
try {
|
|
1344
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ access: "stale-access", expires: Date.now() + 3600_000 }]));
|
|
1345
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1346
|
+
|
|
1347
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1348
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1349
|
+
type: "oauth",
|
|
1350
|
+
refresh: "refresh-1",
|
|
1351
|
+
access: "stale-access",
|
|
1352
|
+
expires: Date.now() + 3600_000,
|
|
1353
|
+
});
|
|
1354
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1355
|
+
|
|
1356
|
+
// First API call fails with 401 (account-specific auth error)
|
|
1357
|
+
mockFetch.mockResolvedValueOnce(
|
|
1358
|
+
new Response(JSON.stringify({ error: { type: "authentication_error", message: "Unauthorized" } }), {
|
|
1359
|
+
status: 401,
|
|
1360
|
+
}),
|
|
1361
|
+
);
|
|
1362
|
+
|
|
1363
|
+
await expect(
|
|
1364
|
+
result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1365
|
+
method: "POST",
|
|
1366
|
+
body: JSON.stringify({ messages: [] }),
|
|
1367
|
+
}),
|
|
1368
|
+
).rejects.toThrow(/All accounts exhausted|No available Anthropic account/);
|
|
1369
|
+
|
|
1370
|
+
// 401 path uses short AUTH_FAILED cooldown (5s)
|
|
1371
|
+
vi.advanceTimersByTime(6_000);
|
|
1372
|
+
|
|
1373
|
+
// Next request should refresh token before calling API
|
|
1374
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("fresh-access", "refresh-1"));
|
|
1375
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1376
|
+
|
|
1377
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1378
|
+
method: "POST",
|
|
1379
|
+
body: JSON.stringify({ messages: [] }),
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
expect(response.status).toBe(200);
|
|
1383
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
1384
|
+
|
|
1385
|
+
const refreshCallUrl = String(mockFetch.mock.calls[1][0]);
|
|
1386
|
+
expect(refreshCallUrl).toContain("/v1/oauth/token");
|
|
1387
|
+
} finally {
|
|
1388
|
+
vi.useRealTimers();
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
it("tries next account when fetch throws a network error", async () => {
|
|
1393
|
+
loadAccounts.mockResolvedValue(
|
|
1394
|
+
makeAccountsData([
|
|
1395
|
+
{ access: "access-1", expires: Date.now() + 3600_000 },
|
|
1396
|
+
{ access: "access-2", expires: Date.now() + 3600_000 },
|
|
1397
|
+
]),
|
|
1398
|
+
);
|
|
1399
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1400
|
+
|
|
1401
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1402
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1403
|
+
type: "oauth",
|
|
1404
|
+
refresh: "refresh-1",
|
|
1405
|
+
access: "access-1",
|
|
1406
|
+
expires: Date.now() + 3600_000,
|
|
1407
|
+
});
|
|
1408
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1409
|
+
|
|
1410
|
+
mockFetch.mockRejectedValueOnce(new Error("network down"));
|
|
1411
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1412
|
+
|
|
1413
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1414
|
+
method: "POST",
|
|
1415
|
+
body: JSON.stringify({ messages: [] }),
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
expect(response.status).toBe(200);
|
|
1419
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
1420
|
+
const secondHeaders = mockFetch.mock.calls[1][1].headers;
|
|
1421
|
+
expect(secondHeaders.get("authorization")).toBe("Bearer access-2");
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
it("handles mixed account-specific failures across three accounts", async () => {
|
|
1425
|
+
loadAccounts.mockResolvedValue(
|
|
1426
|
+
makeAccountsData([
|
|
1427
|
+
{ access: "access-1", expires: Date.now() + 3600_000 },
|
|
1428
|
+
{ access: "access-2", expires: Date.now() + 3600_000 },
|
|
1429
|
+
{ access: "access-3", expires: Date.now() + 3600_000 },
|
|
1430
|
+
]),
|
|
1431
|
+
);
|
|
1432
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1433
|
+
|
|
1434
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1435
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1436
|
+
type: "oauth",
|
|
1437
|
+
refresh: "refresh-1",
|
|
1438
|
+
access: "access-1",
|
|
1439
|
+
expires: Date.now() + 3600_000,
|
|
1440
|
+
});
|
|
1441
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1442
|
+
|
|
1443
|
+
// Account 1: 429 rate limit
|
|
1444
|
+
mockFetch.mockResolvedValueOnce(
|
|
1445
|
+
new Response(JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limit exceeded" } }), {
|
|
1446
|
+
status: 429,
|
|
1447
|
+
}),
|
|
1448
|
+
);
|
|
1449
|
+
// Account 2: 403 permission error
|
|
1450
|
+
mockFetch.mockResolvedValueOnce(
|
|
1451
|
+
new Response(JSON.stringify({ error: { type: "permission_error", message: "Forbidden" } }), { status: 403 }),
|
|
1452
|
+
);
|
|
1453
|
+
// Account 3: success
|
|
1454
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1455
|
+
|
|
1456
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1457
|
+
method: "POST",
|
|
1458
|
+
body: JSON.stringify({ messages: [] }),
|
|
1459
|
+
});
|
|
1460
|
+
|
|
1461
|
+
expect(response.status).toBe(200);
|
|
1462
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
1463
|
+
const thirdHeaders = mockFetch.mock.calls[2][1].headers;
|
|
1464
|
+
expect(thirdHeaders.get("authorization")).toBe("Bearer access-3");
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it("debounces rapid account-switch warning toasts", async () => {
|
|
1468
|
+
loadAccounts.mockResolvedValue(
|
|
1469
|
+
makeAccountsData([
|
|
1470
|
+
{ access: "access-1", expires: Date.now() + 3600_000 },
|
|
1471
|
+
{ access: "access-2", expires: Date.now() + 3600_000 },
|
|
1472
|
+
{ access: "access-3", expires: Date.now() + 3600_000 },
|
|
1473
|
+
]),
|
|
1474
|
+
);
|
|
1475
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1476
|
+
|
|
1477
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1478
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1479
|
+
type: "oauth",
|
|
1480
|
+
refresh: "refresh-1",
|
|
1481
|
+
access: "access-1",
|
|
1482
|
+
expires: Date.now() + 3600_000,
|
|
1483
|
+
});
|
|
1484
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1485
|
+
|
|
1486
|
+
// Two immediate account-specific failures trigger two switches in one request.
|
|
1487
|
+
mockFetch.mockResolvedValueOnce(
|
|
1488
|
+
new Response(JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limit exceeded" } }), {
|
|
1489
|
+
status: 429,
|
|
1490
|
+
}),
|
|
1491
|
+
);
|
|
1492
|
+
mockFetch.mockResolvedValueOnce(
|
|
1493
|
+
new Response(JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limit exceeded" } }), {
|
|
1494
|
+
status: 429,
|
|
1495
|
+
}),
|
|
1496
|
+
);
|
|
1497
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
1498
|
+
|
|
1499
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
1500
|
+
method: "POST",
|
|
1501
|
+
body: JSON.stringify({ messages: [] }),
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
expect(response.status).toBe(200);
|
|
1505
|
+
|
|
1506
|
+
const warningToasts = client.tui.showToast.mock.calls.filter((call) => call[0]?.body?.variant === "warning");
|
|
1507
|
+
|
|
1508
|
+
// Debounce should suppress immediate duplicate switch warnings.
|
|
1509
|
+
expect(warningToasts).toHaveLength(1);
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
// ---------------------------------------------------------------------------
|
|
1514
|
+
// OAuth exchange failure
|
|
1515
|
+
// ---------------------------------------------------------------------------
|
|
1516
|
+
|
|
1517
|
+
describe("OAuth exchange failure", () => {
|
|
1518
|
+
let client;
|
|
1519
|
+
|
|
1520
|
+
beforeEach(() => {
|
|
1521
|
+
vi.resetAllMocks();
|
|
1522
|
+
client = makeClient();
|
|
1523
|
+
loadAccounts.mockResolvedValue(null);
|
|
1524
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
it("propagates exchange failure without saving accounts", async () => {
|
|
1528
|
+
// Mock the OAuth token exchange to fail
|
|
1529
|
+
mockFetch.mockResolvedValueOnce({
|
|
1530
|
+
ok: false,
|
|
1531
|
+
status: 400,
|
|
1532
|
+
json: async () => ({ error: "invalid_grant" }),
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1536
|
+
const method = plugin.auth.methods[0];
|
|
1537
|
+
|
|
1538
|
+
const authResult = await method.authorize();
|
|
1539
|
+
const credentials = await authResult.callback("bad-code#state");
|
|
1540
|
+
|
|
1541
|
+
expect(credentials.type).toBe("failed");
|
|
1542
|
+
// saveAccounts should NOT have been called
|
|
1543
|
+
expect(saveAccounts).not.toHaveBeenCalled();
|
|
1544
|
+
});
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
// ---------------------------------------------------------------------------
|
|
1548
|
+
// Auth menu actions (cancel, fresh, manage)
|
|
1549
|
+
// ---------------------------------------------------------------------------
|
|
1550
|
+
|
|
1551
|
+
describe("auth menu actions", () => {
|
|
1552
|
+
let client;
|
|
1553
|
+
|
|
1554
|
+
beforeEach(() => {
|
|
1555
|
+
vi.resetAllMocks();
|
|
1556
|
+
client = makeClient();
|
|
1557
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Helper: set up a plugin with existing accounts and a loaded accountManager,
|
|
1562
|
+
* then configure readline to return a specific menu choice.
|
|
1563
|
+
*/
|
|
1564
|
+
async function setupWithMenuChoice(menuChoice) {
|
|
1565
|
+
loadAccounts.mockResolvedValue({
|
|
1566
|
+
version: 1,
|
|
1567
|
+
accounts: [
|
|
1568
|
+
{
|
|
1569
|
+
refreshToken: "existing-refresh",
|
|
1570
|
+
addedAt: 1000,
|
|
1571
|
+
lastUsed: 2000,
|
|
1572
|
+
enabled: true,
|
|
1573
|
+
rateLimitResetTimes: {},
|
|
1574
|
+
consecutiveFailures: 0,
|
|
1575
|
+
lastFailureTime: null,
|
|
1576
|
+
},
|
|
1577
|
+
],
|
|
1578
|
+
activeIndex: 0,
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1582
|
+
|
|
1583
|
+
// Run loader to initialize accountManager
|
|
1584
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1585
|
+
type: "oauth",
|
|
1586
|
+
refresh: "existing-refresh",
|
|
1587
|
+
access: "existing-access",
|
|
1588
|
+
expires: Date.now() + 3600_000,
|
|
1589
|
+
});
|
|
1590
|
+
await plugin.auth.loader(getAuth, makeProvider());
|
|
1591
|
+
|
|
1592
|
+
// Configure readline mock to return the menu choice
|
|
1593
|
+
const { createInterface: mockCreateInterface } = await import("node:readline/promises");
|
|
1594
|
+
mockCreateInterface.mockReturnValue({
|
|
1595
|
+
question: vi.fn().mockResolvedValue(menuChoice),
|
|
1596
|
+
close: vi.fn(),
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
// Reset saveAccounts tracking after loader's save
|
|
1600
|
+
saveAccounts.mockClear();
|
|
1601
|
+
|
|
1602
|
+
return plugin;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
it("cancel action returns about:blank with failed callback", async () => {
|
|
1606
|
+
const plugin = await setupWithMenuChoice("c");
|
|
1607
|
+
const method = plugin.auth.methods[0];
|
|
1608
|
+
|
|
1609
|
+
const authResult = await method.authorize();
|
|
1610
|
+
|
|
1611
|
+
expect(authResult.url).toBe("about:blank");
|
|
1612
|
+
expect(authResult.method).toBe("code");
|
|
1613
|
+
|
|
1614
|
+
const credentials = await authResult.callback("anything");
|
|
1615
|
+
expect(credentials.type).toBe("failed");
|
|
1616
|
+
|
|
1617
|
+
// No accounts should have been saved
|
|
1618
|
+
expect(saveAccounts).not.toHaveBeenCalled();
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
it("fresh action clears accounts and proceeds to OAuth", async () => {
|
|
1622
|
+
const plugin = await setupWithMenuChoice("f");
|
|
1623
|
+
const method = plugin.auth.methods[0];
|
|
1624
|
+
|
|
1625
|
+
const authResult = await method.authorize();
|
|
1626
|
+
|
|
1627
|
+
// Should have cleared accounts
|
|
1628
|
+
expect(clearAccounts).toHaveBeenCalled();
|
|
1629
|
+
|
|
1630
|
+
// Should proceed to OAuth (URL should be the authorize URL, not about:blank)
|
|
1631
|
+
expect(authResult.url).toContain("claude.ai/oauth/authorize");
|
|
1632
|
+
expect(authResult.method).toBe("code");
|
|
1633
|
+
|
|
1634
|
+
// Simulate completing the OAuth flow
|
|
1635
|
+
mockFetch.mockResolvedValueOnce({
|
|
1636
|
+
ok: true,
|
|
1637
|
+
json: async () => ({
|
|
1638
|
+
access_token: "fresh-access",
|
|
1639
|
+
refresh_token: "fresh-refresh",
|
|
1640
|
+
expires_in: 3600,
|
|
1641
|
+
}),
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
const credentials = await authResult.callback("fresh-code#state");
|
|
1645
|
+
expect(credentials.type).toBe("success");
|
|
1646
|
+
expect(credentials.refresh).toBe("fresh-refresh");
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
it("manage action saves and returns about:blank", async () => {
|
|
1650
|
+
// For manage, readline returns "m" for menu, then "b" for back in manage submenu
|
|
1651
|
+
loadAccounts.mockResolvedValue({
|
|
1652
|
+
version: 1,
|
|
1653
|
+
accounts: [
|
|
1654
|
+
{
|
|
1655
|
+
refreshToken: "existing-refresh",
|
|
1656
|
+
addedAt: 1000,
|
|
1657
|
+
lastUsed: 2000,
|
|
1658
|
+
enabled: true,
|
|
1659
|
+
rateLimitResetTimes: {},
|
|
1660
|
+
consecutiveFailures: 0,
|
|
1661
|
+
lastFailureTime: null,
|
|
1662
|
+
},
|
|
1663
|
+
],
|
|
1664
|
+
activeIndex: 0,
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1668
|
+
|
|
1669
|
+
// Run loader to initialize accountManager
|
|
1670
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1671
|
+
type: "oauth",
|
|
1672
|
+
refresh: "existing-refresh",
|
|
1673
|
+
access: "existing-access",
|
|
1674
|
+
expires: Date.now() + 3600_000,
|
|
1675
|
+
});
|
|
1676
|
+
await plugin.auth.loader(getAuth, makeProvider());
|
|
1677
|
+
|
|
1678
|
+
// Configure readline: first call returns "m" (menu), second returns "b" (back from manage)
|
|
1679
|
+
const { createInterface: mockCreateInterface } = await import("node:readline/promises");
|
|
1680
|
+
let callCount = 0;
|
|
1681
|
+
mockCreateInterface.mockReturnValue({
|
|
1682
|
+
question: vi.fn().mockImplementation(() => {
|
|
1683
|
+
callCount++;
|
|
1684
|
+
// First question call is the menu prompt, second is the manage submenu
|
|
1685
|
+
return Promise.resolve(callCount === 1 ? "m" : "b");
|
|
1686
|
+
}),
|
|
1687
|
+
close: vi.fn(),
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
saveAccounts.mockClear();
|
|
1691
|
+
|
|
1692
|
+
const method = plugin.auth.methods[0];
|
|
1693
|
+
const authResult = await method.authorize();
|
|
1694
|
+
|
|
1695
|
+
expect(authResult.url).toBe("about:blank");
|
|
1696
|
+
expect(authResult.method).toBe("code");
|
|
1697
|
+
|
|
1698
|
+
// Should have saved after manage
|
|
1699
|
+
expect(saveAccounts).toHaveBeenCalled();
|
|
1700
|
+
|
|
1701
|
+
// Callback should return failed (no OAuth was performed)
|
|
1702
|
+
const credentials = await authResult.callback("anything");
|
|
1703
|
+
expect(credentials.type).toBe("failed");
|
|
1704
|
+
});
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
// ---------------------------------------------------------------------------
|
|
1708
|
+
// Header handling edge cases
|
|
1709
|
+
// ---------------------------------------------------------------------------
|
|
1710
|
+
|
|
1711
|
+
describe("header handling", () => {
|
|
1712
|
+
let client;
|
|
1713
|
+
let fetchFn;
|
|
1714
|
+
|
|
1715
|
+
beforeEach(async () => {
|
|
1716
|
+
vi.resetAllMocks();
|
|
1717
|
+
delete process.env.DISABLE_INTERLEAVED_THINKING;
|
|
1718
|
+
delete process.env.USE_API_CONTEXT_MANAGEMENT;
|
|
1719
|
+
delete process.env.TENGU_MARBLE_ANVIL;
|
|
1720
|
+
delete process.env.TENGU_TOOL_PEAR;
|
|
1721
|
+
delete process.env.TENGU_SCARF_COFFEE;
|
|
1722
|
+
delete process.env.ANTHROPIC_BETAS;
|
|
1723
|
+
|
|
1724
|
+
client = makeClient();
|
|
1725
|
+
loadAccounts.mockResolvedValue(null);
|
|
1726
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
1727
|
+
|
|
1728
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
1729
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
1730
|
+
type: "oauth",
|
|
1731
|
+
refresh: "test-refresh",
|
|
1732
|
+
access: "test-access",
|
|
1733
|
+
expires: Date.now() + 3600_000,
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
1737
|
+
fetchFn = result.fetch;
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
it("preserves and merges incoming anthropic-beta headers", async () => {
|
|
1741
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1742
|
+
|
|
1743
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1744
|
+
method: "POST",
|
|
1745
|
+
headers: {
|
|
1746
|
+
"content-type": "application/json",
|
|
1747
|
+
"anthropic-beta": "custom-beta-2025-01-01,another-beta-2025-02-01",
|
|
1748
|
+
},
|
|
1749
|
+
body: JSON.stringify({ messages: [] }),
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1753
|
+
const betaHeader = init.headers.get("anthropic-beta");
|
|
1754
|
+
|
|
1755
|
+
// Should contain both required betas AND the custom ones
|
|
1756
|
+
expect(betaHeader).toContain("oauth-2025-04-20");
|
|
1757
|
+
expect(betaHeader).toContain("interleaved-thinking-2025-05-14");
|
|
1758
|
+
expect(betaHeader).toContain("claude-code-20250219");
|
|
1759
|
+
expect(betaHeader).toContain("fine-grained-tool-streaming-2025-05-14");
|
|
1760
|
+
expect(betaHeader).toContain("custom-beta-2025-01-01");
|
|
1761
|
+
expect(betaHeader).toContain("another-beta-2025-02-01");
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
it("does NOT add context-1m beta for anthropic/OAuth provider (not supported)", async () => {
|
|
1765
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1766
|
+
|
|
1767
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1768
|
+
method: "POST",
|
|
1769
|
+
headers: { "content-type": "application/json" },
|
|
1770
|
+
body: JSON.stringify({ model: "claude-sonnet-4-1m", messages: [] }),
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1774
|
+
// context-1m beta is unsupported on OAuth; compaction is gated by model.limit.input instead.
|
|
1775
|
+
expect(init.headers.get("anthropic-beta")).not.toContain("context-1m-2025-08-07");
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
it("adds adaptive-thinking beta instead of interleaved-thinking for Opus 4.6 models", async () => {
|
|
1779
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1780
|
+
|
|
1781
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1782
|
+
method: "POST",
|
|
1783
|
+
headers: { "content-type": "application/json" },
|
|
1784
|
+
body: JSON.stringify({ model: "claude-opus-4-6", messages: [] }),
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1788
|
+
const betaHeader = init.headers.get("anthropic-beta");
|
|
1789
|
+
expect(betaHeader).toContain("adaptive-thinking-2026-01-28");
|
|
1790
|
+
expect(betaHeader).not.toContain("interleaved-thinking-2025-05-14");
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
it("transforms budget_tokens thinking to effort for Opus 4.6", async () => {
|
|
1794
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1795
|
+
|
|
1796
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1797
|
+
method: "POST",
|
|
1798
|
+
headers: { "content-type": "application/json" },
|
|
1799
|
+
body: JSON.stringify({
|
|
1800
|
+
model: "claude-opus-4-6",
|
|
1801
|
+
messages: [{ role: "user", content: "think hard" }],
|
|
1802
|
+
thinking: { type: "enabled", budget_tokens: 20000 },
|
|
1803
|
+
}),
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1807
|
+
const parsed = JSON.parse(init.body);
|
|
1808
|
+
expect(parsed.thinking).toEqual({ type: "enabled", effort: "max" });
|
|
1809
|
+
expect(parsed.thinking.budget_tokens).toBeUndefined();
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
it("maps budget_tokens to effort levels correctly for Opus 4.6", async () => {
|
|
1813
|
+
/** @type {Array<[number, string]>} */
|
|
1814
|
+
const cases = [
|
|
1815
|
+
[512, "low"],
|
|
1816
|
+
[1024, "low"],
|
|
1817
|
+
[1025, "medium"],
|
|
1818
|
+
[8000, "medium"],
|
|
1819
|
+
[8001, "high"],
|
|
1820
|
+
[16000, "high"],
|
|
1821
|
+
[16001, "max"],
|
|
1822
|
+
[100000, "max"],
|
|
1823
|
+
];
|
|
1824
|
+
|
|
1825
|
+
for (const [budget, expectedEffort] of cases) {
|
|
1826
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1827
|
+
mockFetch.mockClear();
|
|
1828
|
+
|
|
1829
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1830
|
+
method: "POST",
|
|
1831
|
+
headers: { "content-type": "application/json" },
|
|
1832
|
+
body: JSON.stringify({
|
|
1833
|
+
model: "claude-opus-4-6",
|
|
1834
|
+
messages: [],
|
|
1835
|
+
thinking: { type: "enabled", budget_tokens: budget },
|
|
1836
|
+
}),
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1840
|
+
const parsed = JSON.parse(init.body);
|
|
1841
|
+
expect(parsed.thinking.effort).toBe(expectedEffort);
|
|
1842
|
+
expect(parsed.thinking.budget_tokens).toBeUndefined();
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
it("keeps effort if already provided for Opus 4.6", async () => {
|
|
1847
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1848
|
+
|
|
1849
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1850
|
+
method: "POST",
|
|
1851
|
+
headers: { "content-type": "application/json" },
|
|
1852
|
+
body: JSON.stringify({
|
|
1853
|
+
model: "claude-opus-4-6",
|
|
1854
|
+
messages: [],
|
|
1855
|
+
thinking: { type: "enabled", effort: "low" },
|
|
1856
|
+
}),
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1860
|
+
const parsed = JSON.parse(init.body);
|
|
1861
|
+
expect(parsed.thinking).toEqual({ type: "enabled", effort: "low" });
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it("passes thinking through unchanged for older models (budget_tokens preserved)", async () => {
|
|
1865
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1866
|
+
|
|
1867
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1868
|
+
method: "POST",
|
|
1869
|
+
headers: { "content-type": "application/json" },
|
|
1870
|
+
body: JSON.stringify({
|
|
1871
|
+
model: "claude-sonnet-4-5",
|
|
1872
|
+
messages: [],
|
|
1873
|
+
thinking: { type: "enabled", budget_tokens: 8000 },
|
|
1874
|
+
}),
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1878
|
+
const parsed = JSON.parse(init.body);
|
|
1879
|
+
expect(parsed.thinking).toEqual({ type: "enabled", budget_tokens: 8000 });
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
it("adds ANTHROPIC_BETAS entries for non-haiku models", async () => {
|
|
1883
|
+
process.env.ANTHROPIC_BETAS = "custom-a, custom-b";
|
|
1884
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1885
|
+
|
|
1886
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1887
|
+
method: "POST",
|
|
1888
|
+
headers: { "content-type": "application/json" },
|
|
1889
|
+
body: JSON.stringify({ model: "claude-sonnet-4", messages: [] }),
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1893
|
+
const betaHeader = init.headers.get("anthropic-beta");
|
|
1894
|
+
expect(betaHeader).toContain("custom-a");
|
|
1895
|
+
expect(betaHeader).toContain("custom-b");
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
it("computes x-stainless-helper from tools and message content", async () => {
|
|
1899
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1900
|
+
|
|
1901
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1902
|
+
method: "POST",
|
|
1903
|
+
headers: { "content-type": "application/json" },
|
|
1904
|
+
body: JSON.stringify({
|
|
1905
|
+
messages: [
|
|
1906
|
+
{
|
|
1907
|
+
role: "assistant",
|
|
1908
|
+
content: [{ type: "text", text: "hello", stainlessHelper: "compaction" }],
|
|
1909
|
+
},
|
|
1910
|
+
],
|
|
1911
|
+
tools: [{ name: "read_file", stainlessHelper: "BetaToolRunner" }],
|
|
1912
|
+
}),
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1916
|
+
expect(init.headers.get("x-stainless-helper")).toContain("BetaToolRunner");
|
|
1917
|
+
expect(init.headers.get("x-stainless-helper")).toContain("compaction");
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
it("injects billing and identity system blocks in request body", async () => {
|
|
1921
|
+
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
1922
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1923
|
+
|
|
1924
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1925
|
+
method: "POST",
|
|
1926
|
+
headers: { "content-type": "application/json" },
|
|
1927
|
+
body: JSON.stringify({
|
|
1928
|
+
messages: [{ role: "user", content: "abcd_efghijklmnopqrstuv" }],
|
|
1929
|
+
system: [{ type: "text", text: "Use OpenCode defaults" }],
|
|
1930
|
+
}),
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1934
|
+
const parsed = JSON.parse(init.body);
|
|
1935
|
+
expect(parsed.system[0].text).toContain("x-anthropic-billing-header:");
|
|
1936
|
+
expect(parsed.system[0].text).toContain("cc_entrypoint=cli");
|
|
1937
|
+
expect(parsed.system[0].cache_control).toBeUndefined();
|
|
1938
|
+
expect(parsed.system[1]).toEqual({
|
|
1939
|
+
type: "text",
|
|
1940
|
+
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
|
1941
|
+
cache_control: { type: "ephemeral" },
|
|
1942
|
+
});
|
|
1943
|
+
expect(parsed.system[2].text).toBe("Use Claude Code defaults");
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
it("redacts opencode mentions and compacts duplicated identity prefix", async () => {
|
|
1947
|
+
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
1948
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1949
|
+
|
|
1950
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1951
|
+
method: "POST",
|
|
1952
|
+
headers: { "content-type": "application/json" },
|
|
1953
|
+
body: JSON.stringify({
|
|
1954
|
+
messages: [{ role: "user", content: "hello" }],
|
|
1955
|
+
system: [
|
|
1956
|
+
{
|
|
1957
|
+
type: "text",
|
|
1958
|
+
text: "You are Claude Code, Anthropic's official CLI for Claude.\n\nUse OpenCode defaults\n\n\nSee https://opencode.ai/docs and github.com/anomalyco/opencode",
|
|
1959
|
+
},
|
|
1960
|
+
],
|
|
1961
|
+
}),
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1965
|
+
const parsed = JSON.parse(init.body);
|
|
1966
|
+
|
|
1967
|
+
// identity block is injected once by the plugin
|
|
1968
|
+
expect(parsed.system[1].text).toBe("You are Claude Code, Anthropic's official CLI for Claude.");
|
|
1969
|
+
// original block is compacted and redacted
|
|
1970
|
+
expect(parsed.system[2].text.startsWith("You are Claude Code, Anthropic's official CLI for Claude.")).toBe(false);
|
|
1971
|
+
expect(parsed.system[2].text).toContain("Use Claude Code defaults");
|
|
1972
|
+
expect(parsed.system[2].text).not.toMatch(/opencode/i);
|
|
1973
|
+
expect(parsed.system[2].text).not.toContain("\n\n\n");
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
it("does not inject billing block when CLAUDE_CODE_ATTRIBUTION_HEADER=0", async () => {
|
|
1977
|
+
process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = "0";
|
|
1978
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1979
|
+
|
|
1980
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
1981
|
+
method: "POST",
|
|
1982
|
+
headers: { "content-type": "application/json" },
|
|
1983
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "hello world" }], system: [] }),
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
1987
|
+
const parsed = JSON.parse(init.body);
|
|
1988
|
+
expect(parsed.system[0]).toEqual({
|
|
1989
|
+
type: "text",
|
|
1990
|
+
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
|
1991
|
+
cache_control: { type: "ephemeral" },
|
|
1992
|
+
});
|
|
1993
|
+
expect(parsed.system.some((item) => item.text.startsWith("x-anthropic-billing-header:"))).toBe(false);
|
|
1994
|
+
});
|
|
1995
|
+
|
|
1996
|
+
it("adds metadata.user_id to request body", async () => {
|
|
1997
|
+
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
1998
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
1999
|
+
|
|
2000
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
2001
|
+
method: "POST",
|
|
2002
|
+
headers: { "content-type": "application/json" },
|
|
2003
|
+
body: JSON.stringify({
|
|
2004
|
+
model: "claude-sonnet-4",
|
|
2005
|
+
messages: [{ role: "user", content: "hello" }],
|
|
2006
|
+
system: [],
|
|
2007
|
+
}),
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
2011
|
+
const parsed = JSON.parse(init.body);
|
|
2012
|
+
expect(parsed.metadata.user_id).toMatch(
|
|
2013
|
+
/^user_test-signature-user_account_[^_]+_session_[0-9a-f]{8}-[0-9a-f-]{27}$/,
|
|
2014
|
+
);
|
|
2015
|
+
expect(parsed.betas).toBeUndefined();
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
it("removes unsupported top-level betas field from request body", async () => {
|
|
2019
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2020
|
+
|
|
2021
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
2022
|
+
method: "POST",
|
|
2023
|
+
headers: { "content-type": "application/json" },
|
|
2024
|
+
body: JSON.stringify({ messages: [], betas: ["foo-beta"] }),
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
2028
|
+
const parsed = JSON.parse(init.body);
|
|
2029
|
+
expect(parsed.betas).toBeUndefined();
|
|
2030
|
+
});
|
|
2031
|
+
|
|
2032
|
+
it("uses ANTHROPIC_AUTH_TOKEN when provided", async () => {
|
|
2033
|
+
process.env.ANTHROPIC_AUTH_TOKEN = "manual-override-token";
|
|
2034
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2035
|
+
|
|
2036
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
2037
|
+
method: "POST",
|
|
2038
|
+
headers: { "content-type": "application/json" },
|
|
2039
|
+
body: JSON.stringify({ messages: [] }),
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
2043
|
+
expect(init.headers.get("authorization")).toBe("Bearer manual-override-token");
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
it("builds user-agent with agent sdk suffixes", async () => {
|
|
2047
|
+
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
2048
|
+
process.env.CLAUDE_AGENT_SDK_VERSION = "1.2.3";
|
|
2049
|
+
process.env.CLAUDE_AGENT_SDK_CLIENT_APP = "my-app";
|
|
2050
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2051
|
+
|
|
2052
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
2053
|
+
method: "POST",
|
|
2054
|
+
headers: { "content-type": "application/json" },
|
|
2055
|
+
body: JSON.stringify({ messages: [] }),
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
2059
|
+
const ua = init.headers.get("user-agent");
|
|
2060
|
+
expect(ua).toContain("(external, cli, agent-sdk/1.2.3, client-app/my-app)");
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
it("adds remote and custom headers from environment", async () => {
|
|
2064
|
+
process.env.ANTHROPIC_CUSTOM_HEADERS = "X-Extra-One: value1\nX-Extra-Two: value2";
|
|
2065
|
+
process.env.CLAUDE_CODE_CONTAINER_ID = "container-123";
|
|
2066
|
+
process.env.CLAUDE_CODE_REMOTE_SESSION_ID = "session-456";
|
|
2067
|
+
process.env.CLAUDE_AGENT_SDK_CLIENT_APP = "my-app";
|
|
2068
|
+
process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION = "1";
|
|
2069
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2070
|
+
|
|
2071
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
2072
|
+
method: "POST",
|
|
2073
|
+
headers: { "content-type": "application/json" },
|
|
2074
|
+
body: JSON.stringify({ messages: [] }),
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
2078
|
+
expect(init.headers.get("x-extra-one")).toBe("value1");
|
|
2079
|
+
expect(init.headers.get("x-extra-two")).toBe("value2");
|
|
2080
|
+
expect(init.headers.get("x-claude-remote-container-id")).toBe("container-123");
|
|
2081
|
+
expect(init.headers.get("x-claude-remote-session-id")).toBe("session-456");
|
|
2082
|
+
expect(init.headers.get("x-client-app")).toBe("my-app");
|
|
2083
|
+
expect(init.headers.get("x-anthropic-additional-protection")).toBe("true");
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
it("logs transformed system prompt when debug env is enabled", async () => {
|
|
2087
|
+
process.env.OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT = "1";
|
|
2088
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
2089
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2090
|
+
|
|
2091
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
2092
|
+
method: "POST",
|
|
2093
|
+
headers: { "content-type": "application/json" },
|
|
2094
|
+
body: JSON.stringify({
|
|
2095
|
+
messages: [{ role: "user", content: "hello" }],
|
|
2096
|
+
system: [{ type: "text", text: "Use OpenCode defaults" }],
|
|
2097
|
+
}),
|
|
2098
|
+
});
|
|
2099
|
+
|
|
2100
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
2101
|
+
"[opencode-anthropic-auth][system-debug] transformed system:",
|
|
2102
|
+
expect.stringContaining("Use Claude Code defaults"),
|
|
2103
|
+
);
|
|
2104
|
+
consoleSpy.mockRestore();
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
it("does not log title-generator system prompt when debug env is enabled", async () => {
|
|
2108
|
+
process.env.OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT = "1";
|
|
2109
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
2110
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2111
|
+
|
|
2112
|
+
await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
2113
|
+
method: "POST",
|
|
2114
|
+
headers: { "content-type": "application/json" },
|
|
2115
|
+
body: JSON.stringify({
|
|
2116
|
+
messages: [{ role: "user", content: "test" }],
|
|
2117
|
+
system: [
|
|
2118
|
+
{
|
|
2119
|
+
type: "text",
|
|
2120
|
+
text: "You are a title generator. You output ONLY a thread title. Nothing else.",
|
|
2121
|
+
},
|
|
2122
|
+
],
|
|
2123
|
+
}),
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
const hadSystemDebugLog = consoleSpy.mock.calls.some(
|
|
2127
|
+
(call) => call[0] === "[opencode-anthropic-auth][system-debug] transformed system:",
|
|
2128
|
+
);
|
|
2129
|
+
expect(hadSystemDebugLog).toBe(false);
|
|
2130
|
+
consoleSpy.mockRestore();
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
it("filters unsupported betas on bedrock endpoints", async () => {
|
|
2134
|
+
process.env.TENGU_SCARF_COFFEE = "1";
|
|
2135
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2136
|
+
|
|
2137
|
+
await fetchFn("https://bedrock-runtime.us-east-1.amazonaws.com/v1/messages", {
|
|
2138
|
+
method: "POST",
|
|
2139
|
+
headers: { "content-type": "application/json" },
|
|
2140
|
+
body: JSON.stringify({ model: "claude-sonnet-4-1m", messages: [] }),
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
2144
|
+
const betaHeader = init.headers.get("anthropic-beta");
|
|
2145
|
+
expect(betaHeader).not.toContain("context-1m-2025-08-07");
|
|
2146
|
+
expect(betaHeader).not.toContain("tool-examples-2025-10-29");
|
|
2147
|
+
expect(betaHeader).toContain("claude-code-20250219");
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
it("extracts headers from Request object input", async () => {
|
|
2151
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2152
|
+
|
|
2153
|
+
const request = new Request("https://api.anthropic.com/v1/messages", {
|
|
2154
|
+
method: "POST",
|
|
2155
|
+
headers: {
|
|
2156
|
+
"content-type": "application/json",
|
|
2157
|
+
"x-custom-header": "custom-value",
|
|
2158
|
+
},
|
|
2159
|
+
body: JSON.stringify({ messages: [] }),
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
await fetchFn(request, {});
|
|
2163
|
+
|
|
2164
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
2165
|
+
// The custom header from the Request should be preserved
|
|
2166
|
+
expect(init.headers.get("x-custom-header")).toBe("custom-value");
|
|
2167
|
+
// Auth headers should still be set
|
|
2168
|
+
expect(init.headers.get("authorization")).toBe("Bearer test-access");
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
it("does NOT add ?beta=true to non-/v1/messages URLs", async () => {
|
|
2172
|
+
mockFetch.mockResolvedValueOnce(new Response("", { status: 200 }));
|
|
2173
|
+
|
|
2174
|
+
await fetchFn("https://api.anthropic.com/v1/complete", {
|
|
2175
|
+
method: "POST",
|
|
2176
|
+
body: JSON.stringify({ prompt: "Hello" }),
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
const [input] = mockFetch.mock.calls[0];
|
|
2180
|
+
const url = input instanceof URL ? input : new URL(input.toString());
|
|
2181
|
+
expect(url.searchParams.has("beta")).toBe(false);
|
|
2182
|
+
expect(url.pathname).toBe("/v1/complete");
|
|
2183
|
+
});
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
// ---------------------------------------------------------------------------
|
|
2187
|
+
// markSuccess wiring
|
|
2188
|
+
// ---------------------------------------------------------------------------
|
|
2189
|
+
|
|
2190
|
+
describe("markSuccess wiring", () => {
|
|
2191
|
+
it("resets failure tracking on successful 200 response", async () => {
|
|
2192
|
+
vi.resetAllMocks();
|
|
2193
|
+
const client = makeClient();
|
|
2194
|
+
|
|
2195
|
+
// Account with prior failures
|
|
2196
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ consecutiveFailures: 3, lastFailureTime: Date.now() - 5000 }]));
|
|
2197
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
2198
|
+
|
|
2199
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2200
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2201
|
+
type: "oauth",
|
|
2202
|
+
refresh: "refresh-1",
|
|
2203
|
+
access: "access-1",
|
|
2204
|
+
expires: Date.now() + 3600_000,
|
|
2205
|
+
});
|
|
2206
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
2207
|
+
|
|
2208
|
+
// Successful API call
|
|
2209
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
2210
|
+
|
|
2211
|
+
const response = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2212
|
+
method: "POST",
|
|
2213
|
+
body: JSON.stringify({ messages: [] }),
|
|
2214
|
+
});
|
|
2215
|
+
|
|
2216
|
+
expect(response.status).toBe(200);
|
|
2217
|
+
|
|
2218
|
+
// Verify via a debounced save that the account's failures were reset.
|
|
2219
|
+
// We can't directly inspect the AccountManager, but we can trigger a save
|
|
2220
|
+
// and check what was persisted. The markSuccess resets consecutiveFailures
|
|
2221
|
+
// and lastFailureTime, which will be reflected in the next save.
|
|
2222
|
+
// For now, just verify the response came back successfully — the unit tests
|
|
2223
|
+
// in accounts.test.mjs verify markSuccess behavior directly.
|
|
2224
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
it("records token usage from streaming response", async () => {
|
|
2228
|
+
vi.resetAllMocks();
|
|
2229
|
+
const client = makeClient();
|
|
2230
|
+
const fetchFn = await setupFetchFn(client);
|
|
2231
|
+
|
|
2232
|
+
// Build an SSE stream with message_start and message_delta usage events
|
|
2233
|
+
const sseBody = [
|
|
2234
|
+
"event: message_start",
|
|
2235
|
+
'data: {"type":"message_start","message":{"usage":{"input_tokens":25,"output_tokens":1,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}}',
|
|
2236
|
+
"",
|
|
2237
|
+
"event: content_block_delta",
|
|
2238
|
+
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}',
|
|
2239
|
+
"",
|
|
2240
|
+
"event: message_delta",
|
|
2241
|
+
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":25,"output_tokens":42,"cache_read_input_tokens":10,"cache_creation_input_tokens":5}}',
|
|
2242
|
+
"",
|
|
2243
|
+
"event: message_stop",
|
|
2244
|
+
'data: {"type":"message_stop"}',
|
|
2245
|
+
"",
|
|
2246
|
+
].join("\n");
|
|
2247
|
+
|
|
2248
|
+
mockFetch.mockResolvedValueOnce(
|
|
2249
|
+
new Response(sseBody, {
|
|
2250
|
+
status: 200,
|
|
2251
|
+
headers: { "content-type": "text/event-stream" },
|
|
2252
|
+
}),
|
|
2253
|
+
);
|
|
2254
|
+
|
|
2255
|
+
const response = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
2256
|
+
method: "POST",
|
|
2257
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
|
|
2258
|
+
});
|
|
2259
|
+
|
|
2260
|
+
expect(response.status).toBe(200);
|
|
2261
|
+
|
|
2262
|
+
// Consume the stream so the onUsage callback fires
|
|
2263
|
+
const text = await response.text();
|
|
2264
|
+
expect(text).toContain("Hello");
|
|
2265
|
+
|
|
2266
|
+
// The recordUsage triggers a debounced save; flush it
|
|
2267
|
+
vi.useFakeTimers();
|
|
2268
|
+
vi.advanceTimersByTime(1500);
|
|
2269
|
+
vi.useRealTimers();
|
|
2270
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2271
|
+
|
|
2272
|
+
// Verify usage was recorded: the stats should reflect the message_delta usage
|
|
2273
|
+
const saveCalls = saveAccounts.mock.calls;
|
|
2274
|
+
expect(saveCalls.length).toBeGreaterThan(0);
|
|
2275
|
+
const lastSave = saveCalls[saveCalls.length - 1][0];
|
|
2276
|
+
const acc = lastSave.accounts[0];
|
|
2277
|
+
expect(acc.stats.requests).toBeGreaterThan(0);
|
|
2278
|
+
expect(acc.stats.outputTokens).toBe(42);
|
|
2279
|
+
expect(acc.stats.inputTokens).toBe(25);
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
it("detects whitespace-formatted mid-stream error events and switches account on next request", async () => {
|
|
2283
|
+
vi.resetAllMocks();
|
|
2284
|
+
const client = makeClient();
|
|
2285
|
+
|
|
2286
|
+
loadAccounts.mockResolvedValue(
|
|
2287
|
+
makeAccountsData([
|
|
2288
|
+
{ access: "access-1", expires: Date.now() + 3600_000 },
|
|
2289
|
+
{ access: "access-2", expires: Date.now() + 3600_000 },
|
|
2290
|
+
]),
|
|
2291
|
+
);
|
|
2292
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
2293
|
+
|
|
2294
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2295
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2296
|
+
type: "oauth",
|
|
2297
|
+
refresh: "refresh-1",
|
|
2298
|
+
access: "access-1",
|
|
2299
|
+
expires: Date.now() + 3600_000,
|
|
2300
|
+
});
|
|
2301
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
2302
|
+
|
|
2303
|
+
const sseBody = [
|
|
2304
|
+
"event: error",
|
|
2305
|
+
'data: { "type": "error", "error": { "type": "rate_limit_error", "message": "Rate limit exceeded" } }',
|
|
2306
|
+
"",
|
|
2307
|
+
].join("\n");
|
|
2308
|
+
|
|
2309
|
+
mockFetch.mockResolvedValueOnce(
|
|
2310
|
+
new Response(sseBody, {
|
|
2311
|
+
status: 200,
|
|
2312
|
+
headers: { "content-type": "text/event-stream" },
|
|
2313
|
+
}),
|
|
2314
|
+
);
|
|
2315
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
2316
|
+
|
|
2317
|
+
const first = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2318
|
+
method: "POST",
|
|
2319
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
|
|
2320
|
+
});
|
|
2321
|
+
await first.text();
|
|
2322
|
+
|
|
2323
|
+
const second = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2324
|
+
method: "POST",
|
|
2325
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Again" }] }),
|
|
2326
|
+
});
|
|
2327
|
+
|
|
2328
|
+
expect(second.status).toBe(200);
|
|
2329
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
2330
|
+
const secondHeaders = mockFetch.mock.calls[1][1].headers;
|
|
2331
|
+
expect(secondHeaders.get("authorization")).toBe("Bearer access-2");
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
it("detects chunk-split mid-stream error events and switches account on next request", async () => {
|
|
2335
|
+
vi.resetAllMocks();
|
|
2336
|
+
const client = makeClient();
|
|
2337
|
+
|
|
2338
|
+
loadAccounts.mockResolvedValue(
|
|
2339
|
+
makeAccountsData([
|
|
2340
|
+
{ access: "access-1", expires: Date.now() + 3600_000 },
|
|
2341
|
+
{ access: "access-2", expires: Date.now() + 3600_000 },
|
|
2342
|
+
]),
|
|
2343
|
+
);
|
|
2344
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
2345
|
+
|
|
2346
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2347
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2348
|
+
type: "oauth",
|
|
2349
|
+
refresh: "refresh-1",
|
|
2350
|
+
access: "access-1",
|
|
2351
|
+
expires: Date.now() + 3600_000,
|
|
2352
|
+
});
|
|
2353
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
2354
|
+
|
|
2355
|
+
const encoder = new TextEncoder();
|
|
2356
|
+
const splitStream = new ReadableStream({
|
|
2357
|
+
start(controller) {
|
|
2358
|
+
controller.enqueue(encoder.encode("event: error\n"));
|
|
2359
|
+
controller.enqueue(encoder.encode('data: {"type":"error","error":{"type":"rate_'));
|
|
2360
|
+
controller.enqueue(encoder.encode('limit_error","message":"Rate limit exceeded"}}\n'));
|
|
2361
|
+
controller.enqueue(encoder.encode("\n"));
|
|
2362
|
+
controller.close();
|
|
2363
|
+
},
|
|
2364
|
+
});
|
|
2365
|
+
|
|
2366
|
+
mockFetch.mockResolvedValueOnce(
|
|
2367
|
+
new Response(splitStream, {
|
|
2368
|
+
status: 200,
|
|
2369
|
+
headers: { "content-type": "text/event-stream" },
|
|
2370
|
+
}),
|
|
2371
|
+
);
|
|
2372
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
2373
|
+
|
|
2374
|
+
const first = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2375
|
+
method: "POST",
|
|
2376
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
|
|
2377
|
+
});
|
|
2378
|
+
await first.text();
|
|
2379
|
+
|
|
2380
|
+
const second = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2381
|
+
method: "POST",
|
|
2382
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Again" }] }),
|
|
2383
|
+
});
|
|
2384
|
+
|
|
2385
|
+
expect(second.status).toBe(200);
|
|
2386
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
2387
|
+
const secondHeaders = mockFetch.mock.calls[1][1].headers;
|
|
2388
|
+
expect(secondHeaders.get("authorization")).toBe("Bearer access-2");
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
it("does not switch account on mid-stream service-wide overloaded error", async () => {
|
|
2392
|
+
vi.resetAllMocks();
|
|
2393
|
+
const client = makeClient();
|
|
2394
|
+
|
|
2395
|
+
loadAccounts.mockResolvedValue(
|
|
2396
|
+
makeAccountsData([
|
|
2397
|
+
{ access: "access-1", expires: Date.now() + 3600_000 },
|
|
2398
|
+
{ access: "access-2", expires: Date.now() + 3600_000 },
|
|
2399
|
+
]),
|
|
2400
|
+
);
|
|
2401
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
2402
|
+
|
|
2403
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2404
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2405
|
+
type: "oauth",
|
|
2406
|
+
refresh: "refresh-1",
|
|
2407
|
+
access: "access-1",
|
|
2408
|
+
expires: Date.now() + 3600_000,
|
|
2409
|
+
});
|
|
2410
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
2411
|
+
|
|
2412
|
+
const sseBody = [
|
|
2413
|
+
"event: error",
|
|
2414
|
+
'data: {"type":"error","error":{"type":"overloaded_error","message":"Server overloaded"}}',
|
|
2415
|
+
"",
|
|
2416
|
+
].join("\n");
|
|
2417
|
+
|
|
2418
|
+
mockFetch.mockResolvedValueOnce(
|
|
2419
|
+
new Response(sseBody, {
|
|
2420
|
+
status: 200,
|
|
2421
|
+
headers: { "content-type": "text/event-stream" },
|
|
2422
|
+
}),
|
|
2423
|
+
);
|
|
2424
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
2425
|
+
|
|
2426
|
+
const first = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2427
|
+
method: "POST",
|
|
2428
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
|
|
2429
|
+
});
|
|
2430
|
+
await first.text();
|
|
2431
|
+
|
|
2432
|
+
const second = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2433
|
+
method: "POST",
|
|
2434
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Again" }] }),
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
expect(second.status).toBe(200);
|
|
2438
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
2439
|
+
const secondHeaders = mockFetch.mock.calls[1][1].headers;
|
|
2440
|
+
expect(secondHeaders.get("authorization")).toBe("Bearer access-1");
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
it("ignores SSE-like payloads when response is not text/event-stream", async () => {
|
|
2444
|
+
vi.resetAllMocks();
|
|
2445
|
+
const client = makeClient();
|
|
2446
|
+
|
|
2447
|
+
loadAccounts.mockResolvedValue(
|
|
2448
|
+
makeAccountsData([
|
|
2449
|
+
{ access: "access-1", expires: Date.now() + 3600_000 },
|
|
2450
|
+
{ access: "access-2", expires: Date.now() + 3600_000 },
|
|
2451
|
+
]),
|
|
2452
|
+
);
|
|
2453
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
2454
|
+
|
|
2455
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2456
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2457
|
+
type: "oauth",
|
|
2458
|
+
refresh: "refresh-1",
|
|
2459
|
+
access: "access-1",
|
|
2460
|
+
expires: Date.now() + 3600_000,
|
|
2461
|
+
});
|
|
2462
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
2463
|
+
|
|
2464
|
+
const sseLikeJsonPayload = [
|
|
2465
|
+
"event: error",
|
|
2466
|
+
'data: {"type":"error","error":{"type":"rate_limit_error","message":"Rate limit exceeded"}}',
|
|
2467
|
+
"",
|
|
2468
|
+
].join("\n");
|
|
2469
|
+
|
|
2470
|
+
mockFetch.mockResolvedValueOnce(
|
|
2471
|
+
new Response(sseLikeJsonPayload, {
|
|
2472
|
+
status: 200,
|
|
2473
|
+
headers: { "content-type": "application/json" },
|
|
2474
|
+
}),
|
|
2475
|
+
);
|
|
2476
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
2477
|
+
|
|
2478
|
+
const first = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2479
|
+
method: "POST",
|
|
2480
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
|
|
2481
|
+
});
|
|
2482
|
+
await first.text();
|
|
2483
|
+
|
|
2484
|
+
const second = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2485
|
+
method: "POST",
|
|
2486
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Again" }] }),
|
|
2487
|
+
});
|
|
2488
|
+
|
|
2489
|
+
expect(second.status).toBe(200);
|
|
2490
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
2491
|
+
|
|
2492
|
+
// Should still use account 1 — non-SSE payload must not trigger account failover.
|
|
2493
|
+
const secondHeaders = mockFetch.mock.calls[1][1].headers;
|
|
2494
|
+
expect(secondHeaders.get("authorization")).toBe("Bearer access-1");
|
|
2495
|
+
});
|
|
2496
|
+
|
|
2497
|
+
it("clears token and refreshes after mid-stream auth error cooldown", async () => {
|
|
2498
|
+
vi.useFakeTimers();
|
|
2499
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
2500
|
+
|
|
2501
|
+
try {
|
|
2502
|
+
vi.resetAllMocks();
|
|
2503
|
+
const client = makeClient();
|
|
2504
|
+
|
|
2505
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ access: "stale-access", expires: Date.now() + 3600_000 }]));
|
|
2506
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
2507
|
+
|
|
2508
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2509
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2510
|
+
type: "oauth",
|
|
2511
|
+
refresh: "refresh-1",
|
|
2512
|
+
access: "stale-access",
|
|
2513
|
+
expires: Date.now() + 3600_000,
|
|
2514
|
+
});
|
|
2515
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
2516
|
+
|
|
2517
|
+
const sseBody = [
|
|
2518
|
+
"event: error",
|
|
2519
|
+
'data: {"type":"error","error":{"type":"authentication_error","message":"Unauthorized"}}',
|
|
2520
|
+
"",
|
|
2521
|
+
].join("\n");
|
|
2522
|
+
|
|
2523
|
+
mockFetch.mockResolvedValueOnce(
|
|
2524
|
+
new Response(sseBody, {
|
|
2525
|
+
status: 200,
|
|
2526
|
+
headers: { "content-type": "text/event-stream" },
|
|
2527
|
+
}),
|
|
2528
|
+
);
|
|
2529
|
+
|
|
2530
|
+
const first = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2531
|
+
method: "POST",
|
|
2532
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
|
|
2533
|
+
});
|
|
2534
|
+
await first.text();
|
|
2535
|
+
|
|
2536
|
+
// AUTH_FAILED backoff is short (5s).
|
|
2537
|
+
vi.advanceTimersByTime(6_000);
|
|
2538
|
+
|
|
2539
|
+
// Next request should refresh token first.
|
|
2540
|
+
mockFetch.mockResolvedValueOnce(mockTokenRefresh("fresh-access", "refresh-1"));
|
|
2541
|
+
mockFetch.mockResolvedValueOnce(new Response('{"content":[]}', { status: 200 }));
|
|
2542
|
+
|
|
2543
|
+
const second = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2544
|
+
method: "POST",
|
|
2545
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Again" }] }),
|
|
2546
|
+
});
|
|
2547
|
+
|
|
2548
|
+
expect(second.status).toBe(200);
|
|
2549
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
2550
|
+
|
|
2551
|
+
const refreshCallUrl = String(mockFetch.mock.calls[1][0]);
|
|
2552
|
+
expect(refreshCallUrl).toContain("/v1/oauth/token");
|
|
2553
|
+
} finally {
|
|
2554
|
+
vi.useRealTimers();
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
it("applies quota-style cooldown for mid-stream billing errors", async () => {
|
|
2559
|
+
vi.useFakeTimers();
|
|
2560
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
2561
|
+
|
|
2562
|
+
try {
|
|
2563
|
+
vi.resetAllMocks();
|
|
2564
|
+
const client = makeClient();
|
|
2565
|
+
|
|
2566
|
+
loadAccounts.mockResolvedValue(makeAccountsData([{ access: "access-1", expires: Date.now() + 3600_000 }]));
|
|
2567
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
2568
|
+
|
|
2569
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2570
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2571
|
+
type: "oauth",
|
|
2572
|
+
refresh: "refresh-1",
|
|
2573
|
+
access: "access-1",
|
|
2574
|
+
expires: Date.now() + 3600_000,
|
|
2575
|
+
});
|
|
2576
|
+
const result = await plugin.auth.loader(getAuth, makeProvider());
|
|
2577
|
+
|
|
2578
|
+
const sseBody = [
|
|
2579
|
+
"event: error",
|
|
2580
|
+
'data: {"type":"error","error":{"type":"invalid_request_error","message":"Your credit balance is too low"}}',
|
|
2581
|
+
"",
|
|
2582
|
+
].join("\n");
|
|
2583
|
+
|
|
2584
|
+
mockFetch.mockResolvedValueOnce(
|
|
2585
|
+
new Response(sseBody, {
|
|
2586
|
+
status: 200,
|
|
2587
|
+
headers: { "content-type": "text/event-stream" },
|
|
2588
|
+
}),
|
|
2589
|
+
);
|
|
2590
|
+
|
|
2591
|
+
const first = await result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2592
|
+
method: "POST",
|
|
2593
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Hi" }] }),
|
|
2594
|
+
});
|
|
2595
|
+
await first.text();
|
|
2596
|
+
|
|
2597
|
+
// QUOTA_EXHAUSTED first tier is 60s, so at 35s this account is still blocked.
|
|
2598
|
+
vi.advanceTimersByTime(35_000);
|
|
2599
|
+
|
|
2600
|
+
await expect(
|
|
2601
|
+
result.fetch("https://api.anthropic.com/v1/messages", {
|
|
2602
|
+
method: "POST",
|
|
2603
|
+
body: JSON.stringify({ messages: [{ role: "user", content: "Again" }] }),
|
|
2604
|
+
}),
|
|
2605
|
+
).rejects.toThrow(/No available Anthropic account|All accounts exhausted/);
|
|
2606
|
+
|
|
2607
|
+
// Should fail before issuing another upstream fetch.
|
|
2608
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
2609
|
+
} finally {
|
|
2610
|
+
vi.useRealTimers();
|
|
2611
|
+
}
|
|
2612
|
+
});
|
|
2613
|
+
});
|
|
2614
|
+
|
|
2615
|
+
// ---------------------------------------------------------------------------
|
|
2616
|
+
// override_model_limits — uses setupFetchFn which wires auth.loader via makeProvider()
|
|
2617
|
+
// ---------------------------------------------------------------------------
|
|
2618
|
+
describe("override_model_limits", () => {
|
|
2619
|
+
let client;
|
|
2620
|
+
|
|
2621
|
+
beforeEach(async () => {
|
|
2622
|
+
client = makeClient();
|
|
2623
|
+
loadAccounts.mockResolvedValue(makeAccountsData());
|
|
2624
|
+
saveAccounts.mockResolvedValue(undefined);
|
|
2625
|
+
mockFetch.mockResolvedValue(new Response("", { status: 200 }));
|
|
2626
|
+
delete process.env.OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS;
|
|
2627
|
+
});
|
|
2628
|
+
|
|
2629
|
+
it("overrides context limit to 1M for 1M-window models by default (OAuth)", async () => {
|
|
2630
|
+
const provider = makeProvider();
|
|
2631
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2632
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2633
|
+
type: "oauth",
|
|
2634
|
+
refresh: "refresh-1",
|
|
2635
|
+
access: "access-1",
|
|
2636
|
+
expires: Date.now() + 3_600_000,
|
|
2637
|
+
});
|
|
2638
|
+
await plugin.auth.loader(getAuth, provider);
|
|
2639
|
+
|
|
2640
|
+
expect(provider.models["claude-opus-4-6"].limit.context).toBe(1_000_000);
|
|
2641
|
+
expect(provider.models["claude-sonnet-4-1m"].limit.context).toBe(1_000_000);
|
|
2642
|
+
// non-1M model untouched
|
|
2643
|
+
expect(provider.models["claude-sonnet"].limit.context).toBe(200_000);
|
|
2644
|
+
});
|
|
2645
|
+
|
|
2646
|
+
it("preserves original output limit when override_model_limits.output is 0 (default)", async () => {
|
|
2647
|
+
const provider = makeProvider();
|
|
2648
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2649
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2650
|
+
type: "oauth",
|
|
2651
|
+
refresh: "refresh-1",
|
|
2652
|
+
access: "access-1",
|
|
2653
|
+
expires: Date.now() + 3_600_000,
|
|
2654
|
+
});
|
|
2655
|
+
await plugin.auth.loader(getAuth, provider);
|
|
2656
|
+
|
|
2657
|
+
expect(provider.models["claude-opus-4-6"].limit.output).toBe(32_000);
|
|
2658
|
+
expect(provider.models["claude-sonnet-4-1m"].limit.output).toBe(16_000);
|
|
2659
|
+
});
|
|
2660
|
+
|
|
2661
|
+
it("does not modify limits for non-OAuth auth (API key path)", async () => {
|
|
2662
|
+
const provider = makeProvider();
|
|
2663
|
+
const plugin = await AnthropicAuthPlugin({ client });
|
|
2664
|
+
const getAuth = vi.fn().mockResolvedValue({ type: "api" });
|
|
2665
|
+
await plugin.auth.loader(getAuth, provider);
|
|
2666
|
+
|
|
2667
|
+
expect(provider.models["claude-opus-4-6"].limit.context).toBe(200_000);
|
|
2668
|
+
expect(provider.models["claude-sonnet-4-1m"].limit.context).toBe(200_000);
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
it("does not override limits when override_model_limits.enabled is false in config", async () => {
|
|
2672
|
+
const configModule = await import("./lib/config.mjs");
|
|
2673
|
+
configModule.loadConfig.mockReturnValueOnce({
|
|
2674
|
+
...configModule.loadConfig(),
|
|
2675
|
+
override_model_limits: { enabled: false, context: 1_000_000, output: 0 },
|
|
2676
|
+
});
|
|
2677
|
+
const provider = makeProvider();
|
|
2678
|
+
const pluginDisabled = await AnthropicAuthPlugin({ client });
|
|
2679
|
+
const getAuth = vi.fn().mockResolvedValue({
|
|
2680
|
+
type: "oauth",
|
|
2681
|
+
refresh: "refresh-1",
|
|
2682
|
+
access: "access-1",
|
|
2683
|
+
expires: Date.now() + 3_600_000,
|
|
2684
|
+
});
|
|
2685
|
+
await pluginDisabled.auth.loader(getAuth, provider);
|
|
2686
|
+
|
|
2687
|
+
expect(provider.models["claude-opus-4-6"].limit.context).toBe(200_000);
|
|
2688
|
+
expect(provider.models["claude-sonnet-4-1m"].limit.context).toBe(200_000);
|
|
2689
|
+
});
|
|
2690
|
+
});
|