@cortexkit/anthropic-auth-core 1.4.0 → 1.5.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.
@@ -53,8 +53,13 @@ export type AccountStorage = {
53
53
  quota?: {
54
54
  enabled?: boolean;
55
55
  checkIntervalMinutes?: number;
56
+ refreshEveryNRequests?: number;
56
57
  minimumRemaining?: Partial<Record<QuotaWindowName | '5h' | '1w', number>>;
57
58
  failClosedOnUnknownQuota?: boolean;
59
+ mainQuota?: OAuthQuotaSnapshot;
60
+ mainQuotaCheckedAt?: number;
61
+ mainQuotaToken?: string;
62
+ mainLastQuotaApiError?: AccountOperationError;
58
63
  };
59
64
  claudeCache?: {
60
65
  enabled?: boolean;
@@ -85,6 +90,7 @@ export type AccountManagerOptions = {
85
90
  now?: () => number;
86
91
  fetchImpl?: typeof fetch;
87
92
  configPath?: string;
93
+ quotaManager?: import('./quota-manager.ts').QuotaManager;
88
94
  };
89
95
  export type AccountRefreshError = {
90
96
  accountId: string;
@@ -124,7 +130,24 @@ export declare function buildRefreshOperationError(input: {
124
130
  }): AccountOperationError;
125
131
  export declare function refreshBackoffActive(error: AccountOperationError | undefined, refreshToken: string | undefined, now: number): boolean;
126
132
  export declare function formatRefreshBackoffMessage(error: AccountOperationError, now: number): string;
133
+ export declare function buildQuotaOperationError(input: {
134
+ error: unknown;
135
+ now: number;
136
+ previous?: AccountOperationError;
137
+ }): AccountOperationError;
138
+ export declare function quotaBackoffActive(error: AccountOperationError | undefined, now: number): boolean;
139
+ export declare function formatQuotaBackoffMessage(error: AccountOperationError, now: number): string;
127
140
  export declare function getQuotaCheckIntervalMs(storage: AccountStorage | null): number;
141
+ export declare function getPersistedMainQuota(storage: AccountStorage | null): {
142
+ quota: OAuthQuotaSnapshot;
143
+ checkedAt: number;
144
+ tokenFingerprint?: string;
145
+ } | null;
146
+ /**
147
+ * How often (in requests) to force a quota refresh, independent of the timer.
148
+ * Returns 0 when disabled (default).
149
+ */
150
+ export declare function getQuotaRefreshEveryNRequests(storage: AccountStorage | null): number;
128
151
  export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean;
129
152
  export declare function getQuotaNextRefreshAt(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now: number): number;
130
153
  export declare function fetchOAuthQuotaSnapshot(input: {
@@ -142,7 +165,13 @@ export declare class FallbackAccountManager {
142
165
  private readonly refreshPromises;
143
166
  private refreshTimer;
144
167
  private quotaTimer;
168
+ readonly quotaManager: import('./quota-manager.ts').QuotaManager | null;
145
169
  constructor(options?: AccountManagerOptions);
170
+ /**
171
+ * Seed QuotaManager from persisted account.quota if no cache entry exists
172
+ * yet. Prevents unnecessary API calls when the on-disk snapshot is fresh.
173
+ */
174
+ private seedFallbackQuota;
146
175
  load(): Promise<AccountStorage | null>;
147
176
  save(storage: AccountStorage): Promise<void>;
148
177
  startBackgroundRefresh(): void;
@@ -150,9 +179,18 @@ export declare class FallbackAccountManager {
150
179
  getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
151
180
  markUsed(account: OAuthAccount): Promise<void>;
152
181
  accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
182
+ /**
183
+ * Return the account with its quota overlaid from the unified QuotaManager
184
+ * cache (token-bound) when available, so quota-policy decisions use the same
185
+ * source of truth as the staleness check. Falls back to the stored
186
+ * account.quota when no manager is wired or the cache has no entry.
187
+ */
188
+ private quotaPolicyAccount;
153
189
  refreshDueAccounts(): Promise<void>;
154
190
  refreshQuotaForDueAccounts(): Promise<void>;
155
- refreshQuotaForAllAccounts(): Promise<{
191
+ refreshQuotaForAllAccounts(options?: {
192
+ force?: boolean;
193
+ }): Promise<{
156
194
  storage: AccountStorage | null;
157
195
  errors: AccountRefreshError[];
158
196
  }>;
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,
@@ -452,10 +455,51 @@ export function formatRefreshBackoffMessage(error, now) {
452
455
  const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
453
456
  return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
454
457
  }
458
+ export function buildQuotaOperationError(input) {
459
+ const previousRetryCount = input.previous?.retryCount ?? 0;
460
+ const retryCount = previousRetryCount + 1;
461
+ const delay = isTransientQuotaError(input.error)
462
+ ? Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
463
+ : NON_TRANSIENT_QUOTA_RETRY_DELAY_MS;
464
+ return {
465
+ message: formatErrorMessage(input.error),
466
+ checkedAt: input.now,
467
+ nextRetryAt: input.now + delay,
468
+ retryCount,
469
+ };
470
+ }
471
+ export function quotaBackoffActive(error, now) {
472
+ if (!error?.nextRetryAt || error.nextRetryAt <= now)
473
+ return false;
474
+ return true;
475
+ }
476
+ export function formatQuotaBackoffMessage(error, now) {
477
+ const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
478
+ return `Quota API backed off for ${seconds}s after: ${error.message}`;
479
+ }
455
480
  export function getQuotaCheckIntervalMs(storage) {
456
481
  const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
457
482
  return Math.max(1, minutes) * 60_000;
458
483
  }
484
+ export function getPersistedMainQuota(storage) {
485
+ if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt)
486
+ return null;
487
+ return {
488
+ quota: storage.quota.mainQuota,
489
+ checkedAt: storage.quota.mainQuotaCheckedAt,
490
+ tokenFingerprint: storage.quota.mainQuotaToken,
491
+ };
492
+ }
493
+ /**
494
+ * How often (in requests) to force a quota refresh, independent of the timer.
495
+ * Returns 0 when disabled (default).
496
+ */
497
+ export function getQuotaRefreshEveryNRequests(storage) {
498
+ const n = storage?.quota?.refreshEveryNRequests;
499
+ return typeof n === 'number' && Number.isFinite(n) && n > 0
500
+ ? Math.floor(n)
501
+ : 0;
502
+ }
459
503
  function failClosedOnUnknownQuota(storage) {
460
504
  return (storage?.quota?.failClosedOnUnknownQuota ??
461
505
  DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
@@ -597,10 +641,11 @@ function recordRefreshError(account, error, now) {
597
641
  });
598
642
  }
599
643
  function recordQuotaRefreshError(account, error, now) {
600
- account.lastQuotaRefreshError = {
601
- message: formatErrorMessage(error),
602
- checkedAt: now,
603
- };
644
+ account.lastQuotaRefreshError = buildQuotaOperationError({
645
+ error,
646
+ now,
647
+ previous: account.lastQuotaRefreshError,
648
+ });
604
649
  if (error instanceof ClaudeOAuthRefreshError) {
605
650
  recordRefreshError(account, error, now);
606
651
  }
@@ -618,10 +663,33 @@ export class FallbackAccountManager {
618
663
  refreshPromises = new Map();
619
664
  refreshTimer = null;
620
665
  quotaTimer = null;
666
+ quotaManager;
621
667
  constructor(options = {}) {
622
668
  this.now = options.now ?? Date.now;
623
669
  this.fetchImpl = options.fetchImpl ?? fetch;
624
670
  this.configPath = options.configPath ?? getAccountStoragePath();
671
+ this.quotaManager = options.quotaManager ?? null;
672
+ }
673
+ /**
674
+ * Seed QuotaManager from persisted account.quota if no cache entry exists
675
+ * yet. Prevents unnecessary API calls when the on-disk snapshot is fresh.
676
+ */
677
+ seedFallbackQuota(account, storage) {
678
+ if (!this.quotaManager)
679
+ return;
680
+ if (this.quotaManager.getFallback(account.id, account.access))
681
+ return;
682
+ if (!account.quota)
683
+ return;
684
+ const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
685
+ if (checkedAt <= 0)
686
+ return;
687
+ const checkInterval = getQuotaCheckIntervalMs(storage);
688
+ this.quotaManager.setFallback(account.id, {
689
+ quota: account.quota,
690
+ refreshAfter: checkedAt + checkInterval,
691
+ checkedAt,
692
+ }, account.access);
625
693
  }
626
694
  async load() {
627
695
  return loadAccounts(this.configPath);
@@ -671,11 +739,23 @@ export class FallbackAccountManager {
671
739
  next = await this.refreshAccount(next, storage);
672
740
  changed = true;
673
741
  }
674
- if (quotaIsStale(next, storage, this.now())) {
742
+ this.seedFallbackQuota(next, storage);
743
+ const stale = this.quotaManager
744
+ ? this.quotaManager.isFallbackStale(next.id, next.access)
745
+ : quotaIsStale(next, storage, this.now());
746
+ // Skip the request-time refresh when this account's quota API is
747
+ // backed off (recent 429/5xx). Hitting it again would extend the
748
+ // backoff; evaluate policy on the cached/seeded quota instead. Mirrors
749
+ // the background refreshQuotaForDueAccounts() guard.
750
+ if (stale &&
751
+ !quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
675
752
  next = await this.refreshAccountQuota(next, storage);
676
753
  changed = true;
677
754
  }
678
- if (this.accountPassesQuotaPolicy(next, storage))
755
+ // Single source of truth: evaluate quota policy from the unified
756
+ // QuotaManager cache (the same source as the staleness check above) so
757
+ // an active-route refresh that updated only the cache is not ignored.
758
+ if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage))
679
759
  usable.push(next);
680
760
  }
681
761
  catch (error) {
@@ -708,6 +788,18 @@ export class FallbackAccountManager {
708
788
  accountPassesQuotaPolicy(account, storage) {
709
789
  return quotaSnapshotPassesPolicy(account.quota, storage);
710
790
  }
791
+ /**
792
+ * Return the account with its quota overlaid from the unified QuotaManager
793
+ * cache (token-bound) when available, so quota-policy decisions use the same
794
+ * source of truth as the staleness check. Falls back to the stored
795
+ * account.quota when no manager is wired or the cache has no entry.
796
+ */
797
+ quotaPolicyAccount(account) {
798
+ if (!this.quotaManager)
799
+ return account;
800
+ const cached = this.quotaManager.getFallback(account.id, account.access)?.quota;
801
+ return cached ? { ...account, quota: cached } : account;
802
+ }
711
803
  async refreshDueAccounts() {
712
804
  const storage = await this.load();
713
805
  if (!storage || !refreshEnabled(storage))
@@ -719,11 +811,11 @@ export class FallbackAccountManager {
719
811
  if (!tokenNeedsRefresh(account, storage, this.now()))
720
812
  continue;
721
813
  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
- });
814
+ // Backoff skips are steady-state while a fallback account is waiting for
815
+ // its next retry. Logging every background tick from every OpenCode
816
+ // process creates noise without adding new diagnostic signal; the
817
+ // failure/backoff itself is recorded when the refresh attempt fails and
818
+ // shown by /claude-quota.
727
819
  continue;
728
820
  }
729
821
  try {
@@ -767,7 +859,16 @@ export class FallbackAccountManager {
767
859
  next = await this.refreshAccount(next, storage);
768
860
  changed = true;
769
861
  }
770
- if (!quotaIsStale(next, storage, this.now()))
862
+ if (quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
863
+ continue;
864
+ }
865
+ this.seedFallbackQuota(next, storage);
866
+ // Use QuotaManager staleness when available (shared cache);
867
+ // fall back to per-account on-disk staleness otherwise.
868
+ const stale = this.quotaManager
869
+ ? this.quotaManager.isFallbackStale(next.id, next.access)
870
+ : quotaIsStale(next, storage, this.now());
871
+ if (!stale)
771
872
  continue;
772
873
  await this.refreshAccountQuota(next, storage);
773
874
  changed = true;
@@ -782,11 +883,12 @@ export class FallbackAccountManager {
782
883
  if (changed)
783
884
  await this.save(storage);
784
885
  }
785
- async refreshQuotaForAllAccounts() {
886
+ async refreshQuotaForAllAccounts(options = {}) {
786
887
  const storage = await this.load();
787
888
  const errors = [];
788
889
  if (!storage || !quotaEnabled(storage))
789
890
  return { storage, errors };
891
+ const force = options.force ?? false;
790
892
  let changed = false;
791
893
  for (const account of storage.accounts) {
792
894
  if (account.enabled === false)
@@ -802,7 +904,10 @@ export class FallbackAccountManager {
802
904
  next = await this.refreshAccount(next, storage);
803
905
  changed = true;
804
906
  }
805
- if (!quotaIsStale(next, storage, this.now())) {
907
+ // force (manual /claude-quota) bypasses the staleness skip to fetch
908
+ // fresh numbers on demand. refreshAccountQuota still respects 429
909
+ // backoff via QuotaManager.refreshFallback.
910
+ if (!force && !quotaIsStale(next, storage, this.now())) {
806
911
  if (next.lastQuotaRefreshError) {
807
912
  next.lastQuotaRefreshError = undefined;
808
913
  updateStoredAccount(storage, next);
@@ -948,12 +1053,18 @@ export class FallbackAccountManager {
948
1053
  if (!target.access) {
949
1054
  throw new Error(`Fallback account ${account.id} has no access token`);
950
1055
  }
951
- try {
952
- target.quota = await fetchOAuthQuotaSnapshot({
953
- accessToken: target.access,
1056
+ // Unify on the shared QuotaManager when present: it adds inflight
1057
+ // deduplication and 429 backoff gating around the same quota API. Fall back
1058
+ // to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
1059
+ const fetchSnapshot = (accessToken) => this.quotaManager
1060
+ ? this.quotaManager.refreshFallback(target.id, accessToken)
1061
+ : fetchOAuthQuotaSnapshot({
1062
+ accessToken,
954
1063
  fetchImpl: this.fetchImpl,
955
1064
  now: this.now,
956
1065
  });
1066
+ try {
1067
+ target.quota = await fetchSnapshot(target.access);
957
1068
  }
958
1069
  catch (error) {
959
1070
  const message = error instanceof Error ? error.message : String(error);
@@ -964,14 +1075,22 @@ export class FallbackAccountManager {
964
1075
  });
965
1076
  if (!target.access)
966
1077
  throw error;
967
- target.quota = await fetchOAuthQuotaSnapshot({
968
- accessToken: target.access,
969
- fetchImpl: this.fetchImpl,
970
- now: this.now,
971
- });
1078
+ // 401 does not arm QuotaManager backoff, so this retry proceeds.
1079
+ target.quota = await fetchSnapshot(target.access);
972
1080
  }
973
1081
  target.lastQuotaRefreshError = undefined;
974
1082
  updateStoredAccount(storage, target);
1083
+ // Sync to shared QuotaManager so all consumers see the same cache. The
1084
+ // refreshFallback path already cached the snapshot; re-set here so
1085
+ // refreshAfter reflects this storage's check interval consistently.
1086
+ if (this.quotaManager && target.quota) {
1087
+ const now = this.now();
1088
+ this.quotaManager.setFallback(target.id, {
1089
+ quota: target.quota,
1090
+ refreshAfter: now + getQuotaCheckIntervalMs(storage),
1091
+ checkedAt: now,
1092
+ }, target.access);
1093
+ }
975
1094
  return target;
976
1095
  }
977
1096
  }
@@ -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/constants.js CHANGED
@@ -36,7 +36,9 @@ export const OPENCODE_IDENTITY_PREFIX = 'You are OpenCode';
36
36
  export const CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
37
37
  export const PARALLEL_TOOL_CALLS_SYSTEM_PROMPT = [
38
38
  '<use_parallel_tool_calls>',
39
- 'For maximum efficiency, whenever you perform multiple independent operations, invoke all relevant tools simultaneously rather than sequentially. Prioritize calling tools in parallel whenever possible. For example, when reading 3 files, run 3 tool calls in parallel to read all 3 files into context at the same time. When running multiple read-only commands like `ls` or `list_dir`, always run all of the commands in parallel. Err on the side of maximizing parallel tool calls rather than running too many tools sequentially.',
39
+ 'For maximum efficiency, whenever you perform multiple independent operations, invoke all relevant tools simultaneously rather than sequentially. Prioritize calling independent tools in parallel whenever possible. For example, when reading 3 known files, run 3 tool calls in parallel to read all 3 files into context at the same time. When running multiple independent read-only commands like `ls` or `list_dir`, run all of those commands in parallel. Err on the side of maximizing parallel tool calls for independent work rather than running too many tools sequentially.',
40
+ '',
41
+ 'Do not parallelize tool calls when one call depends on the output of another call. If a tool call needs an ID, filename, search result, or any other value returned by a previous tool call, run the first call, wait for its result, then run the dependent call using the real returned value. Never invent placeholder IDs, guessed task IDs, or other guessed values for dependent tool calls just to place them in the same parallel block.',
40
42
  '</use_parallel_tool_calls>',
41
43
  ].join('\n');
42
44
  export const CCH_SALT = '59cf53e54c78';
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export * from './dump.ts';
9
9
  export * from './fast.ts';
10
10
  export * from './logger.ts';
11
11
  export * from './pkce.ts';
12
+ export * from './quota-manager.ts';
12
13
  export * from './quotas.ts';
13
14
  export * from './relay.ts';
14
15
  export * from './routing.ts';
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export * from "./dump.js";
9
9
  export * from "./fast.js";
10
10
  export * from "./logger.js";
11
11
  export * from "./pkce.js";
12
+ export * from "./quota-manager.js";
12
13
  export * from "./quotas.js";
13
14
  export * from "./relay.js";
14
15
  export * from "./routing.js";
@@ -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
+ }
package/dist/relay.js CHANGED
@@ -241,6 +241,12 @@ function toBinaryChunk(data) {
241
241
  function parseRelayControlMessage(data) {
242
242
  return JSON.parse(data);
243
243
  }
244
+ function relayControlErrorMessage(message) {
245
+ const detail = message.message || 'relay websocket error';
246
+ return message.status
247
+ ? `relay websocket error ${message.status}: ${detail}`
248
+ : detail;
249
+ }
244
250
  class PersistentRelaySession {
245
251
  config;
246
252
  affinity;
@@ -561,7 +567,7 @@ class PersistentRelaySession {
561
567
  }
562
568
  const error = message.status === 409
563
569
  ? new RelayStateMismatchError(message.message || 'state mismatch')
564
- : new Error(message.message || 'relay websocket error');
570
+ : new Error(relayControlErrorMessage(message));
565
571
  this.failPending(error);
566
572
  }
567
573
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",