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/accounts.mjs CHANGED
@@ -1,7 +1,13 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { loadAccounts, saveAccounts, createDefaultStats } from "./storage.mjs";
2
3
  import { HealthScoreTracker, TokenBucketTracker, selectAccount } from "./rotation.mjs";
3
4
  import { calculateBackoffMs } from "./backoff.mjs";
4
5
 
6
+ /** QA fix M12: hash token fragment instead of leaking raw prefix */
7
+ function hashTokenFragment(token) {
8
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
9
+ }
10
+
5
11
  /**
6
12
  * @typedef {import('./config.mjs').AnthropicAuthConfig} AnthropicAuthConfig
7
13
  * @typedef {import('./config.mjs').AccountSelectionStrategy} AccountSelectionStrategy
@@ -92,7 +98,7 @@ export class AccountManager {
92
98
  // re-bootstrapping from OpenCode auth fallback credentials.
93
99
  if (stored) {
94
100
  manager.#accounts = stored.accounts.map((acc, index) => ({
95
- id: acc.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
101
+ id: acc.id || `${acc.addedAt}:${hashTokenFragment(acc.refreshToken)}`,
96
102
  index,
97
103
  email: acc.email,
98
104
  refreshToken: acc.refreshToken,
@@ -193,6 +199,15 @@ export class AccountManager {
193
199
  return this.#currentIndex;
194
200
  }
195
201
 
202
+ /**
203
+ * Get the health score for a specific account.
204
+ * @param {number} accountIndex
205
+ * @returns {number}
206
+ */
207
+ getHealthScore(accountIndex) {
208
+ return this.#healthTracker.getScore(accountIndex);
209
+ }
210
+
196
211
  /**
197
212
  * Force the active account to a specific index.
198
213
  * Used by OPENCODE_ANTHROPIC_INITIAL_ACCOUNT to pin a session to one account.
@@ -334,6 +349,36 @@ export class AccountManager {
334
349
  this.#tokenTracker.refund(account.index);
335
350
  }
336
351
 
352
+ /**
353
+ * Apply a utilization-based health penalty (proactive, before actual rate limit).
354
+ * @param {ManagedAccount} account
355
+ * @param {number} penalty - Points to deduct (0-10 typical)
356
+ */
357
+ applyUtilizationPenalty(account, penalty) {
358
+ if (penalty > 0) {
359
+ this.#healthTracker.applyPenalty(account.index, penalty);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Handle surpassed rate limit threshold — aggressive penalty and soft cooldown.
365
+ * @param {ManagedAccount} account
366
+ * @param {string | null} resetAt - ISO timestamp or null
367
+ */
368
+ applySurpassedThreshold(account, resetAt) {
369
+ // Apply a significant health penalty
370
+ this.#healthTracker.applyPenalty(account.index, 15);
371
+
372
+ // If reset time provided, set a soft cooldown
373
+ if (resetAt) {
374
+ const resetTime = new Date(resetAt).getTime();
375
+ if (!isNaN(resetTime) && resetTime > Date.now()) {
376
+ const SOFT_COOLDOWN_KEY = "soft_cooldown";
377
+ account.rateLimitResetTimes[SOFT_COOLDOWN_KEY] = resetTime;
378
+ }
379
+ }
380
+ }
381
+
337
382
  /**
338
383
  * Add a new account to the pool.
339
384
  * @param {string} refreshToken
@@ -359,7 +404,7 @@ export class AccountManager {
359
404
  const now = Date.now();
360
405
  /** @type {ManagedAccount} */
361
406
  const account = {
362
- id: `${now}:${refreshToken.slice(0, 12)}`,
407
+ id: `${now}:${hashTokenFragment(refreshToken)}`,
363
408
  index: this.#accounts.length,
364
409
  email,
365
410
  refreshToken,
@@ -415,11 +460,10 @@ export class AccountManager {
415
460
  }
416
461
  }
417
462
 
418
- // Reset health scores for all accounts since indices shifted after splice.
419
- // This is simpler and safer than trying to remap individual scores.
420
- for (let i = 0; i < this.#accounts.length; i++) {
421
- this.#healthTracker.reset(i);
422
- }
463
+ // Reset both health and token trackers since indices shifted after splice (QA fix H10).
464
+ // Rebuilding is simpler and safer than trying to remap individual entries.
465
+ this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
466
+ this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
423
467
  this.requestSaveToDisk();
424
468
  return true;
425
469
  }
@@ -445,6 +489,10 @@ export class AccountManager {
445
489
  this.#accounts = [];
446
490
  this.#currentIndex = -1;
447
491
  this.#cursor = 0;
492
+ // QA fix: reset trackers to avoid stale state leaking to new accounts
493
+ this.#healthTracker = new HealthScoreTracker(this.#config.health_score);
494
+ this.#tokenTracker = new TokenBucketTracker(this.#config.token_bucket);
495
+ this.#statsDeltas.clear();
448
496
  }
449
497
 
450
498
  /**
@@ -587,10 +635,20 @@ export class AccountManager {
587
635
  activeIndex: Math.max(0, this.#currentIndex),
588
636
  };
589
637
 
590
- await saveAccounts(storage);
638
+ // QA fix H8: snapshot delta keys before the async write to avoid losing
639
+ // deltas added during the await. Only delete keys we captured, not new ones.
640
+ const capturedDeltaKeys = new Set(this.#statsDeltas.keys());
591
641
 
592
- // Clear deltas after successful write
593
- this.#statsDeltas.clear();
642
+ // QA fix M15: pass pre-loaded disk data to saveAccounts to avoid TOCTOU double-read
643
+ const preloadedDiskData = diskAccountsById
644
+ ? { version: 1, accounts: [...diskAccountsById.values()], activeIndex: Math.max(0, this.#currentIndex) }
645
+ : null;
646
+ await saveAccounts(storage, preloadedDiskData);
647
+
648
+ // Clear only the captured deltas; new ones added during await are preserved
649
+ for (const key of capturedDeltaKeys) {
650
+ this.#statsDeltas.delete(key);
651
+ }
594
652
 
595
653
  // Update in-memory stats to match what we wrote (so subsequent reads
596
654
  // reflect the merged values, not stale pre-merge values)
@@ -630,7 +688,7 @@ export class AccountManager {
630
688
  const existing =
631
689
  (acc.id && existingById.get(acc.id)) || (!acc.id ? existingByToken.get(acc.refreshToken) : null);
632
690
  return {
633
- id: acc.id || existing?.id || `${acc.addedAt}:${acc.refreshToken.slice(0, 12)}`,
691
+ id: acc.id || existing?.id || `${acc.addedAt}:${hashTokenFragment(acc.refreshToken)}`,
634
692
  index,
635
693
  email: acc.email ?? existing?.email,
636
694
  refreshToken: acc.refreshToken,
@@ -356,18 +356,18 @@ describe("AccountManager persistence", () => {
356
356
 
357
357
  it("saveToDisk calls saveAccounts with correct format", async () => {
358
358
  await manager.saveToDisk();
359
- expect(saveAccounts).toHaveBeenCalledWith(
360
- expect.objectContaining({
361
- version: 1,
362
- accounts: expect.arrayContaining([
363
- expect.objectContaining({
364
- refreshToken: "token1",
365
- enabled: true,
366
- }),
367
- ]),
368
- activeIndex: expect.any(Number),
369
- }),
359
+ // M15: saveToDisk now passes optional 2nd arg (preloaded disk data)
360
+ const savedArg = saveAccounts.mock.calls[0][0];
361
+ expect(savedArg.version).toBe(1);
362
+ expect(savedArg.accounts).toEqual(
363
+ expect.arrayContaining([
364
+ expect.objectContaining({
365
+ refreshToken: "token1",
366
+ enabled: true,
367
+ }),
368
+ ]),
370
369
  );
370
+ expect(typeof savedArg.activeIndex).toBe("number");
371
371
  });
372
372
 
373
373
  it("requestSaveToDisk debounces saves", async () => {
@@ -606,18 +606,18 @@ describe("AccountManager usage stats", () => {
606
606
 
607
607
  await manager.saveToDisk();
608
608
 
609
- expect(saveAccounts).toHaveBeenCalledWith(
610
- expect.objectContaining({
611
- accounts: expect.arrayContaining([
612
- expect.objectContaining({
613
- stats: expect.objectContaining({
614
- requests: 1,
615
- inputTokens: 100,
616
- outputTokens: 50,
617
- }),
609
+ // M15: saveToDisk now passes optional 2nd arg (preloaded disk data)
610
+ const savedArg = saveAccounts.mock.calls[0][0];
611
+ expect(savedArg.accounts).toEqual(
612
+ expect.arrayContaining([
613
+ expect.objectContaining({
614
+ stats: expect.objectContaining({
615
+ requests: 1,
616
+ inputTokens: 100,
617
+ outputTokens: 50,
618
618
  }),
619
- ]),
620
- }),
619
+ }),
620
+ ]),
621
621
  );
622
622
  });
623
623
  });
package/lib/backoff.mjs CHANGED
@@ -7,6 +7,31 @@ const AUTH_FAILED_BACKOFF = 5_000;
7
7
  const RATE_LIMIT_EXCEEDED_BACKOFF = 30_000;
8
8
  const MIN_BACKOFF_MS = 2_000;
9
9
 
10
+ /**
11
+ * Parse the retry-after-ms header from a response.
12
+ * Returns milliseconds directly (no conversion needed).
13
+ * @param {Response} response
14
+ * @returns {number | null}
15
+ */
16
+ export function parseRetryAfterMsHeader(response) {
17
+ const header = response.headers.get("retry-after-ms");
18
+ if (!header) return null;
19
+ const ms = parseFloat(header);
20
+ return !isNaN(ms) && ms > 0 ? ms : null;
21
+ }
22
+
23
+ /**
24
+ * Parse the x-should-retry header from a response.
25
+ * @param {Response} response
26
+ * @returns {boolean | null} true = force retry, false = force no retry, null = not present
27
+ */
28
+ export function parseShouldRetryHeader(response) {
29
+ const header = response.headers.get("x-should-retry");
30
+ if (header === "true") return true;
31
+ if (header === "false") return false;
32
+ return null;
33
+ }
34
+
10
35
  /**
11
36
  * Parse the Retry-After header from a response.
12
37
  * Supports both seconds (integer) and HTTP-date formats.
package/lib/config.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { randomBytes } from "node:crypto";
4
5
 
5
6
  /**
6
7
  * @typedef {'sticky' | 'round-robin' | 'hybrid'} AccountSelectionStrategy
@@ -44,6 +45,14 @@ import { homedir } from "node:os";
44
45
  * @property {number} min_interval_minutes - Minimum minutes between idle refresh attempts per account
45
46
  */
46
47
 
48
+ /**
49
+ * @typedef {object} CachePolicyConfig
50
+ * @property {string} ttl - "1h" (default), "5m", or "off"
51
+ * @property {boolean} ttl_supported - Auto-set to false if API rejects TTL
52
+ * @property {boolean} boundary_marker - Inject static/dynamic boundary marker into system prompt (opt-in)
53
+ * @property {number} hit_rate_warning_threshold - Warn when avg cache hit rate falls below this (0-1). Default: 0.3
54
+ */
55
+
47
56
  /**
48
57
  * @typedef {object} AnthropicAuthConfig
49
58
  * @property {AccountSelectionStrategy} account_selection_strategy
@@ -57,6 +66,10 @@ import { homedir } from "node:os";
57
66
  * @property {ToastConfig} toasts
58
67
  * @property {HeaderConfig} headers
59
68
  * @property {IdleRefreshConfig} idle_refresh
69
+ * @property {CachePolicyConfig} cache_policy
70
+ * @property {boolean} fast_mode
71
+ * @property {{emulate_minimal: boolean}} telemetry
72
+ * @property {boolean} usage_toast
60
73
  */
61
74
 
62
75
  /** @type {AnthropicAuthConfig} */
@@ -98,6 +111,23 @@ export const DEFAULT_CONFIG = {
98
111
  window_minutes: 60,
99
112
  min_interval_minutes: 30,
100
113
  },
114
+ cache_policy: {
115
+ ttl: "1h",
116
+ ttl_supported: true,
117
+ boundary_marker: false,
118
+ hit_rate_warning_threshold: 0.3,
119
+ },
120
+ fast_mode: false,
121
+ telemetry: {
122
+ emulate_minimal: false,
123
+ },
124
+ usage_toast: false,
125
+ headers: {
126
+ emulation_profile: "",
127
+ overrides: {},
128
+ disable: [],
129
+ billing_header: true,
130
+ },
101
131
  };
