@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.
@@ -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(): Promise<{
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
- message: formatErrorMessage(error),
602
- checkedAt: now,
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
- if (quotaIsStale(next, storage, this.now())) {
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
- if (this.accountPassesQuotaPolicy(next, storage))
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
- log('[refresh] fallback oauth skipped backoff', {
723
- accountId: account.id,
724
- nextRetryAt: account.lastRefreshError?.nextRetryAt,
725
- retryCount: account.lastRefreshError?.retryCount,
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 (!quotaIsStale(next, storage, this.now()))
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
- if (!quotaIsStale(next, storage, this.now())) {
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
- try {
952
- target.quota = await fetchOAuthQuotaSnapshot({
953
- accessToken: target.access,
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
- target.quota = await fetchOAuthQuotaSnapshot({
968
- accessToken: target.access,
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
  }
@@ -73,6 +73,7 @@ export declare class CacheKeepManager {
73
73
  trackedSessions: number;
74
74
  nextPrewarmAt: number | undefined;
75
75
  };
76
+ trackedCount(): number;
76
77
  track(input: {
77
78
  sessionId?: string | null;
78
79
  url: string;
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
+ };