@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
package/dist/accounts.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export type AccountQuotaWindow = {
|
|
|
31
31
|
checkedAt: number;
|
|
32
32
|
};
|
|
33
33
|
export type RoutingMode = 'main-first' | 'fallback-first';
|
|
34
|
+
export type KillswitchThresholds = Partial<Record<QuotaWindowName | '5h' | '1w', number>>;
|
|
35
|
+
export type KillswitchConfig = {
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
/** Thresholds for the main OAuth account (remaining % below which the account is killed). */
|
|
38
|
+
main?: KillswitchThresholds;
|
|
39
|
+
/** Per-account overrides keyed by account ID. Accounts without an entry use the `main` thresholds. */
|
|
40
|
+
accounts?: Record<string, KillswitchThresholds>;
|
|
41
|
+
};
|
|
34
42
|
export type AccountStorage = {
|
|
35
43
|
version: 1;
|
|
36
44
|
main?: {
|
|
@@ -53,8 +61,15 @@ export type AccountStorage = {
|
|
|
53
61
|
quota?: {
|
|
54
62
|
enabled?: boolean;
|
|
55
63
|
checkIntervalMinutes?: number;
|
|
64
|
+
refreshEveryNRequests?: number;
|
|
56
65
|
minimumRemaining?: Partial<Record<QuotaWindowName | '5h' | '1w', number>>;
|
|
57
66
|
failClosedOnUnknownQuota?: boolean;
|
|
67
|
+
/** Opt-in OpenCode TUI toast after quota refresh. Default: false. */
|
|
68
|
+
showToasts?: boolean;
|
|
69
|
+
mainQuota?: OAuthQuotaSnapshot;
|
|
70
|
+
mainQuotaCheckedAt?: number;
|
|
71
|
+
mainQuotaToken?: string;
|
|
72
|
+
mainLastQuotaApiError?: AccountOperationError;
|
|
58
73
|
};
|
|
59
74
|
claudeCache?: {
|
|
60
75
|
enabled?: boolean;
|
|
@@ -78,6 +93,7 @@ export type AccountStorage = {
|
|
|
78
93
|
fallbackToDirect?: boolean;
|
|
79
94
|
transport?: 'http' | 'websocket';
|
|
80
95
|
};
|
|
96
|
+
killswitch?: KillswitchConfig;
|
|
81
97
|
accounts: OAuthAccount[];
|
|
82
98
|
};
|
|
83
99
|
export type OAuthQuotaSnapshot = Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
|
|
@@ -85,6 +101,8 @@ export type AccountManagerOptions = {
|
|
|
85
101
|
now?: () => number;
|
|
86
102
|
fetchImpl?: typeof fetch;
|
|
87
103
|
configPath?: string;
|
|
104
|
+
quotaManager?: import('./quota-manager.ts').QuotaManager;
|
|
105
|
+
onFallbackStorageChanged?: () => void;
|
|
88
106
|
};
|
|
89
107
|
export type AccountRefreshError = {
|
|
90
108
|
accountId: string;
|
|
@@ -124,8 +142,41 @@ export declare function buildRefreshOperationError(input: {
|
|
|
124
142
|
}): AccountOperationError;
|
|
125
143
|
export declare function refreshBackoffActive(error: AccountOperationError | undefined, refreshToken: string | undefined, now: number): boolean;
|
|
126
144
|
export declare function formatRefreshBackoffMessage(error: AccountOperationError, now: number): string;
|
|
145
|
+
export declare function buildQuotaOperationError(input: {
|
|
146
|
+
error: unknown;
|
|
147
|
+
now: number;
|
|
148
|
+
previous?: AccountOperationError;
|
|
149
|
+
}): AccountOperationError;
|
|
150
|
+
export declare function quotaBackoffActive(error: AccountOperationError | undefined, now: number): boolean;
|
|
151
|
+
export declare function formatQuotaBackoffMessage(error: AccountOperationError, now: number): string;
|
|
127
152
|
export declare function getQuotaCheckIntervalMs(storage: AccountStorage | null): number;
|
|
153
|
+
export declare function getPersistedMainQuota(storage: AccountStorage | null): {
|
|
154
|
+
quota: OAuthQuotaSnapshot;
|
|
155
|
+
checkedAt: number;
|
|
156
|
+
tokenFingerprint?: string;
|
|
157
|
+
} | null;
|
|
158
|
+
/**
|
|
159
|
+
* How often (in requests) to force a quota refresh, independent of the timer.
|
|
160
|
+
* Returns 0 when disabled (default).
|
|
161
|
+
*/
|
|
162
|
+
export declare function getQuotaRefreshEveryNRequests(storage: AccountStorage | null): number;
|
|
128
163
|
export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean;
|
|
164
|
+
export declare const DEFAULT_KILLSWITCH_THRESHOLDS: Record<QuotaWindowName, number>;
|
|
165
|
+
export declare function isKillswitchEnabled(storage: AccountStorage | null): boolean;
|
|
166
|
+
/**
|
|
167
|
+
* Returns true if the account's quota is above its killswitch threshold.
|
|
168
|
+
* When killswitch is disabled, always returns true.
|
|
169
|
+
*/
|
|
170
|
+
export declare function killswitchPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, accountId?: string): boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Find the earliest reset time across all accounts' quota windows.
|
|
173
|
+
* Returns seconds from `now` until that reset, or 300 as a fallback.
|
|
174
|
+
*/
|
|
175
|
+
export declare function killswitchRetryAfterSeconds(mainQuota: OAuthQuotaSnapshot | undefined, fallbackAccounts: Array<{
|
|
176
|
+
quota?: OAuthQuotaSnapshot;
|
|
177
|
+
}>, now: number): number;
|
|
178
|
+
export declare function getKillswitchConfig(storage: AccountStorage | null): KillswitchConfig;
|
|
179
|
+
export declare function setKillswitchPersistent(config: KillswitchConfig, path?: string): Promise<AccountStorage>;
|
|
129
180
|
export declare function getQuotaNextRefreshAt(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now: number): number;
|
|
130
181
|
export declare function fetchOAuthQuotaSnapshot(input: {
|
|
131
182
|
accessToken: string;
|
|
@@ -142,7 +193,14 @@ export declare class FallbackAccountManager {
|
|
|
142
193
|
private readonly refreshPromises;
|
|
143
194
|
private refreshTimer;
|
|
144
195
|
private quotaTimer;
|
|
196
|
+
readonly quotaManager: import('./quota-manager.ts').QuotaManager | null;
|
|
197
|
+
private readonly onFallbackStorageChanged;
|
|
145
198
|
constructor(options?: AccountManagerOptions);
|
|
199
|
+
/**
|
|
200
|
+
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
|
201
|
+
* yet. Prevents unnecessary API calls when the on-disk snapshot is fresh.
|
|
202
|
+
*/
|
|
203
|
+
private seedFallbackQuota;
|
|
146
204
|
load(): Promise<AccountStorage | null>;
|
|
147
205
|
save(storage: AccountStorage): Promise<void>;
|
|
148
206
|
startBackgroundRefresh(): void;
|
|
@@ -150,9 +208,18 @@ export declare class FallbackAccountManager {
|
|
|
150
208
|
getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
|
|
151
209
|
markUsed(account: OAuthAccount): Promise<void>;
|
|
152
210
|
accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
|
|
211
|
+
/**
|
|
212
|
+
* Return the account with its quota overlaid from the unified QuotaManager
|
|
213
|
+
* cache (token-bound) when available, so quota-policy decisions use the same
|
|
214
|
+
* source of truth as the staleness check. Falls back to the stored
|
|
215
|
+
* account.quota when no manager is wired or the cache has no entry.
|
|
216
|
+
*/
|
|
217
|
+
private quotaPolicyAccount;
|
|
153
218
|
refreshDueAccounts(): Promise<void>;
|
|
154
219
|
refreshQuotaForDueAccounts(): Promise<void>;
|
|
155
|
-
refreshQuotaForAllAccounts(
|
|
220
|
+
refreshQuotaForAllAccounts(options?: {
|
|
221
|
+
force?: boolean;
|
|
222
|
+
}): Promise<{
|
|
156
223
|
storage: AccountStorage | null;
|
|
157
224
|
errors: AccountRefreshError[];
|
|
158
225
|
}>;
|
package/dist/accounts.js
CHANGED
|
@@ -16,6 +16,9 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
|
|
|
16
16
|
const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
|
|
17
17
|
const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
|
|
18
18
|
const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000;
|
|
19
|
+
const MIN_QUOTA_RETRY_DELAY_MS = 60_000;
|
|
20
|
+
const MAX_QUOTA_RETRY_DELAY_MS = 15 * 60_000;
|
|
21
|
+
const NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 5 * 60_000;
|
|
19
22
|
const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5;
|
|
20
23
|
const DEFAULT_MINIMUM_REMAINING = {
|
|
21
24
|
five_hour: 0,
|
|
@@ -127,6 +130,7 @@ function normalizeStorage(value) {
|
|
|
127
130
|
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
128
131
|
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
129
132
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
133
|
+
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
130
134
|
accounts: value.accounts
|
|
131
135
|
.map(normalizeAccount)
|
|
132
136
|
.filter((account) => account != null),
|
|
@@ -452,10 +456,51 @@ export function formatRefreshBackoffMessage(error, now) {
|
|
|
452
456
|
const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
|
|
453
457
|
return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
|
|
454
458
|
}
|
|
459
|
+
export function buildQuotaOperationError(input) {
|
|
460
|
+
const previousRetryCount = input.previous?.retryCount ?? 0;
|
|
461
|
+
const retryCount = previousRetryCount + 1;
|
|
462
|
+
const delay = isTransientQuotaError(input.error)
|
|
463
|
+
? Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
|
|
464
|
+
: NON_TRANSIENT_QUOTA_RETRY_DELAY_MS;
|
|
465
|
+
return {
|
|
466
|
+
message: formatErrorMessage(input.error),
|
|
467
|
+
checkedAt: input.now,
|
|
468
|
+
nextRetryAt: input.now + delay,
|
|
469
|
+
retryCount,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
export function quotaBackoffActive(error, now) {
|
|
473
|
+
if (!error?.nextRetryAt || error.nextRetryAt <= now)
|
|
474
|
+
return false;
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
export function formatQuotaBackoffMessage(error, now) {
|
|
478
|
+
const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
|
|
479
|
+
return `Quota API backed off for ${seconds}s after: ${error.message}`;
|
|
480
|
+
}
|
|
455
481
|
export function getQuotaCheckIntervalMs(storage) {
|
|
456
482
|
const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
|
|
457
483
|
return Math.max(1, minutes) * 60_000;
|
|
458
484
|
}
|
|
485
|
+
export function getPersistedMainQuota(storage) {
|
|
486
|
+
if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt)
|
|
487
|
+
return null;
|
|
488
|
+
return {
|
|
489
|
+
quota: storage.quota.mainQuota,
|
|
490
|
+
checkedAt: storage.quota.mainQuotaCheckedAt,
|
|
491
|
+
tokenFingerprint: storage.quota.mainQuotaToken,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* How often (in requests) to force a quota refresh, independent of the timer.
|
|
496
|
+
* Returns 0 when disabled (default).
|
|
497
|
+
*/
|
|
498
|
+
export function getQuotaRefreshEveryNRequests(storage) {
|
|
499
|
+
const n = storage?.quota?.refreshEveryNRequests;
|
|
500
|
+
return typeof n === 'number' && Number.isFinite(n) && n > 0
|
|
501
|
+
? Math.floor(n)
|
|
502
|
+
: 0;
|
|
503
|
+
}
|
|
459
504
|
function failClosedOnUnknownQuota(storage) {
|
|
460
505
|
return (storage?.quota?.failClosedOnUnknownQuota ??
|
|
461
506
|
DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
|
|
@@ -473,6 +518,95 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
|
|
|
473
518
|
}
|
|
474
519
|
return true;
|
|
475
520
|
}
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Killswitch — hard-block requests when remaining quota drops below per-account
|
|
523
|
+
// thresholds, even if the API would still accept them.
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
export const DEFAULT_KILLSWITCH_THRESHOLDS = {
|
|
526
|
+
five_hour: 5,
|
|
527
|
+
seven_day: 10,
|
|
528
|
+
};
|
|
529
|
+
function normalizeKillswitchThresholds(thresholds) {
|
|
530
|
+
return {
|
|
531
|
+
five_hour: thresholds?.five_hour ??
|
|
532
|
+
thresholds?.['5h'] ??
|
|
533
|
+
DEFAULT_KILLSWITCH_THRESHOLDS.five_hour,
|
|
534
|
+
seven_day: thresholds?.seven_day ??
|
|
535
|
+
thresholds?.['1w'] ??
|
|
536
|
+
DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
export function isKillswitchEnabled(storage) {
|
|
540
|
+
return storage?.killswitch?.enabled === true;
|
|
541
|
+
}
|
|
542
|
+
function getKillswitchThresholdsForAccount(storage, accountId) {
|
|
543
|
+
if (!storage?.killswitch)
|
|
544
|
+
return DEFAULT_KILLSWITCH_THRESHOLDS;
|
|
545
|
+
if (accountId && storage.killswitch.accounts?.[accountId]) {
|
|
546
|
+
return normalizeKillswitchThresholds(storage.killswitch.accounts[accountId]);
|
|
547
|
+
}
|
|
548
|
+
return normalizeKillswitchThresholds(storage.killswitch.main);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Returns true if the account's quota is above its killswitch threshold.
|
|
552
|
+
* When killswitch is disabled, always returns true.
|
|
553
|
+
*/
|
|
554
|
+
export function killswitchPassesPolicy(quota, storage, accountId) {
|
|
555
|
+
if (!isKillswitchEnabled(storage))
|
|
556
|
+
return true;
|
|
557
|
+
const thresholds = getKillswitchThresholdsForAccount(storage, accountId);
|
|
558
|
+
let sawUnknownWindow = false;
|
|
559
|
+
for (const key of ['five_hour', 'seven_day']) {
|
|
560
|
+
const window = quota?.[key];
|
|
561
|
+
// Defer the unknown-window decision: a quota snapshot can legally carry
|
|
562
|
+
// only one window, and a present window below its threshold must still
|
|
563
|
+
// block even if the other window is missing.
|
|
564
|
+
if (!window) {
|
|
565
|
+
sawUnknownWindow = true;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (window.remainingPercent < thresholds[key])
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
if (sawUnknownWindow)
|
|
572
|
+
return !failClosedOnUnknownQuota(storage);
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Find the earliest reset time across all accounts' quota windows.
|
|
577
|
+
* Returns seconds from `now` until that reset, or 300 as a fallback.
|
|
578
|
+
*/
|
|
579
|
+
export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now) {
|
|
580
|
+
const resetTimes = [];
|
|
581
|
+
const allQuotas = [mainQuota, ...fallbackAccounts.map((a) => a.quota)];
|
|
582
|
+
for (const quota of allQuotas) {
|
|
583
|
+
for (const key of ['five_hour', 'seven_day']) {
|
|
584
|
+
const resetStr = quota?.[key]?.resetsAt;
|
|
585
|
+
if (!resetStr)
|
|
586
|
+
continue;
|
|
587
|
+
const resetTime = Date.parse(resetStr);
|
|
588
|
+
if (Number.isFinite(resetTime) && resetTime > now) {
|
|
589
|
+
resetTimes.push(resetTime);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (!resetTimes.length)
|
|
594
|
+
return 300;
|
|
595
|
+
return Math.max(1, Math.ceil((Math.min(...resetTimes) - now) / 1000)) + 60;
|
|
596
|
+
}
|
|
597
|
+
export function getKillswitchConfig(storage) {
|
|
598
|
+
return storage?.killswitch ?? { enabled: false };
|
|
599
|
+
}
|
|
600
|
+
export async function setKillswitchPersistent(config, path = getAccountStoragePath()) {
|
|
601
|
+
const storage = (await loadAccounts(path)) ?? {
|
|
602
|
+
version: 1,
|
|
603
|
+
main: { type: 'opencode', provider: 'anthropic' },
|
|
604
|
+
accounts: [],
|
|
605
|
+
};
|
|
606
|
+
storage.killswitch = config;
|
|
607
|
+
await saveAccounts(storage, path);
|
|
608
|
+
return storage;
|
|
609
|
+
}
|
|
476
610
|
export function getQuotaNextRefreshAt(quota, storage, now) {
|
|
477
611
|
if (!quotaEnabled(storage))
|
|
478
612
|
return now + getQuotaCheckIntervalMs(storage);
|
|
@@ -597,10 +731,11 @@ function recordRefreshError(account, error, now) {
|
|
|
597
731
|
});
|
|
598
732
|
}
|
|
599
733
|
function recordQuotaRefreshError(account, error, now) {
|
|
600
|
-
account.lastQuotaRefreshError = {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
734
|
+
account.lastQuotaRefreshError = buildQuotaOperationError({
|
|
735
|
+
error,
|
|
736
|
+
now,
|
|
737
|
+
previous: account.lastQuotaRefreshError,
|
|
738
|
+
});
|
|
604
739
|
if (error instanceof ClaudeOAuthRefreshError) {
|
|
605
740
|
recordRefreshError(account, error, now);
|
|
606
741
|
}
|
|
@@ -618,10 +753,35 @@ export class FallbackAccountManager {
|
|
|
618
753
|
refreshPromises = new Map();
|
|
619
754
|
refreshTimer = null;
|
|
620
755
|
quotaTimer = null;
|
|
756
|
+
quotaManager;
|
|
757
|
+
onFallbackStorageChanged;
|
|
621
758
|
constructor(options = {}) {
|
|
622
759
|
this.now = options.now ?? Date.now;
|
|
623
760
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
624
761
|
this.configPath = options.configPath ?? getAccountStoragePath();
|
|
762
|
+
this.quotaManager = options.quotaManager ?? null;
|
|
763
|
+
this.onFallbackStorageChanged = options.onFallbackStorageChanged;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
|
767
|
+
* yet. Prevents unnecessary API calls when the on-disk snapshot is fresh.
|
|
768
|
+
*/
|
|
769
|
+
seedFallbackQuota(account, storage) {
|
|
770
|
+
if (!this.quotaManager)
|
|
771
|
+
return;
|
|
772
|
+
if (this.quotaManager.getFallback(account.id, account.access))
|
|
773
|
+
return;
|
|
774
|
+
if (!account.quota)
|
|
775
|
+
return;
|
|
776
|
+
const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
|
|
777
|
+
if (checkedAt <= 0)
|
|
778
|
+
return;
|
|
779
|
+
const checkInterval = getQuotaCheckIntervalMs(storage);
|
|
780
|
+
this.quotaManager.setFallback(account.id, {
|
|
781
|
+
quota: account.quota,
|
|
782
|
+
refreshAfter: checkedAt + checkInterval,
|
|
783
|
+
checkedAt,
|
|
784
|
+
}, account.access);
|
|
625
785
|
}
|
|
626
786
|
async load() {
|
|
627
787
|
return loadAccounts(this.configPath);
|
|
@@ -671,11 +831,23 @@ export class FallbackAccountManager {
|
|
|
671
831
|
next = await this.refreshAccount(next, storage);
|
|
672
832
|
changed = true;
|
|
673
833
|
}
|
|
674
|
-
|
|
834
|
+
this.seedFallbackQuota(next, storage);
|
|
835
|
+
const stale = this.quotaManager
|
|
836
|
+
? this.quotaManager.isFallbackStale(next.id, next.access)
|
|
837
|
+
: quotaIsStale(next, storage, this.now());
|
|
838
|
+
// Skip the request-time refresh when this account's quota API is
|
|
839
|
+
// backed off (recent 429/5xx). Hitting it again would extend the
|
|
840
|
+
// backoff; evaluate policy on the cached/seeded quota instead. Mirrors
|
|
841
|
+
// the background refreshQuotaForDueAccounts() guard.
|
|
842
|
+
if (stale &&
|
|
843
|
+
!quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
675
844
|
next = await this.refreshAccountQuota(next, storage);
|
|
676
845
|
changed = true;
|
|
677
846
|
}
|
|
678
|
-
|
|
847
|
+
// Single source of truth: evaluate quota policy from the unified
|
|
848
|
+
// QuotaManager cache (the same source as the staleness check above) so
|
|
849
|
+
// an active-route refresh that updated only the cache is not ignored.
|
|
850
|
+
if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage))
|
|
679
851
|
usable.push(next);
|
|
680
852
|
}
|
|
681
853
|
catch (error) {
|
|
@@ -708,6 +880,18 @@ export class FallbackAccountManager {
|
|
|
708
880
|
accountPassesQuotaPolicy(account, storage) {
|
|
709
881
|
return quotaSnapshotPassesPolicy(account.quota, storage);
|
|
710
882
|
}
|
|
883
|
+
/**
|
|
884
|
+
* Return the account with its quota overlaid from the unified QuotaManager
|
|
885
|
+
* cache (token-bound) when available, so quota-policy decisions use the same
|
|
886
|
+
* source of truth as the staleness check. Falls back to the stored
|
|
887
|
+
* account.quota when no manager is wired or the cache has no entry.
|
|
888
|
+
*/
|
|
889
|
+
quotaPolicyAccount(account) {
|
|
890
|
+
if (!this.quotaManager)
|
|
891
|
+
return account;
|
|
892
|
+
const cached = this.quotaManager.getFallback(account.id, account.access)?.quota;
|
|
893
|
+
return cached ? { ...account, quota: cached } : account;
|
|
894
|
+
}
|
|
711
895
|
async refreshDueAccounts() {
|
|
712
896
|
const storage = await this.load();
|
|
713
897
|
if (!storage || !refreshEnabled(storage))
|
|
@@ -719,11 +903,11 @@ export class FallbackAccountManager {
|
|
|
719
903
|
if (!tokenNeedsRefresh(account, storage, this.now()))
|
|
720
904
|
continue;
|
|
721
905
|
if (refreshBackoffActive(account.lastRefreshError, account.refresh, this.now())) {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
906
|
+
// Backoff skips are steady-state while a fallback account is waiting for
|
|
907
|
+
// its next retry. Logging every background tick from every OpenCode
|
|
908
|
+
// process creates noise without adding new diagnostic signal; the
|
|
909
|
+
// failure/backoff itself is recorded when the refresh attempt fails and
|
|
910
|
+
// shown by /claude-quota.
|
|
727
911
|
continue;
|
|
728
912
|
}
|
|
729
913
|
try {
|
|
@@ -767,7 +951,16 @@ export class FallbackAccountManager {
|
|
|
767
951
|
next = await this.refreshAccount(next, storage);
|
|
768
952
|
changed = true;
|
|
769
953
|
}
|
|
770
|
-
if (
|
|
954
|
+
if (quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
this.seedFallbackQuota(next, storage);
|
|
958
|
+
// Use QuotaManager staleness when available (shared cache);
|
|
959
|
+
// fall back to per-account on-disk staleness otherwise.
|
|
960
|
+
const stale = this.quotaManager
|
|
961
|
+
? this.quotaManager.isFallbackStale(next.id, next.access)
|
|
962
|
+
: quotaIsStale(next, storage, this.now());
|
|
963
|
+
if (!stale)
|
|
771
964
|
continue;
|
|
772
965
|
await this.refreshAccountQuota(next, storage);
|
|
773
966
|
changed = true;
|
|
@@ -779,14 +972,17 @@ export class FallbackAccountManager {
|
|
|
779
972
|
// Quota probes are advisory; failed probes fail closed at selection time.
|
|
780
973
|
}
|
|
781
974
|
}
|
|
782
|
-
if (changed)
|
|
975
|
+
if (changed) {
|
|
783
976
|
await this.save(storage);
|
|
977
|
+
this.onFallbackStorageChanged?.();
|
|
978
|
+
}
|
|
784
979
|
}
|
|
785
|
-
async refreshQuotaForAllAccounts() {
|
|
980
|
+
async refreshQuotaForAllAccounts(options = {}) {
|
|
786
981
|
const storage = await this.load();
|
|
787
982
|
const errors = [];
|
|
788
983
|
if (!storage || !quotaEnabled(storage))
|
|
789
984
|
return { storage, errors };
|
|
985
|
+
const force = options.force ?? false;
|
|
790
986
|
let changed = false;
|
|
791
987
|
for (const account of storage.accounts) {
|
|
792
988
|
if (account.enabled === false)
|
|
@@ -802,7 +998,10 @@ export class FallbackAccountManager {
|
|
|
802
998
|
next = await this.refreshAccount(next, storage);
|
|
803
999
|
changed = true;
|
|
804
1000
|
}
|
|
805
|
-
|
|
1001
|
+
// force (manual /claude-quota) bypasses the staleness skip to fetch
|
|
1002
|
+
// fresh numbers on demand. refreshAccountQuota still respects 429
|
|
1003
|
+
// backoff via QuotaManager.refreshFallback.
|
|
1004
|
+
if (!force && !quotaIsStale(next, storage, this.now())) {
|
|
806
1005
|
if (next.lastQuotaRefreshError) {
|
|
807
1006
|
next.lastQuotaRefreshError = undefined;
|
|
808
1007
|
updateStoredAccount(storage, next);
|
|
@@ -948,12 +1147,18 @@ export class FallbackAccountManager {
|
|
|
948
1147
|
if (!target.access) {
|
|
949
1148
|
throw new Error(`Fallback account ${account.id} has no access token`);
|
|
950
1149
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1150
|
+
// Unify on the shared QuotaManager when present: it adds inflight
|
|
1151
|
+
// deduplication and 429 backoff gating around the same quota API. Fall back
|
|
1152
|
+
// to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
|
|
1153
|
+
const fetchSnapshot = (accessToken) => this.quotaManager
|
|
1154
|
+
? this.quotaManager.refreshFallback(target.id, accessToken)
|
|
1155
|
+
: fetchOAuthQuotaSnapshot({
|
|
1156
|
+
accessToken,
|
|
954
1157
|
fetchImpl: this.fetchImpl,
|
|
955
1158
|
now: this.now,
|
|
956
1159
|
});
|
|
1160
|
+
try {
|
|
1161
|
+
target.quota = await fetchSnapshot(target.access);
|
|
957
1162
|
}
|
|
958
1163
|
catch (error) {
|
|
959
1164
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -964,14 +1169,22 @@ export class FallbackAccountManager {
|
|
|
964
1169
|
});
|
|
965
1170
|
if (!target.access)
|
|
966
1171
|
throw error;
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
fetchImpl: this.fetchImpl,
|
|
970
|
-
now: this.now,
|
|
971
|
-
});
|
|
1172
|
+
// 401 does not arm QuotaManager backoff, so this retry proceeds.
|
|
1173
|
+
target.quota = await fetchSnapshot(target.access);
|
|
972
1174
|
}
|
|
973
1175
|
target.lastQuotaRefreshError = undefined;
|
|
974
1176
|
updateStoredAccount(storage, target);
|
|
1177
|
+
// Sync to shared QuotaManager so all consumers see the same cache. The
|
|
1178
|
+
// refreshFallback path already cached the snapshot; re-set here so
|
|
1179
|
+
// refreshAfter reflects this storage's check interval consistently.
|
|
1180
|
+
if (this.quotaManager && target.quota) {
|
|
1181
|
+
const now = this.now();
|
|
1182
|
+
this.quotaManager.setFallback(target.id, {
|
|
1183
|
+
quota: target.quota,
|
|
1184
|
+
refreshAfter: now + getQuotaCheckIntervalMs(storage),
|
|
1185
|
+
checkedAt: now,
|
|
1186
|
+
}, target.access);
|
|
1187
|
+
}
|
|
975
1188
|
return target;
|
|
976
1189
|
}
|
|
977
1190
|
}
|
package/dist/cachekeep.d.ts
CHANGED
package/dist/cachekeep.js
CHANGED
|
@@ -205,6 +205,9 @@ export class CacheKeepManager {
|
|
|
205
205
|
: undefined;
|
|
206
206
|
return { trackedSessions: targets.length, nextPrewarmAt };
|
|
207
207
|
}
|
|
208
|
+
trackedCount() {
|
|
209
|
+
return this.targets.size;
|
|
210
|
+
}
|
|
208
211
|
track(input) {
|
|
209
212
|
if (!input.sessionId)
|
|
210
213
|
return { tracked: false, reason: 'missing session id' };
|
package/dist/index.d.ts
CHANGED
|
@@ -7,8 +7,10 @@ export * from './claude-code.ts';
|
|
|
7
7
|
export * from './constants.ts';
|
|
8
8
|
export * from './dump.ts';
|
|
9
9
|
export * from './fast.ts';
|
|
10
|
+
export * from './killswitch.ts';
|
|
10
11
|
export * from './logger.ts';
|
|
11
12
|
export * from './pkce.ts';
|
|
13
|
+
export * from './quota-manager.ts';
|
|
12
14
|
export * from './quotas.ts';
|
|
13
15
|
export * from './relay.ts';
|
|
14
16
|
export * from './routing.ts';
|
package/dist/index.js
CHANGED
|
@@ -7,8 +7,10 @@ export * from "./claude-code.js";
|
|
|
7
7
|
export * from "./constants.js";
|
|
8
8
|
export * from "./dump.js";
|
|
9
9
|
export * from "./fast.js";
|
|
10
|
+
export * from "./killswitch.js";
|
|
10
11
|
export * from "./logger.js";
|
|
11
12
|
export * from "./pkce.js";
|
|
13
|
+
export * from "./quota-manager.js";
|
|
12
14
|
export * from "./quotas.js";
|
|
13
15
|
export * from "./relay.js";
|
|
14
16
|
export * from "./routing.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { KillswitchConfig } from './accounts.ts';
|
|
2
|
+
export declare const KILLSWITCH_COMMAND_NAME = "claude-killswitch";
|
|
3
|
+
export type KillswitchCommandAction = {
|
|
4
|
+
type: 'status';
|
|
5
|
+
} | {
|
|
6
|
+
type: 'on';
|
|
7
|
+
} | {
|
|
8
|
+
type: 'off';
|
|
9
|
+
} | {
|
|
10
|
+
type: 'set';
|
|
11
|
+
entries: Array<{
|
|
12
|
+
account: string;
|
|
13
|
+
fh: number;
|
|
14
|
+
sd: number;
|
|
15
|
+
}>;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'usage';
|
|
18
|
+
};
|
|
19
|
+
export declare function parseKillswitchCommandAction(argumentsText: string): KillswitchCommandAction;
|
|
20
|
+
export declare function executeKillswitchCommand(input: {
|
|
21
|
+
argumentsText: string;
|
|
22
|
+
config: KillswitchConfig;
|
|
23
|
+
accountIds: string[];
|
|
24
|
+
}): {
|
|
25
|
+
text: string;
|
|
26
|
+
updatedConfig?: KillswitchConfig;
|
|
27
|
+
};
|