@cortexkit/anthropic-auth-core 1.4.1 → 1.6.0
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/dist/accounts.d.ts +68 -1
- package/dist/accounts.js +236 -23
- package/dist/cachekeep.d.ts +1 -0
- package/dist/cachekeep.js +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/killswitch.d.ts +27 -0
- package/dist/killswitch.js +127 -0
- package/dist/quota-manager.d.ts +116 -0
- package/dist/quota-manager.js +432 -0
- package/dist/relay.js +41 -11
- package/package.json +1 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { DEFAULT_KILLSWITCH_THRESHOLDS as DEFAULT_THRESHOLDS } from "./accounts.js";
|
|
2
|
+
export const KILLSWITCH_COMMAND_NAME = 'claude-killswitch';
|
|
3
|
+
export function parseKillswitchCommandAction(argumentsText) {
|
|
4
|
+
const parts = argumentsText.trim().split(/\s+/).filter(Boolean);
|
|
5
|
+
if (parts.length === 0)
|
|
6
|
+
return { type: 'status' };
|
|
7
|
+
if (parts.length === 1 && parts[0] === 'on')
|
|
8
|
+
return { type: 'on' };
|
|
9
|
+
if (parts.length === 1 && parts[0] === 'off')
|
|
10
|
+
return { type: 'off' };
|
|
11
|
+
if (parts[0] === 'set') {
|
|
12
|
+
const entries = [];
|
|
13
|
+
for (let i = 1; i < parts.length; i++) {
|
|
14
|
+
const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)$/);
|
|
15
|
+
if (!match)
|
|
16
|
+
return { type: 'usage' };
|
|
17
|
+
const [, account, fhStr, sdStr] = match;
|
|
18
|
+
entries.push({
|
|
19
|
+
account,
|
|
20
|
+
fh: Number.parseInt(fhStr, 10),
|
|
21
|
+
sd: Number.parseInt(sdStr, 10),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (entries.length === 0)
|
|
25
|
+
return { type: 'usage' };
|
|
26
|
+
return { type: 'set', entries };
|
|
27
|
+
}
|
|
28
|
+
return { type: 'usage' };
|
|
29
|
+
}
|
|
30
|
+
function buildStatusTable(config, accountIds) {
|
|
31
|
+
const enabled = config.enabled === true;
|
|
32
|
+
const lines = [
|
|
33
|
+
'## Killswitch',
|
|
34
|
+
'',
|
|
35
|
+
`Status: **${enabled ? 'ON' : 'OFF'}**`,
|
|
36
|
+
];
|
|
37
|
+
if (enabled) {
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push('| Account | 5h threshold | 1w threshold |');
|
|
40
|
+
lines.push('| ------- | ------------ | ------------ |');
|
|
41
|
+
const mainT = config.main ?? {};
|
|
42
|
+
const fh = mainT.five_hour ?? mainT['5h'] ?? DEFAULT_THRESHOLDS.five_hour;
|
|
43
|
+
const sd = mainT.seven_day ?? mainT['1w'] ?? DEFAULT_THRESHOLDS.seven_day;
|
|
44
|
+
lines.push(`| main | \u2265 ${fh}% | \u2265 ${sd}% |`);
|
|
45
|
+
for (const id of accountIds) {
|
|
46
|
+
const t = config.accounts?.[id] ?? config.main ?? {};
|
|
47
|
+
const afh = t.five_hour ?? t['5h'] ?? DEFAULT_THRESHOLDS.five_hour;
|
|
48
|
+
const asd = t.seven_day ?? t['1w'] ?? DEFAULT_THRESHOLDS.seven_day;
|
|
49
|
+
lines.push(`| ${id} | \u2265 ${afh}% | \u2265 ${asd}% |`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
const USAGE_TEXT = [
|
|
55
|
+
'## Killswitch Commands',
|
|
56
|
+
'',
|
|
57
|
+
'```',
|
|
58
|
+
`/${KILLSWITCH_COMMAND_NAME} — show status`,
|
|
59
|
+
`/${KILLSWITCH_COMMAND_NAME} on — enable with current or default thresholds`,
|
|
60
|
+
`/${KILLSWITCH_COMMAND_NAME} off — disable`,
|
|
61
|
+
`/${KILLSWITCH_COMMAND_NAME} set all:5,10 — set all accounts to 5h≥5%, 1w≥10%`,
|
|
62
|
+
`/${KILLSWITCH_COMMAND_NAME} set main:3,8 work-alt:5,10 — per-account`,
|
|
63
|
+
'```',
|
|
64
|
+
].join('\n');
|
|
65
|
+
export function executeKillswitchCommand(input) {
|
|
66
|
+
const action = parseKillswitchCommandAction(input.argumentsText);
|
|
67
|
+
if (action.type === 'status') {
|
|
68
|
+
const status = buildStatusTable(input.config, input.accountIds);
|
|
69
|
+
return { text: `${status}\n\n${USAGE_TEXT}` };
|
|
70
|
+
}
|
|
71
|
+
if (action.type === 'on') {
|
|
72
|
+
const updated = {
|
|
73
|
+
...input.config,
|
|
74
|
+
enabled: true,
|
|
75
|
+
main: input.config.main ?? {
|
|
76
|
+
five_hour: DEFAULT_THRESHOLDS.five_hour,
|
|
77
|
+
seven_day: DEFAULT_THRESHOLDS.seven_day,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const status = buildStatusTable(updated, input.accountIds);
|
|
81
|
+
return {
|
|
82
|
+
text: `## Killswitch Enabled\n\n${status}`,
|
|
83
|
+
updatedConfig: updated,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (action.type === 'off') {
|
|
87
|
+
const updated = { ...input.config, enabled: false };
|
|
88
|
+
return {
|
|
89
|
+
text: '## Killswitch Disabled',
|
|
90
|
+
updatedConfig: updated,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (action.type === 'set') {
|
|
94
|
+
const accounts = { ...(input.config.accounts ?? {}) };
|
|
95
|
+
const updated = {
|
|
96
|
+
...input.config,
|
|
97
|
+
enabled: true,
|
|
98
|
+
accounts,
|
|
99
|
+
};
|
|
100
|
+
for (const entry of action.entries) {
|
|
101
|
+
const thresholds = {
|
|
102
|
+
five_hour: entry.fh,
|
|
103
|
+
seven_day: entry.sd,
|
|
104
|
+
};
|
|
105
|
+
if (entry.account === 'main') {
|
|
106
|
+
updated.main = thresholds;
|
|
107
|
+
}
|
|
108
|
+
else if (entry.account === 'all') {
|
|
109
|
+
updated.main = thresholds;
|
|
110
|
+
for (const id of input.accountIds) {
|
|
111
|
+
accounts[id] = thresholds;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
accounts[entry.account] = thresholds;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const status = buildStatusTable(updated, input.accountIds);
|
|
119
|
+
return {
|
|
120
|
+
text: `## Killswitch Updated\n\n${status}`,
|
|
121
|
+
updatedConfig: updated,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// usage
|
|
125
|
+
const status = buildStatusTable(input.config, input.accountIds);
|
|
126
|
+
return { text: `${status}\n\n${USAGE_TEXT}` };
|
|
127
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified quota cache and API gateway.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for main + fallback quota state. All consumers
|
|
5
|
+
* share one QuotaManager instance so they see the same in-memory cache.
|
|
6
|
+
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
|
+
*/
|
|
8
|
+
import type { AccountOperationError, AccountStorage, OAuthAccount, OAuthQuotaSnapshot } from './accounts.ts';
|
|
9
|
+
/**
|
|
10
|
+
* Stable, non-reversible fingerprint of an access token. Used to detect a
|
|
11
|
+
* main-account switch so a different account's persisted/cached quota is never
|
|
12
|
+
* reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
|
|
13
|
+
*/
|
|
14
|
+
export declare function tokenFingerprint(token: string): string;
|
|
15
|
+
export type QuotaEntry = {
|
|
16
|
+
quota: OAuthQuotaSnapshot;
|
|
17
|
+
refreshAfter: number;
|
|
18
|
+
checkedAt: number;
|
|
19
|
+
};
|
|
20
|
+
export type QuotaManagerOptions = {
|
|
21
|
+
storage: AccountStorage | null;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number, tokenFingerprint: string) => void;
|
|
25
|
+
onApiError?: (error: AccountOperationError) => void;
|
|
26
|
+
};
|
|
27
|
+
export declare class QuotaManager {
|
|
28
|
+
private main;
|
|
29
|
+
private mainTokenFp;
|
|
30
|
+
private fallbacks;
|
|
31
|
+
private fallbackTokenFps;
|
|
32
|
+
private inflightMain;
|
|
33
|
+
private inflightFallbacks;
|
|
34
|
+
private mainLastApiError;
|
|
35
|
+
private fallbackApiErrors;
|
|
36
|
+
private fallbackErrorTokenFps;
|
|
37
|
+
private apiGate;
|
|
38
|
+
private lastApiCallAt;
|
|
39
|
+
private storage;
|
|
40
|
+
private readonly fetchImpl;
|
|
41
|
+
private readonly now;
|
|
42
|
+
private readonly onMainQuotaFetched;
|
|
43
|
+
private readonly onApiError;
|
|
44
|
+
constructor(opts: QuotaManagerOptions);
|
|
45
|
+
/**
|
|
46
|
+
* Cached main quota entry. Pass the live access token to enforce token
|
|
47
|
+
* binding: if the cached entry was produced by a different token (main
|
|
48
|
+
* account switched), it is dropped and null is returned so the caller
|
|
49
|
+
* refetches for the current account. Called without a token (e.g. for
|
|
50
|
+
* display) it returns whatever is cached.
|
|
51
|
+
*/
|
|
52
|
+
getMain(accessToken?: string): QuotaEntry | null;
|
|
53
|
+
/**
|
|
54
|
+
* Cached fallback quota entry. Pass the live access token to enforce token
|
|
55
|
+
* binding: if the entry was produced by a different token (account re-login),
|
|
56
|
+
* it is dropped and null is returned so the caller refetches.
|
|
57
|
+
*/
|
|
58
|
+
getFallback(accountId: string, accessToken?: string): QuotaEntry | null;
|
|
59
|
+
getAllFallbacks(): Map<string, QuotaEntry>;
|
|
60
|
+
setMain(accessToken: string, entry: QuotaEntry): void;
|
|
61
|
+
setFallback(accountId: string, entry: QuotaEntry, accessToken?: string): void;
|
|
62
|
+
refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>;
|
|
63
|
+
refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>;
|
|
64
|
+
refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Fire-and-forget refresh. Does not await, swallows errors.
|
|
67
|
+
*/
|
|
68
|
+
refreshMainInBackground(accessToken: string): void;
|
|
69
|
+
isMainStale(): boolean;
|
|
70
|
+
isFallbackStale(accountId: string, accessToken?: string): boolean;
|
|
71
|
+
shouldRefreshOnRequestCount(requestCount: number): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Combined check: should a refresh happen right now?
|
|
74
|
+
* True if main is stale by time OR triggered by request count.
|
|
75
|
+
*/
|
|
76
|
+
needsRefresh(requestCount: number): boolean;
|
|
77
|
+
updateStorage(storage: AccountStorage | null): void;
|
|
78
|
+
/**
|
|
79
|
+
* Seed fallback cache entries from persisted account.quota data.
|
|
80
|
+
* Only seeds accounts that don't already have a cache entry.
|
|
81
|
+
* Prevents unnecessary API calls when persisted quota is still fresh.
|
|
82
|
+
*/
|
|
83
|
+
seedFallbacksFromAccounts(accounts: OAuthAccount[]): void;
|
|
84
|
+
/**
|
|
85
|
+
* Whether the MAIN quota API is currently in backoff. Scoped to the main
|
|
86
|
+
* account — a fallback account's 429 never reports here.
|
|
87
|
+
*/
|
|
88
|
+
isBackedOff(): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Whether a specific fallback account's quota API is in backoff.
|
|
91
|
+
*/
|
|
92
|
+
isFallbackBackedOff(accountId: string, accessToken?: string): boolean;
|
|
93
|
+
getLastApiError(): AccountOperationError | undefined;
|
|
94
|
+
/** Minimum gap between consecutive quota API calls (ms). */
|
|
95
|
+
private static readonly API_CALL_GAP_MS;
|
|
96
|
+
private static fallbackInflightKey;
|
|
97
|
+
/**
|
|
98
|
+
* Serialize API calls through a shared gate so only one
|
|
99
|
+
* quota API request runs at a time, with a minimum gap
|
|
100
|
+
* between calls. Prevents concurrent and rapid-fire calls
|
|
101
|
+
* from triggering Anthropic's rate limits.
|
|
102
|
+
*/
|
|
103
|
+
private _enqueueApiFetch;
|
|
104
|
+
private _fetchMain;
|
|
105
|
+
private _fetchFallback;
|
|
106
|
+
private static isAuthError;
|
|
107
|
+
/** Main quota failure: arms main-only backoff and persists via onApiError. */
|
|
108
|
+
private _handleMainFetchError;
|
|
109
|
+
/**
|
|
110
|
+
* Fallback quota failure: arms backoff for THIS account only. Never touches
|
|
111
|
+
* main backoff state and never calls onApiError (which persists the main
|
|
112
|
+
* quota error) — the per-account error is recorded by the caller via the
|
|
113
|
+
* account's lastQuotaRefreshError.
|
|
114
|
+
*/
|
|
115
|
+
private _handleFallbackFetchError;
|
|
116
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified quota cache and API gateway.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for main + fallback quota state. All consumers
|
|
5
|
+
* share one QuotaManager instance so they see the same in-memory cache.
|
|
6
|
+
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, quotaBackoffActive, } from "./accounts.js";
|
|
10
|
+
// Capture real setTimeout before tests can mock globalThis.setTimeout
|
|
11
|
+
const nativeSetTimeout = globalThis.setTimeout;
|
|
12
|
+
/**
|
|
13
|
+
* Stable, non-reversible fingerprint of an access token. Used to detect a
|
|
14
|
+
* main-account switch so a different account's persisted/cached quota is never
|
|
15
|
+
* reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
|
|
16
|
+
*/
|
|
17
|
+
export function tokenFingerprint(token) {
|
|
18
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 16);
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Class
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export class QuotaManager {
|
|
24
|
+
// --- State ---
|
|
25
|
+
main = null;
|
|
26
|
+
mainTokenFp = null;
|
|
27
|
+
fallbacks = new Map();
|
|
28
|
+
// Fingerprint of the access token that produced each fallback cache entry, so
|
|
29
|
+
// a re-login (credential change) for the same account id invalidates the
|
|
30
|
+
// stale entry instead of being treated as fresh.
|
|
31
|
+
fallbackTokenFps = new Map();
|
|
32
|
+
// --- Inflight deduplication ---
|
|
33
|
+
inflightMain = null;
|
|
34
|
+
inflightFallbacks = new Map();
|
|
35
|
+
// --- Rate-limiting (scoped per route so a fallback 429 never backs off the
|
|
36
|
+
// main account or vice versa) ---
|
|
37
|
+
mainLastApiError = undefined;
|
|
38
|
+
fallbackApiErrors = new Map();
|
|
39
|
+
fallbackErrorTokenFps = new Map();
|
|
40
|
+
// --- Serial API gate (prevents concurrent quota API calls) ---
|
|
41
|
+
apiGate = Promise.resolve();
|
|
42
|
+
lastApiCallAt = 0;
|
|
43
|
+
// --- Config ---
|
|
44
|
+
storage;
|
|
45
|
+
fetchImpl;
|
|
46
|
+
now;
|
|
47
|
+
onMainQuotaFetched;
|
|
48
|
+
onApiError;
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
this.storage = opts.storage;
|
|
51
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
52
|
+
this.now = opts.now ?? Date.now;
|
|
53
|
+
this.onMainQuotaFetched = opts.onMainQuotaFetched;
|
|
54
|
+
this.onApiError = opts.onApiError;
|
|
55
|
+
// Seed main quota from persisted storage, bound to the token fingerprint
|
|
56
|
+
// that produced it. refreshMain() drops this seed if the live token's
|
|
57
|
+
// fingerprint differs (main-account switch), preventing stale wrong-account
|
|
58
|
+
// quota from being served during backoff.
|
|
59
|
+
const persisted = getPersistedMainQuota(opts.storage);
|
|
60
|
+
if (persisted) {
|
|
61
|
+
this.main = {
|
|
62
|
+
quota: persisted.quota,
|
|
63
|
+
refreshAfter: getQuotaNextRefreshAt(persisted.quota, opts.storage, persisted.checkedAt),
|
|
64
|
+
checkedAt: persisted.checkedAt,
|
|
65
|
+
};
|
|
66
|
+
this.mainTokenFp = persisted.tokenFingerprint ?? null;
|
|
67
|
+
}
|
|
68
|
+
// Seed backoff state from persisted storage
|
|
69
|
+
const persistedError = opts.storage?.quota?.mainLastQuotaApiError;
|
|
70
|
+
if (persistedError && quotaBackoffActive(persistedError, this.now())) {
|
|
71
|
+
this.mainLastApiError = persistedError;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// =========================================================================
|
|
75
|
+
// Get (synchronous, from cache)
|
|
76
|
+
// =========================================================================
|
|
77
|
+
/**
|
|
78
|
+
* Cached main quota entry. Pass the live access token to enforce token
|
|
79
|
+
* binding: if the cached entry was produced by a different token (main
|
|
80
|
+
* account switched), it is dropped and null is returned so the caller
|
|
81
|
+
* refetches for the current account. Called without a token (e.g. for
|
|
82
|
+
* display) it returns whatever is cached.
|
|
83
|
+
*/
|
|
84
|
+
getMain(accessToken) {
|
|
85
|
+
if (accessToken &&
|
|
86
|
+
this.main &&
|
|
87
|
+
this.mainTokenFp &&
|
|
88
|
+
this.mainTokenFp !== tokenFingerprint(accessToken)) {
|
|
89
|
+
this.main = null;
|
|
90
|
+
this.mainTokenFp = null;
|
|
91
|
+
}
|
|
92
|
+
return this.main;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Cached fallback quota entry. Pass the live access token to enforce token
|
|
96
|
+
* binding: if the entry was produced by a different token (account re-login),
|
|
97
|
+
* it is dropped and null is returned so the caller refetches.
|
|
98
|
+
*/
|
|
99
|
+
getFallback(accountId, accessToken) {
|
|
100
|
+
const entry = this.fallbacks.get(accountId) ?? null;
|
|
101
|
+
if (!accessToken || !entry)
|
|
102
|
+
return entry;
|
|
103
|
+
const fp = this.fallbackTokenFps.get(accountId);
|
|
104
|
+
if (fp !== tokenFingerprint(accessToken)) {
|
|
105
|
+
this.fallbacks.delete(accountId);
|
|
106
|
+
this.fallbackTokenFps.delete(accountId);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return entry;
|
|
110
|
+
}
|
|
111
|
+
getAllFallbacks() {
|
|
112
|
+
return this.fallbacks;
|
|
113
|
+
}
|
|
114
|
+
// =========================================================================
|
|
115
|
+
// Set (manual inject — seeding from persisted account.quota on boot)
|
|
116
|
+
// =========================================================================
|
|
117
|
+
setMain(accessToken, entry) {
|
|
118
|
+
this.mainTokenFp = tokenFingerprint(accessToken);
|
|
119
|
+
this.main = entry;
|
|
120
|
+
}
|
|
121
|
+
setFallback(accountId, entry, accessToken) {
|
|
122
|
+
this.fallbacks.set(accountId, entry);
|
|
123
|
+
if (accessToken) {
|
|
124
|
+
this.fallbackTokenFps.set(accountId, tokenFingerprint(accessToken));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
this.fallbackTokenFps.delete(accountId);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// =========================================================================
|
|
131
|
+
// Refresh (async, deduplicated, rate-limited)
|
|
132
|
+
// =========================================================================
|
|
133
|
+
async refreshMain(accessToken) {
|
|
134
|
+
// If the main account/token changed, invalidate the cache (including a
|
|
135
|
+
// persisted seed) BEFORE the backoff short-circuit so a different account's
|
|
136
|
+
// stale quota is never returned while the quota API is backed off.
|
|
137
|
+
const fp = tokenFingerprint(accessToken);
|
|
138
|
+
if (this.mainTokenFp && this.mainTokenFp !== fp) {
|
|
139
|
+
this.main = null;
|
|
140
|
+
this.mainTokenFp = null;
|
|
141
|
+
}
|
|
142
|
+
// Deduplicate — return in-flight promise if already fetching
|
|
143
|
+
if (this.inflightMain)
|
|
144
|
+
return this.inflightMain;
|
|
145
|
+
// Rate-limit — if API recently 429'd, return stale or throw
|
|
146
|
+
if (this.isBackedOff()) {
|
|
147
|
+
if (this.main)
|
|
148
|
+
return this.main.quota;
|
|
149
|
+
throw new Error('Quota API rate-limited — try again later');
|
|
150
|
+
}
|
|
151
|
+
this.inflightMain = this._fetchMain(accessToken);
|
|
152
|
+
return this.inflightMain;
|
|
153
|
+
}
|
|
154
|
+
async refreshFallback(accountId, accessToken) {
|
|
155
|
+
// Deduplicate per account+token so a same-label re-login never joins a
|
|
156
|
+
// quota probe that was started with the previous credentials.
|
|
157
|
+
const inflightKey = QuotaManager.fallbackInflightKey(accountId, accessToken);
|
|
158
|
+
const inflight = this.inflightFallbacks.get(inflightKey);
|
|
159
|
+
if (inflight)
|
|
160
|
+
return inflight;
|
|
161
|
+
// Rate-limit — scoped to THIS fallback account only
|
|
162
|
+
if (this.isFallbackBackedOff(accountId, accessToken)) {
|
|
163
|
+
const cached = this.getFallback(accountId, accessToken);
|
|
164
|
+
if (cached)
|
|
165
|
+
return cached.quota;
|
|
166
|
+
throw new Error('Quota API rate-limited — try again later');
|
|
167
|
+
}
|
|
168
|
+
const promise = this._fetchFallback(accountId, accessToken);
|
|
169
|
+
this.inflightFallbacks.set(inflightKey, promise);
|
|
170
|
+
return promise;
|
|
171
|
+
}
|
|
172
|
+
async refreshAllFallbacks(accounts) {
|
|
173
|
+
const now = this.now();
|
|
174
|
+
for (const account of accounts) {
|
|
175
|
+
if (account.enabled === false)
|
|
176
|
+
continue;
|
|
177
|
+
if (!account.access)
|
|
178
|
+
continue;
|
|
179
|
+
const cached = this.getFallback(account.id, account.access);
|
|
180
|
+
if (cached && now < cached.refreshAfter)
|
|
181
|
+
continue;
|
|
182
|
+
try {
|
|
183
|
+
await this.refreshFallback(account.id, account.access);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Best-effort — keep stale cache entry if fetch fails
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Fire-and-forget refresh. Does not await, swallows errors.
|
|
192
|
+
*/
|
|
193
|
+
refreshMainInBackground(accessToken) {
|
|
194
|
+
if (this.inflightMain)
|
|
195
|
+
return;
|
|
196
|
+
if (this.isBackedOff())
|
|
197
|
+
return;
|
|
198
|
+
void this.refreshMain(accessToken).catch(() => { });
|
|
199
|
+
}
|
|
200
|
+
// =========================================================================
|
|
201
|
+
// Staleness queries
|
|
202
|
+
// =========================================================================
|
|
203
|
+
isMainStale() {
|
|
204
|
+
if (!this.main)
|
|
205
|
+
return true;
|
|
206
|
+
return this.now() >= this.main.refreshAfter;
|
|
207
|
+
}
|
|
208
|
+
isFallbackStale(accountId, accessToken) {
|
|
209
|
+
// Token-aware: a credential change invalidates the entry (treated as stale).
|
|
210
|
+
const entry = this.getFallback(accountId, accessToken);
|
|
211
|
+
if (!entry)
|
|
212
|
+
return true;
|
|
213
|
+
return this.now() >= entry.refreshAfter;
|
|
214
|
+
}
|
|
215
|
+
shouldRefreshOnRequestCount(requestCount) {
|
|
216
|
+
const everyN = getQuotaRefreshEveryNRequests(this.storage);
|
|
217
|
+
if (everyN <= 0)
|
|
218
|
+
return false;
|
|
219
|
+
return requestCount > 0 && requestCount % everyN === 0;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Combined check: should a refresh happen right now?
|
|
223
|
+
* True if main is stale by time OR triggered by request count.
|
|
224
|
+
*/
|
|
225
|
+
needsRefresh(requestCount) {
|
|
226
|
+
return this.isMainStale() || this.shouldRefreshOnRequestCount(requestCount);
|
|
227
|
+
}
|
|
228
|
+
// =========================================================================
|
|
229
|
+
// Config
|
|
230
|
+
// =========================================================================
|
|
231
|
+
updateStorage(storage) {
|
|
232
|
+
this.storage = storage;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Seed fallback cache entries from persisted account.quota data.
|
|
236
|
+
* Only seeds accounts that don't already have a cache entry.
|
|
237
|
+
* Prevents unnecessary API calls when persisted quota is still fresh.
|
|
238
|
+
*/
|
|
239
|
+
seedFallbacksFromAccounts(accounts) {
|
|
240
|
+
const checkInterval = getQuotaCheckIntervalMs(this.storage);
|
|
241
|
+
for (const account of accounts) {
|
|
242
|
+
if (account.enabled === false)
|
|
243
|
+
continue;
|
|
244
|
+
if (this.getFallback(account.id, account.access))
|
|
245
|
+
continue;
|
|
246
|
+
if (!account.quota)
|
|
247
|
+
continue;
|
|
248
|
+
const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
|
|
249
|
+
if (checkedAt <= 0)
|
|
250
|
+
continue;
|
|
251
|
+
this.setFallback(account.id, {
|
|
252
|
+
quota: account.quota,
|
|
253
|
+
refreshAfter: checkedAt + checkInterval,
|
|
254
|
+
checkedAt,
|
|
255
|
+
}, account.access);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether the MAIN quota API is currently in backoff. Scoped to the main
|
|
260
|
+
* account — a fallback account's 429 never reports here.
|
|
261
|
+
*/
|
|
262
|
+
isBackedOff() {
|
|
263
|
+
return quotaBackoffActive(this.mainLastApiError, this.now());
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Whether a specific fallback account's quota API is in backoff.
|
|
267
|
+
*/
|
|
268
|
+
isFallbackBackedOff(accountId, accessToken) {
|
|
269
|
+
if (accessToken) {
|
|
270
|
+
const errorFp = this.fallbackErrorTokenFps.get(accountId);
|
|
271
|
+
if (errorFp !== tokenFingerprint(accessToken))
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
return quotaBackoffActive(this.fallbackApiErrors.get(accountId), this.now());
|
|
275
|
+
}
|
|
276
|
+
getLastApiError() {
|
|
277
|
+
return this.mainLastApiError;
|
|
278
|
+
}
|
|
279
|
+
// =========================================================================
|
|
280
|
+
// Private
|
|
281
|
+
// =========================================================================
|
|
282
|
+
/** Minimum gap between consecutive quota API calls (ms). */
|
|
283
|
+
static API_CALL_GAP_MS = 1_000;
|
|
284
|
+
static fallbackInflightKey(accountId, accessToken) {
|
|
285
|
+
return JSON.stringify([accountId, tokenFingerprint(accessToken)]);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Serialize API calls through a shared gate so only one
|
|
289
|
+
* quota API request runs at a time, with a minimum gap
|
|
290
|
+
* between calls. Prevents concurrent and rapid-fire calls
|
|
291
|
+
* from triggering Anthropic's rate limits.
|
|
292
|
+
*/
|
|
293
|
+
_enqueueApiFetch(fn) {
|
|
294
|
+
const gatedFn = async () => {
|
|
295
|
+
// Wait until minimum gap since last API call
|
|
296
|
+
const elapsed = this.now() - this.lastApiCallAt;
|
|
297
|
+
if (elapsed < QuotaManager.API_CALL_GAP_MS) {
|
|
298
|
+
await new Promise((r) => {
|
|
299
|
+
const id = nativeSetTimeout(r, QuotaManager.API_CALL_GAP_MS - elapsed);
|
|
300
|
+
if (typeof id === 'object' && 'unref' in id)
|
|
301
|
+
id.unref();
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
this.lastApiCallAt = this.now();
|
|
305
|
+
return fn();
|
|
306
|
+
};
|
|
307
|
+
const queued = this.apiGate.then(gatedFn, gatedFn);
|
|
308
|
+
this.apiGate = queued.catch(() => { });
|
|
309
|
+
return queued;
|
|
310
|
+
}
|
|
311
|
+
async _fetchMain(accessToken) {
|
|
312
|
+
return this._enqueueApiFetch(async () => {
|
|
313
|
+
try {
|
|
314
|
+
// Re-check backoff inside gate — may have been set by
|
|
315
|
+
// a preceding queued call while we waited
|
|
316
|
+
if (this.isBackedOff()) {
|
|
317
|
+
if (this.main)
|
|
318
|
+
return this.main.quota;
|
|
319
|
+
throw new Error('Quota API rate-limited — try again later');
|
|
320
|
+
}
|
|
321
|
+
const fileLock = await acquireRefreshFileLock({
|
|
322
|
+
name: 'opencode-main-quota-refresh',
|
|
323
|
+
ttlMs: 30_000,
|
|
324
|
+
});
|
|
325
|
+
if (!fileLock) {
|
|
326
|
+
if (this.main)
|
|
327
|
+
return this.main.quota;
|
|
328
|
+
throw new Error('Quota refresh is already in progress');
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const quota = await fetchOAuthQuotaSnapshot({
|
|
332
|
+
accessToken,
|
|
333
|
+
fetchImpl: this.fetchImpl,
|
|
334
|
+
now: this.now,
|
|
335
|
+
});
|
|
336
|
+
const now = this.now();
|
|
337
|
+
this.mainTokenFp = tokenFingerprint(accessToken);
|
|
338
|
+
this.main = {
|
|
339
|
+
quota,
|
|
340
|
+
refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now),
|
|
341
|
+
checkedAt: now,
|
|
342
|
+
};
|
|
343
|
+
this.mainLastApiError = undefined;
|
|
344
|
+
this.onMainQuotaFetched?.(quota, now, this.mainTokenFp);
|
|
345
|
+
return quota;
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
this._handleMainFetchError(error);
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
await fileLock.release();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
this.inflightMain = null;
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
async _fetchFallback(accountId, accessToken) {
|
|
361
|
+
return this._enqueueApiFetch(async () => {
|
|
362
|
+
try {
|
|
363
|
+
// Re-check backoff inside gate — scoped to this fallback account
|
|
364
|
+
if (this.isFallbackBackedOff(accountId, accessToken)) {
|
|
365
|
+
const cached = this.getFallback(accountId, accessToken);
|
|
366
|
+
if (cached)
|
|
367
|
+
return cached.quota;
|
|
368
|
+
throw new Error('Quota API rate-limited — try again later');
|
|
369
|
+
}
|
|
370
|
+
const quota = await fetchOAuthQuotaSnapshot({
|
|
371
|
+
accessToken,
|
|
372
|
+
fetchImpl: this.fetchImpl,
|
|
373
|
+
now: this.now,
|
|
374
|
+
});
|
|
375
|
+
const now = this.now();
|
|
376
|
+
this.setFallback(accountId, {
|
|
377
|
+
quota,
|
|
378
|
+
refreshAfter: now + getQuotaCheckIntervalMs(this.storage),
|
|
379
|
+
checkedAt: now,
|
|
380
|
+
}, accessToken);
|
|
381
|
+
this.fallbackApiErrors.delete(accountId);
|
|
382
|
+
this.fallbackErrorTokenFps.delete(accountId);
|
|
383
|
+
return quota;
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
this._handleFallbackFetchError(accountId, accessToken, error);
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
this.inflightFallbacks.delete(QuotaManager.fallbackInflightKey(accountId, accessToken));
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// A 401 is an auth/token problem, not a rate limit. The caller refreshes the
|
|
395
|
+
// token and retries; backing off the quota API here would block that retry,
|
|
396
|
+
// so surface 401s without recording backoff state.
|
|
397
|
+
static isAuthError(error) {
|
|
398
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
399
|
+
return /quota check failed: 401\b/.test(message);
|
|
400
|
+
}
|
|
401
|
+
/** Main quota failure: arms main-only backoff and persists via onApiError. */
|
|
402
|
+
_handleMainFetchError(error) {
|
|
403
|
+
if (QuotaManager.isAuthError(error))
|
|
404
|
+
return;
|
|
405
|
+
this.mainLastApiError = buildQuotaOperationError({
|
|
406
|
+
error,
|
|
407
|
+
now: this.now(),
|
|
408
|
+
previous: this.mainLastApiError,
|
|
409
|
+
});
|
|
410
|
+
this.onApiError?.(this.mainLastApiError);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Fallback quota failure: arms backoff for THIS account only. Never touches
|
|
414
|
+
* main backoff state and never calls onApiError (which persists the main
|
|
415
|
+
* quota error) — the per-account error is recorded by the caller via the
|
|
416
|
+
* account's lastQuotaRefreshError.
|
|
417
|
+
*/
|
|
418
|
+
_handleFallbackFetchError(accountId, accessToken, error) {
|
|
419
|
+
if (QuotaManager.isAuthError(error))
|
|
420
|
+
return;
|
|
421
|
+
const tokenFp = tokenFingerprint(accessToken);
|
|
422
|
+
const previous = this.fallbackErrorTokenFps.get(accountId) === tokenFp
|
|
423
|
+
? this.fallbackApiErrors.get(accountId)
|
|
424
|
+
: undefined;
|
|
425
|
+
this.fallbackApiErrors.set(accountId, buildQuotaOperationError({
|
|
426
|
+
error,
|
|
427
|
+
now: this.now(),
|
|
428
|
+
previous,
|
|
429
|
+
}));
|
|
430
|
+
this.fallbackErrorTokenFps.set(accountId, tokenFp);
|
|
431
|
+
}
|
|
432
|
+
}
|