opencode-anthropic-fix 0.0.25 → 0.0.27
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/LICENSE +648 -21
- package/README.md +41 -5
- package/index.mjs +4040 -2838
- package/lib/accounts.mjs +69 -11
- package/lib/accounts.test.mjs +22 -22
- package/lib/backoff.mjs +25 -0
- package/lib/config.mjs +66 -1
- package/lib/oauth.mjs +283 -77
- package/lib/oauth.test.mjs +120 -3
- package/lib/rotation.mjs +21 -1
- package/lib/rotation.test.mjs +3 -3
- package/lib/storage.mjs +34 -6
- package/lib/storage.test.mjs +14 -7
- package/package.json +3 -2
package/lib/oauth.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { CLIENT_ID } from "./config.mjs";
|
|
3
|
+
import { parseRetryAfterHeader, parseRetryAfterMsHeader } from "./backoff.mjs";
|
|
3
4
|
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// OAuth helpers — shared between plugin (index.mjs) and CLI (cli.mjs)
|
|
@@ -10,8 +11,27 @@ const OAUTH_MAX_HOST = "claude.ai";
|
|
|
10
11
|
const OAUTH_REDIRECT_URI = `https://${OAUTH_CONSOLE_HOST}/oauth/code/callback`;
|
|
11
12
|
const OAUTH_TOKEN_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/token`;
|
|
12
13
|
const OAUTH_REVOKE_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/revoke`;
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// Real Claude Code uses axios 1.13.6 for OAuth calls — axios sets User-Agent
|
|
15
|
+
// automatically and adds Accept header. We must match this fingerprint exactly.
|
|
16
|
+
// CLI version profile: 2.1.81 (for reference; not sent on OAuth calls)
|
|
17
|
+
const OAUTH_AXIOS_VERSION = "1.13.6";
|
|
18
|
+
const OAUTH_USER_AGENT = `axios/${OAUTH_AXIOS_VERSION}`;
|
|
19
|
+
const OAUTH_ACCEPT = "application/json, text/plain, */*";
|
|
20
|
+
const OAUTH_MAX_RETRIES = 2;
|
|
21
|
+
const OAUTH_MAX_RETRY_DELAY_MS = 30_000;
|
|
22
|
+
const OAUTH_RATE_LIMIT_COOLDOWN_MS = 30_000;
|
|
23
|
+
const OAUTH_RETRY_AFTER_SOURCE_HEADER_MS = "retry-after-ms";
|
|
24
|
+
const OAUTH_RETRY_AFTER_SOURCE_HEADER = "retry-after";
|
|
25
|
+
const OAUTH_RETRY_AFTER_SOURCE_FALLBACK_429 = "fallback-429";
|
|
26
|
+
|
|
27
|
+
export const CLAUDE_AI_SCOPES = [
|
|
28
|
+
"user:profile",
|
|
29
|
+
"user:inference",
|
|
30
|
+
"user:sessions:claude_code",
|
|
31
|
+
"user:mcp_servers",
|
|
32
|
+
"user:file_upload",
|
|
33
|
+
];
|
|
34
|
+
const CONSOLE_SCOPES = ["org:create_api_key", "user:profile"];
|
|
15
35
|
|
|
16
36
|
function base64url(input) {
|
|
17
37
|
return Buffer.from(input).toString("base64url");
|
|
@@ -20,13 +40,124 @@ function base64url(input) {
|
|
|
20
40
|
function generatePKCE() {
|
|
21
41
|
const verifier = base64url(randomBytes(32));
|
|
22
42
|
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
23
|
-
|
|
43
|
+
const state = base64url(randomBytes(32));
|
|
44
|
+
return { verifier, challenge, state };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {number} ms
|
|
49
|
+
* @returns {Promise<void>}
|
|
50
|
+
*/
|
|
51
|
+
function sleep(ms) {
|
|
52
|
+
if (ms <= 0) return Promise.resolve();
|
|
53
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {number} status
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
function isRetryableTokenStatus(status) {
|
|
61
|
+
return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Response} resp
|
|
66
|
+
* @param {number} attempt
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
function getRetryDelayMs(resp, attempt) {
|
|
70
|
+
if (!resp || !resp.headers || typeof resp.headers.get !== "function") {
|
|
71
|
+
return Math.min(1000 * 2 ** attempt, 8000);
|
|
72
|
+
}
|
|
73
|
+
const headerMs = parseRetryAfterMsHeader(resp);
|
|
74
|
+
const retryAfterMs = parseRetryAfterHeader(resp);
|
|
75
|
+
const fallback = Math.min(1000 * 2 ** attempt, 8000);
|
|
76
|
+
const resolved = headerMs ?? retryAfterMs ?? fallback;
|
|
77
|
+
return Math.max(250, Math.min(resolved, OAUTH_MAX_RETRY_DELAY_MS));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a caller-facing cooldown hint from Retry-After headers.
|
|
82
|
+
* Falls back to a conservative 30s cooldown for 429s.
|
|
83
|
+
* @param {Response | null | undefined} resp
|
|
84
|
+
* @param {number | undefined} status
|
|
85
|
+
* @returns {{ retryAfterMs: number | null, retryAfterSource?: string }}
|
|
86
|
+
*/
|
|
87
|
+
function getCooldownHint(resp, status) {
|
|
88
|
+
if (resp && resp.headers && typeof resp.headers.get === "function") {
|
|
89
|
+
const headerMs = parseRetryAfterMsHeader(resp);
|
|
90
|
+
if (Number.isFinite(headerMs) && headerMs > 0) {
|
|
91
|
+
return {
|
|
92
|
+
retryAfterMs: Math.max(250, Math.min(headerMs, OAUTH_MAX_RETRY_DELAY_MS)),
|
|
93
|
+
retryAfterSource: OAUTH_RETRY_AFTER_SOURCE_HEADER_MS,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const retryAfterMs = parseRetryAfterHeader(resp);
|
|
98
|
+
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
|
|
99
|
+
return {
|
|
100
|
+
retryAfterMs: Math.max(250, Math.min(retryAfterMs, OAUTH_MAX_RETRY_DELAY_MS)),
|
|
101
|
+
retryAfterSource: OAUTH_RETRY_AFTER_SOURCE_HEADER,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (status === 429) {
|
|
107
|
+
return {
|
|
108
|
+
retryAfterMs: OAUTH_RATE_LIMIT_COOLDOWN_MS,
|
|
109
|
+
retryAfterSource: OAUTH_RETRY_AFTER_SOURCE_FALLBACK_429,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { retryAfterMs: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} rawText
|
|
118
|
+
* @returns {{ errorCode?: string, reason?: string }}
|
|
119
|
+
*/
|
|
120
|
+
function parseOAuthErrorBody(rawText) {
|
|
121
|
+
if (!rawText) return {};
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(rawText);
|
|
125
|
+
let errorCode;
|
|
126
|
+
let reason;
|
|
127
|
+
|
|
128
|
+
if (typeof parsed.error === "string" && parsed.error) {
|
|
129
|
+
errorCode = parsed.error;
|
|
130
|
+
} else if (parsed.error && typeof parsed.error === "object") {
|
|
131
|
+
if (typeof parsed.error.type === "string" && parsed.error.type) {
|
|
132
|
+
errorCode = parsed.error.type;
|
|
133
|
+
}
|
|
134
|
+
if (typeof parsed.error.message === "string" && parsed.error.message) {
|
|
135
|
+
reason = parsed.error.message;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!reason) {
|
|
140
|
+
if (typeof parsed.error_description === "string" && parsed.error_description) {
|
|
141
|
+
reason = parsed.error_description;
|
|
142
|
+
} else if (typeof parsed.message === "string" && parsed.message) {
|
|
143
|
+
reason = parsed.message;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...(errorCode ? { errorCode } : {}),
|
|
149
|
+
...(reason ? { reason } : {}),
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
const trimmed = rawText.trim();
|
|
153
|
+
return trimmed ? { reason: trimmed } : {};
|
|
154
|
+
}
|
|
24
155
|
}
|
|
25
156
|
|
|
26
157
|
/**
|
|
27
158
|
* Build an OAuth authorization URL with PKCE challenge.
|
|
28
159
|
* @param {"max" | "console"} mode
|
|
29
|
-
* @returns {Promise<{url: string, verifier: string}>}
|
|
160
|
+
* @returns {Promise<{url: string, verifier: string, state: string}>}
|
|
30
161
|
*/
|
|
31
162
|
export async function authorize(mode) {
|
|
32
163
|
const pkce = generatePKCE();
|
|
@@ -36,13 +167,15 @@ export async function authorize(mode) {
|
|
|
36
167
|
url.searchParams.set("client_id", CLIENT_ID);
|
|
37
168
|
url.searchParams.set("response_type", "code");
|
|
38
169
|
url.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
|
|
39
|
-
|
|
170
|
+
const scopes = mode === "console" ? CONSOLE_SCOPES : CLAUDE_AI_SCOPES;
|
|
171
|
+
url.searchParams.set("scope", scopes.join(" "));
|
|
40
172
|
url.searchParams.set("code_challenge", pkce.challenge);
|
|
41
173
|
url.searchParams.set("code_challenge_method", "S256");
|
|
42
|
-
url.searchParams.set("state", pkce.
|
|
174
|
+
url.searchParams.set("state", pkce.state);
|
|
43
175
|
return {
|
|
44
176
|
url: url.toString(),
|
|
45
177
|
verifier: pkce.verifier,
|
|
178
|
+
state: pkce.state,
|
|
46
179
|
};
|
|
47
180
|
}
|
|
48
181
|
|
|
@@ -52,29 +185,24 @@ export async function authorize(mode) {
|
|
|
52
185
|
* @param {string} verifier
|
|
53
186
|
* @returns {Promise<
|
|
54
187
|
* | { type: "success", refresh: string, access: string, expires: number, email?: string }
|
|
55
|
-
* | {
|
|
188
|
+
* | {
|
|
189
|
+
* type: "failed",
|
|
190
|
+
* status?: number,
|
|
191
|
+
* code?: string,
|
|
192
|
+
* reason?: string,
|
|
193
|
+
* details?: string,
|
|
194
|
+
* retryAfterMs?: number,
|
|
195
|
+
* retryAfterSource?: "retry-after-ms" | "retry-after" | "fallback-429"
|
|
196
|
+
* }
|
|
56
197
|
* >}
|
|
57
198
|
*/
|
|
58
199
|
export async function exchange(code, verifier) {
|
|
59
|
-
const fail = (status, rawText = "") => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const parsed = JSON.parse(rawText);
|
|
66
|
-
if (typeof parsed.error === "string" && parsed.error) {
|
|
67
|
-
errorCode = parsed.error;
|
|
68
|
-
}
|
|
69
|
-
if (typeof parsed.error_description === "string" && parsed.error_description) {
|
|
70
|
-
reason = parsed.error_description;
|
|
71
|
-
} else if (typeof parsed.message === "string" && parsed.message) {
|
|
72
|
-
reason = parsed.message;
|
|
73
|
-
}
|
|
74
|
-
} catch {
|
|
75
|
-
reason = rawText.trim() || undefined;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
200
|
+
const fail = (status, rawText = "", cooldownHint = { retryAfterMs: null }) => {
|
|
201
|
+
const { errorCode, reason } = parseOAuthErrorBody(rawText);
|
|
202
|
+
const retryAfterMs =
|
|
203
|
+
Number.isFinite(cooldownHint?.retryAfterMs) && cooldownHint.retryAfterMs > 0
|
|
204
|
+
? Number(cooldownHint.retryAfterMs)
|
|
205
|
+
: null;
|
|
78
206
|
|
|
79
207
|
const detailsParts = [];
|
|
80
208
|
if (typeof status === "number") detailsParts.push(`HTTP ${status}`);
|
|
@@ -87,43 +215,76 @@ export async function exchange(code, verifier) {
|
|
|
87
215
|
...(errorCode ? { code: errorCode } : {}),
|
|
88
216
|
...(reason ? { reason } : {}),
|
|
89
217
|
...(detailsParts.length ? { details: detailsParts.join(" · ") } : {}),
|
|
218
|
+
...(retryAfterMs ? { retryAfterMs } : {}),
|
|
219
|
+
...(cooldownHint?.retryAfterSource ? { retryAfterSource: cooldownHint.retryAfterSource } : {}),
|
|
90
220
|
};
|
|
91
221
|
};
|
|
92
222
|
|
|
93
223
|
const splits = code.split("#");
|
|
94
224
|
let result;
|
|
225
|
+
for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
|
|
226
|
+
try {
|
|
227
|
+
result = await fetch(OAUTH_TOKEN_URL, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: {
|
|
230
|
+
Accept: OAUTH_ACCEPT,
|
|
231
|
+
"Content-Type": "application/json",
|
|
232
|
+
"User-Agent": OAUTH_USER_AGENT,
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
grant_type: "authorization_code",
|
|
236
|
+
code: splits[0],
|
|
237
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
238
|
+
client_id: CLIENT_ID,
|
|
239
|
+
code_verifier: verifier,
|
|
240
|
+
state: splits[1],
|
|
241
|
+
}),
|
|
242
|
+
signal: AbortSignal.timeout(15_000),
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (attempt < OAUTH_MAX_RETRIES) {
|
|
246
|
+
await sleep(Math.min(500 * 2 ** attempt, 2000));
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
return fail(undefined, err instanceof Error ? err.message : String(err));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!result.ok) {
|
|
253
|
+
const raw =
|
|
254
|
+
typeof result.text === "function"
|
|
255
|
+
? await result
|
|
256
|
+
.text()
|
|
257
|
+
.then((value) => (typeof value === "string" ? value : ""))
|
|
258
|
+
.catch(() => "")
|
|
259
|
+
: "";
|
|
260
|
+
|
|
261
|
+
if (attempt < OAUTH_MAX_RETRIES && isRetryableTokenStatus(result.status)) {
|
|
262
|
+
await sleep(getRetryDelayMs(result, attempt));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return fail(result.status, raw, getCooldownHint(result, result.status));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!result) {
|
|
273
|
+
return fail(undefined, "Token exchange request did not complete");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let json;
|
|
95
277
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
"Content-Type": "application/json",
|
|
100
|
-
"User-Agent": OAUTH_USER_AGENT,
|
|
101
|
-
},
|
|
102
|
-
body: JSON.stringify({
|
|
103
|
-
code: splits[0],
|
|
104
|
-
state: splits[1],
|
|
105
|
-
grant_type: "authorization_code",
|
|
106
|
-
client_id: CLIENT_ID,
|
|
107
|
-
redirect_uri: OAUTH_REDIRECT_URI,
|
|
108
|
-
code_verifier: verifier,
|
|
109
|
-
}),
|
|
110
|
-
});
|
|
111
|
-
} catch (err) {
|
|
112
|
-
return fail(undefined, err instanceof Error ? err.message : String(err));
|
|
278
|
+
json = await result.json();
|
|
279
|
+
} catch {
|
|
280
|
+
return fail(result.status, "Invalid JSON in token response");
|
|
113
281
|
}
|
|
114
282
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
? await result
|
|
119
|
-
.text()
|
|
120
|
-
.then((value) => (typeof value === "string" ? value : ""))
|
|
121
|
-
.catch(() => "")
|
|
122
|
-
: "";
|
|
123
|
-
return fail(result.status, raw);
|
|
283
|
+
// Validate required fields before returning success (QA fix C4)
|
|
284
|
+
if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
|
|
285
|
+
return fail(result.status, "Missing required fields in token response");
|
|
124
286
|
}
|
|
125
287
|
|
|
126
|
-
const json = await result.json();
|
|
127
288
|
return {
|
|
128
289
|
type: "success",
|
|
129
290
|
refresh: json.refresh_token,
|
|
@@ -148,6 +309,7 @@ export async function revoke(refreshToken) {
|
|
|
148
309
|
const resp = await fetch(OAUTH_REVOKE_URL, {
|
|
149
310
|
method: "POST",
|
|
150
311
|
headers: {
|
|
312
|
+
Accept: OAUTH_ACCEPT,
|
|
151
313
|
"Content-Type": "application/json",
|
|
152
314
|
"User-Agent": OAUTH_USER_AGENT,
|
|
153
315
|
},
|
|
@@ -172,32 +334,76 @@ export async function revoke(refreshToken) {
|
|
|
172
334
|
* @throws {Error} On HTTP errors or network failures
|
|
173
335
|
*/
|
|
174
336
|
export async function refreshToken(refreshTokenValue, options = {}) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
body: JSON.stringify({
|
|
182
|
-
grant_type: "refresh_token",
|
|
183
|
-
client_id: CLIENT_ID,
|
|
184
|
-
refresh_token: refreshTokenValue,
|
|
185
|
-
}),
|
|
186
|
-
...(options.signal ? { signal: options.signal } : {}),
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
if (!resp.ok) {
|
|
190
|
-
const text = await resp.text().catch(() => "");
|
|
191
|
-
const error = new Error(`Token refresh failed (HTTP ${resp.status}): ${text}`);
|
|
192
|
-
error.status = resp.status;
|
|
337
|
+
// QA fix M13: default 30s timeout to prevent hanging on unresponsive server
|
|
338
|
+
const signal = options.signal ?? AbortSignal.timeout(30_000);
|
|
339
|
+
let lastError = null;
|
|
340
|
+
|
|
341
|
+
for (let attempt = 0; attempt <= OAUTH_MAX_RETRIES; attempt++) {
|
|
342
|
+
let resp;
|
|
193
343
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
344
|
+
resp = await fetch(OAUTH_TOKEN_URL, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: {
|
|
347
|
+
Accept: OAUTH_ACCEPT,
|
|
348
|
+
"Content-Type": "application/json",
|
|
349
|
+
"User-Agent": OAUTH_USER_AGENT,
|
|
350
|
+
},
|
|
351
|
+
body: JSON.stringify({
|
|
352
|
+
grant_type: "refresh_token",
|
|
353
|
+
refresh_token: refreshTokenValue,
|
|
354
|
+
client_id: CLIENT_ID,
|
|
355
|
+
scope: options.scopes ? options.scopes.join(" ") : CLAUDE_AI_SCOPES.join(" "),
|
|
356
|
+
}),
|
|
357
|
+
signal,
|
|
358
|
+
});
|
|
359
|
+
} catch (err) {
|
|
360
|
+
lastError = err;
|
|
361
|
+
if (attempt < OAUTH_MAX_RETRIES) {
|
|
362
|
+
await sleep(Math.min(500 * 2 ** attempt, 2000));
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
throw err;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!resp.ok) {
|
|
369
|
+
const text = await resp.text().catch(() => "");
|
|
370
|
+
const retryAfterMs = getRetryDelayMs(resp, attempt);
|
|
371
|
+
|
|
372
|
+
if (attempt < OAUTH_MAX_RETRIES && isRetryableTokenStatus(resp.status)) {
|
|
373
|
+
await sleep(retryAfterMs);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const error = new Error(`Token refresh failed (HTTP ${resp.status}): ${text}`);
|
|
378
|
+
error.status = resp.status;
|
|
379
|
+
const cooldownHint = getCooldownHint(resp, resp.status);
|
|
380
|
+
error.retryAfterMs =
|
|
381
|
+
Number.isFinite(cooldownHint.retryAfterMs) && cooldownHint.retryAfterMs > 0
|
|
382
|
+
? cooldownHint.retryAfterMs
|
|
383
|
+
: retryAfterMs;
|
|
384
|
+
if (cooldownHint.retryAfterSource) {
|
|
385
|
+
error.retryAfterSource = cooldownHint.retryAfterSource;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const parsed = parseOAuthErrorBody(text);
|
|
389
|
+
if (parsed.errorCode) error.code = parsed.errorCode;
|
|
390
|
+
|
|
391
|
+
throw error;
|
|
198
392
|
}
|
|
199
|
-
|
|
393
|
+
|
|
394
|
+
return resp.json();
|
|
200
395
|
}
|
|
201
396
|
|
|
202
|
-
|
|
397
|
+
throw lastError instanceof Error ? lastError : new Error("Token refresh failed after retries");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Check if an account has all required Claude.ai scopes.
|
|
402
|
+
* @param {string[] | undefined} scopes
|
|
403
|
+
* @returns {boolean}
|
|
404
|
+
*/
|
|
405
|
+
export function hasRequiredScopes(scopes) {
|
|
406
|
+
if (!Array.isArray(scopes) || scopes.length === 0) return false;
|
|
407
|
+
const stored = new Set(scopes);
|
|
408
|
+
return CLAUDE_AI_SCOPES.every((s) => stored.has(s));
|
|
203
409
|
}
|
package/lib/oauth.test.mjs
CHANGED
|
@@ -23,7 +23,7 @@ describe("oauth headers", () => {
|
|
|
23
23
|
await exchange("code#state", "verifier");
|
|
24
24
|
|
|
25
25
|
const [, init] = mockFetch.mock.calls[0];
|
|
26
|
-
expect(init.headers["User-Agent"]).toBe("
|
|
26
|
+
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
it("sends Claude Code user-agent on token refresh", async () => {
|
|
@@ -35,7 +35,7 @@ describe("oauth headers", () => {
|
|
|
35
35
|
await refreshToken("refresh-token");
|
|
36
36
|
|
|
37
37
|
const [, init] = mockFetch.mock.calls[0];
|
|
38
|
-
expect(init.headers["User-Agent"]).toBe("
|
|
38
|
+
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it("sends Claude Code user-agent on token revoke", async () => {
|
|
@@ -44,6 +44,123 @@ describe("oauth headers", () => {
|
|
|
44
44
|
await revoke("refresh-token");
|
|
45
45
|
|
|
46
46
|
const [, init] = mockFetch.mock.calls[0];
|
|
47
|
-
expect(init.headers["User-Agent"]).toBe("
|
|
47
|
+
expect(init.headers["User-Agent"]).toBe("axios/1.13.6");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("parses Anthropic nested error payloads on exchange failure", async () => {
|
|
51
|
+
mockFetch.mockResolvedValueOnce({
|
|
52
|
+
ok: false,
|
|
53
|
+
status: 400,
|
|
54
|
+
headers: { get: () => null },
|
|
55
|
+
text: async () => JSON.stringify({ error: { type: "invalid_grant", message: "Bad auth code." } }),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await exchange("code#state", "verifier");
|
|
59
|
+
expect(result.type).toBe("failed");
|
|
60
|
+
expect(result.status).toBe(400);
|
|
61
|
+
expect(result.code).toBe("invalid_grant");
|
|
62
|
+
expect(result.reason).toBe("Bad auth code.");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("retries token exchange on 429 before succeeding", async () => {
|
|
66
|
+
mockFetch
|
|
67
|
+
.mockResolvedValueOnce({
|
|
68
|
+
ok: false,
|
|
69
|
+
status: 429,
|
|
70
|
+
headers: { get: (key) => (key === "retry-after-ms" ? "1" : null) },
|
|
71
|
+
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "retry" } }),
|
|
72
|
+
})
|
|
73
|
+
.mockResolvedValueOnce({
|
|
74
|
+
ok: true,
|
|
75
|
+
json: async () => ({
|
|
76
|
+
access_token: "access",
|
|
77
|
+
refresh_token: "refresh",
|
|
78
|
+
expires_in: 3600,
|
|
79
|
+
account: { email_address: "retry@example.com" },
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const result = await exchange("code#state", "verifier");
|
|
84
|
+
expect(result.type).toBe("success");
|
|
85
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("retries token refresh on 429 before succeeding", async () => {
|
|
89
|
+
mockFetch
|
|
90
|
+
.mockResolvedValueOnce({
|
|
91
|
+
ok: false,
|
|
92
|
+
status: 429,
|
|
93
|
+
headers: { get: (key) => (key === "retry-after-ms" ? "1" : null) },
|
|
94
|
+
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "retry" } }),
|
|
95
|
+
})
|
|
96
|
+
.mockResolvedValueOnce({
|
|
97
|
+
ok: true,
|
|
98
|
+
json: async () => ({ access_token: "a", refresh_token: "r", expires_in: 3600 }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = await refreshToken("refresh-token");
|
|
102
|
+
expect(result.access_token).toBe("a");
|
|
103
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("uses nested error type as refresh error code", async () => {
|
|
107
|
+
mockFetch.mockResolvedValueOnce({
|
|
108
|
+
ok: false,
|
|
109
|
+
status: 400,
|
|
110
|
+
headers: { get: () => null },
|
|
111
|
+
text: async () => JSON.stringify({ error: { type: "invalid_grant", message: "Bad refresh token." } }),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await expect(refreshToken("refresh-token")).rejects.toMatchObject({
|
|
115
|
+
status: 400,
|
|
116
|
+
code: "invalid_grant",
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns cooldown hint on exchange 429 without Retry-After headers", async () => {
|
|
121
|
+
vi.useFakeTimers();
|
|
122
|
+
try {
|
|
123
|
+
mockFetch.mockResolvedValue({
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 429,
|
|
126
|
+
headers: { get: () => null },
|
|
127
|
+
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limited" } }),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const promise = exchange("code#state", "verifier");
|
|
131
|
+
await vi.runAllTimersAsync();
|
|
132
|
+
const result = await promise;
|
|
133
|
+
|
|
134
|
+
expect(result).toMatchObject({
|
|
135
|
+
type: "failed",
|
|
136
|
+
status: 429,
|
|
137
|
+
code: "rate_limit_error",
|
|
138
|
+
retryAfterMs: 30_000,
|
|
139
|
+
});
|
|
140
|
+
} finally {
|
|
141
|
+
vi.useRealTimers();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("throws 30s cooldown hint on refresh 429 without Retry-After headers", async () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
try {
|
|
148
|
+
mockFetch.mockResolvedValue({
|
|
149
|
+
ok: false,
|
|
150
|
+
status: 429,
|
|
151
|
+
headers: { get: () => null },
|
|
152
|
+
text: async () => JSON.stringify({ error: { type: "rate_limit_error", message: "Rate limited" } }),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const assertion = expect(refreshToken("refresh-token")).rejects.toMatchObject({
|
|
156
|
+
status: 429,
|
|
157
|
+
code: "rate_limit_error",
|
|
158
|
+
retryAfterMs: 30_000,
|
|
159
|
+
});
|
|
160
|
+
await vi.runAllTimersAsync();
|
|
161
|
+
await assertion;
|
|
162
|
+
} finally {
|
|
163
|
+
vi.useRealTimers();
|
|
164
|
+
}
|
|
48
165
|
});
|
|
49
166
|
});
|
package/lib/rotation.mjs
CHANGED
|
@@ -72,6 +72,25 @@ export class HealthScoreTracker {
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Apply a direct penalty to an account's health score.
|
|
77
|
+
* @param {number} accountIndex
|
|
78
|
+
* @param {number} penalty - Points to deduct
|
|
79
|
+
*/
|
|
80
|
+
applyPenalty(accountIndex, penalty) {
|
|
81
|
+
const state = this.#scores.get(accountIndex);
|
|
82
|
+
if (!state) {
|
|
83
|
+
this.#scores.set(accountIndex, {
|
|
84
|
+
score: Math.max(0, this.#config.initial - penalty),
|
|
85
|
+
lastUpdated: Date.now(),
|
|
86
|
+
consecutiveFailures: 0,
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
state.score = Math.max(0, this.getScore(accountIndex) - penalty);
|
|
90
|
+
state.lastUpdated = Date.now();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
/**
|
|
76
95
|
* Record a general failure.
|
|
77
96
|
* @param {number} accountIndex
|
|
@@ -197,7 +216,8 @@ const SWITCH_THRESHOLD = 100;
|
|
|
197
216
|
function calculateHybridScore(account, maxTokens) {
|
|
198
217
|
const healthComponent = account.healthScore * 2;
|
|
199
218
|
const tokenComponent = (account.tokens / maxTokens) * 100 * 5;
|
|
200
|
-
|
|
219
|
+
// QA fix M17: lastUsed=0 means never-used; treat as 0 freshness bonus instead of max
|
|
220
|
+
const secondsSinceUsed = account.lastUsed > 0 ? (Date.now() - account.lastUsed) / 1000 : 0;
|
|
201
221
|
const freshnessComponent = Math.min(secondsSinceUsed, 3600) * 0.1;
|
|
202
222
|
|
|
203
223
|
return Math.max(0, healthComponent + tokenComponent + freshnessComponent);
|
package/lib/rotation.test.mjs
CHANGED
|
@@ -135,7 +135,7 @@ describe("TokenBucketTracker", () => {
|
|
|
135
135
|
tracker.consume(0);
|
|
136
136
|
tracker.consume(0);
|
|
137
137
|
tracker.refund(0);
|
|
138
|
-
expect(tracker.getTokens(0)).
|
|
138
|
+
expect(tracker.getTokens(0)).toBeCloseTo(49, 0);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
it("refund does not exceed max", () => {
|
|
@@ -290,9 +290,9 @@ describe("selectAccount", () => {
|
|
|
290
290
|
it("selects the account with highest composite score", () => {
|
|
291
291
|
const candidates = [
|
|
292
292
|
makeCandidate({ index: 0, lastUsed: Date.now() }),
|
|
293
|
-
makeCandidate({ index: 1, lastUsed:
|
|
293
|
+
makeCandidate({ index: 1, lastUsed: Date.now() - 120_000 }), // 2 min ago = more fresh
|
|
294
294
|
];
|
|
295
|
-
// Account 1 should have higher freshness score
|
|
295
|
+
// Account 1 should have higher freshness score (120s * 0.1 = 12pt bonus)
|
|
296
296
|
const result = selectAccount(candidates, "hybrid", null, healthTracker, tokenTracker, 0);
|
|
297
297
|
// Both have same health and tokens, but account 1 has more freshness
|
|
298
298
|
expect(result?.index).toBe(1);
|