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/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
|
|
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
|
|
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
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
593
|
-
|
|
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
|
|
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,
|
package/lib/accounts.test.mjs
CHANGED
|
@@ -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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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
|
}
|