102
132
 
103
133
  export const VALID_STRATEGIES = ["sticky", "round-robin", "hybrid"];
@@ -119,6 +149,10 @@ function createDefaultConfig() {
119
149
  token_bucket: { ...DEFAULT_CONFIG.token_bucket },
120
150
  toasts: { ...DEFAULT_CONFIG.toasts },
121
151
  idle_refresh: { ...DEFAULT_CONFIG.idle_refresh },
152
+ cache_policy: { ...DEFAULT_CONFIG.cache_policy },
153
+ fast_mode: DEFAULT_CONFIG.fast_mode,
154
+ telemetry: { ...DEFAULT_CONFIG.telemetry },
155
+ headers: { ...DEFAULT_CONFIG.headers, overrides: {}, disable: [] },
122
156
  };
123
157
  }
124
158
 
@@ -293,6 +327,36 @@ function validateConfig(raw) {
293
327
  };
294
328
  }
295
329
 
330
+ // Cache policy sub-config
331
+ if (raw.cache_policy && typeof raw.cache_policy === "object") {
332
+ const cp = /** @type {Record<string, unknown>} */ (raw.cache_policy);
333
+ const validTtls = ["1h", "5m", "off"];
334
+ config.cache_policy = {
335
+ ttl: typeof cp.ttl === "string" && validTtls.includes(cp.ttl) ? cp.ttl : DEFAULT_CONFIG.cache_policy.ttl,
336
+ ttl_supported:
337
+ typeof cp.ttl_supported === "boolean" ? cp.ttl_supported : DEFAULT_CONFIG.cache_policy.ttl_supported,
338
+ boundary_marker:
339
+ typeof cp.boundary_marker === "boolean" ? cp.boundary_marker : DEFAULT_CONFIG.cache_policy.boundary_marker,
340
+ hit_rate_warning_threshold:
341
+ typeof cp.hit_rate_warning_threshold === "number" && Number.isFinite(cp.hit_rate_warning_threshold)
342
+ ? Math.max(0, Math.min(1, cp.hit_rate_warning_threshold))
343
+ : DEFAULT_CONFIG.cache_policy.hit_rate_warning_threshold,
344
+ };
345
+ }
346
+
347
+ // Fast mode
348
+ config.fast_mode = typeof raw.fast_mode === "boolean" ? raw.fast_mode : false;
349
+
350
+ // Telemetry
351
+ config.telemetry = config.telemetry || {};
352
+ config.telemetry.emulate_minimal =
353
+ typeof raw.telemetry?.emulate_minimal === "boolean"
354
+ ? raw.telemetry.emulate_minimal
355
+ : DEFAULT_CONFIG.telemetry.emulate_minimal;
356
+
357
+ // Usage toast
358
+ config.usage_toast = typeof raw.usage_toast === "boolean" ? raw.usage_toast : DEFAULT_CONFIG.usage_toast;
359
+
296
360
  return config;
297
361
  }
298
362
 
@@ -456,7 +520,8 @@ export function saveConfig(updates) {
456
520
  const merged = deepMergeConfig(existing, updates);
457
521
 
458
522
  // Atomic write: temp file + rename
459
- const tmpPath = configPath + `.tmp.${process.pid}`;
523
+ // QA fix M16: use random bytes to avoid collision with same-process concurrent calls
524
+ const tmpPath = configPath + `.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
460
525
  writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
461
526
  renameSync(tmpPath, configPath);
462
527
  }