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/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
- const OAUTH_CLI_VERSION_PROFILE = "2.1.79";
14
- const OAUTH_USER_AGENT = `claude-cli/${OAUTH_CLI_VERSION_PROFILE} (external, cli)`;
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
- return { verifier, challenge };
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
- url.searchParams.set("scope", "org:create_api_key user:profile user:inference");
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.verifier);
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
- * | { type: "failed", status?: number, code?: string, reason?: string, details?: string }
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
- let errorCode;
61
- let reason;
62
-
63
- if (rawText) {
64
- try {
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
- result = await fetch(OAUTH_TOKEN_URL, {
97
- method: "POST",
98
- headers: {
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
- if (!result.ok) {
116
- const raw =
117
- typeof result.text === "function"
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
- const resp = await fetch(OAUTH_TOKEN_URL, {
176
- method: "POST",
177
- headers: {
178
- "Content-Type": "application/json",
179
- "User-Agent": OAUTH_USER_AGENT,
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
- const parsed = JSON.parse(text);
195
- if (parsed.error) error.code = parsed.error;
196
- } catch {
197
- // Body may not be valid JSON
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
- throw error;
393
+
394
+ return resp.json();
200
395
  }
201
396
 
202
- return resp.json();
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
  }
@@ -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("claude-cli/2.1.79 (external, cli)");
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("claude-cli/2.1.79 (external, cli)");
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("claude-cli/2.1.79 (external, cli)");
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
- const secondsSinceUsed = (Date.now() - account.lastUsed) / 1000;
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);
@@ -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)).toBe(49);
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: 0 }), // Older = more fresh
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);