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