@cortexkit/anthropic-auth-core 1.15.1 → 1.17.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.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { parseRetryAfterHeader, refreshClaudeOAuthToken } from "./auth.js";
6
6
  import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from "./constants.js";
7
7
  import { log, logger } from "./logger.js";
8
+ import { tokenFingerprint } from "./token-fingerprint.js";
8
9
  const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
9
10
  const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
10
11
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
@@ -124,6 +125,28 @@ function normalizeAccount(value) {
124
125
  lastRefreshError: normalizeOperationError(value.lastRefreshError),
125
126
  lastQuotaRefreshError: normalizeOperationError(value.lastQuotaRefreshError),
126
127
  quota: normalizeQuota(value.quota),
128
+ profile: normalizeOAuthAccountProfile(value.profile),
129
+ };
130
+ }
131
+ function normalizeOAuthAccountProfile(value) {
132
+ if (!isRecord(value))
133
+ return undefined;
134
+ if (typeof value.tier !== 'string' ||
135
+ !value.tier.trim() ||
136
+ typeof value.orgType !== 'string' ||
137
+ !value.orgType.trim() ||
138
+ typeof value.checkedAt !== 'number' ||
139
+ !Number.isFinite(value.checkedAt)) {
140
+ return undefined;
141
+ }
142
+ return {
143
+ tier: value.tier.trim(),
144
+ orgType: value.orgType.trim(),
145
+ checkedAt: value.checkedAt,
146
+ ...(typeof value.tokenFingerprint === 'string' &&
147
+ value.tokenFingerprint.trim() && {
148
+ tokenFingerprint: value.tokenFingerprint.trim(),
149
+ }),
127
150
  };
128
151
  }
129
152
  function normalizeOperationError(value) {
@@ -220,8 +243,56 @@ function normalizeQuota(value) {
220
243
  // — only inputs that already carried an array reach this line.
221
244
  quota.scoped = scoped;
222
245
  }
246
+ if (isRecord(value.extraUsage)) {
247
+ const used = normalizeQuotaMoney(value.extraUsage.used);
248
+ const limit = normalizeQuotaMoney(value.extraUsage.limit);
249
+ if (used && limit && typeof value.extraUsage.exhausted === 'boolean') {
250
+ quota.extraUsage = {
251
+ used,
252
+ limit,
253
+ ...(typeof value.extraUsage.utilizationPercent === 'number' &&
254
+ Number.isFinite(value.extraUsage.utilizationPercent) && {
255
+ utilizationPercent: value.extraUsage.utilizationPercent,
256
+ }),
257
+ ...(typeof value.extraUsage.severity === 'string' && {
258
+ severity: value.extraUsage.severity,
259
+ }),
260
+ exhausted: value.extraUsage.exhausted,
261
+ };
262
+ }
263
+ }
264
+ if (typeof value.bindingWindow === 'string' && value.bindingWindow.trim()) {
265
+ quota.bindingWindow = value.bindingWindow.trim();
266
+ }
267
+ if (value.bindingWindowSource === 'poll' ||
268
+ value.bindingWindowSource === 'headers') {
269
+ quota.bindingWindowSource = value.bindingWindowSource;
270
+ }
271
+ if (typeof value.fallbackAdvised === 'boolean') {
272
+ quota.fallbackAdvised = value.fallbackAdvised;
273
+ }
274
+ if (value.source === 'poll' || value.source === 'headers') {
275
+ quota.source = value.source;
276
+ }
223
277
  return Object.keys(quota).length ? quota : undefined;
224
278
  }
279
+ function normalizeQuotaMoney(value) {
280
+ if (!isRecord(value))
281
+ return undefined;
282
+ if (typeof value.amountMinor !== 'number' ||
283
+ !Number.isFinite(value.amountMinor) ||
284
+ typeof value.currency !== 'string' ||
285
+ !value.currency.trim() ||
286
+ typeof value.exponent !== 'number' ||
287
+ !Number.isFinite(value.exponent)) {
288
+ return undefined;
289
+ }
290
+ return {
291
+ amountMinor: value.amountMinor,
292
+ currency: value.currency.trim(),
293
+ exponent: value.exponent,
294
+ };
295
+ }
225
296
  // Fresh empty storage shell — main OpenCode OAuth account, no fallback
226
297
  // accounts. Returns a new object each call so mutating callers don't alias.
227
298
  export function createEmptyStorage() {
@@ -236,7 +307,11 @@ function normalizeStorage(value) {
236
307
  return null;
237
308
  return {
238
309
  version: 1,
239
- main: { type: 'opencode', provider: 'anthropic' },
310
+ main: {
311
+ type: 'opencode',
312
+ provider: 'anthropic',
313
+ profile: normalizeOAuthAccountProfile(isRecord(value.main) ? value.main.profile : undefined),
314
+ },
240
315
  routing: isRecord(value.routing) ? value.routing : undefined,
241
316
  fallbackOn: Array.isArray(value.fallbackOn)
242
317
  ? value.fallbackOn.filter((status) => Number.isInteger(status))
@@ -330,6 +405,11 @@ function mergeConfigAndState(configValue, stateValue) {
330
405
  : [];
331
406
  return {
332
407
  ...configValue,
408
+ main: {
409
+ type: 'opencode',
410
+ provider: 'anthropic',
411
+ profile: normalizeOAuthAccountProfile(mainState?.profile),
412
+ },
333
413
  refresh: objectWithDefinedEntries({
334
414
  ...refreshConfig,
335
415
  mainLastRefreshError: mainRefreshSource.lastRefreshError,
@@ -392,52 +472,127 @@ function accountRuntimeState(account) {
392
472
  lastRefreshError: account.lastRefreshError,
393
473
  lastQuotaRefreshError: account.lastQuotaRefreshError,
394
474
  quota: account.quota,
475
+ profile: account.profile,
395
476
  });
396
477
  }
397
478
  function quotaSnapshotCheckedAt(quota) {
398
479
  return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []), quota?.checkedAt ?? 0);
399
480
  }
481
+ function quotaSourcePrecedence(quota) {
482
+ if (quota?.source === 'poll')
483
+ return 2;
484
+ if (quota?.source === 'headers')
485
+ return 1;
486
+ return 0;
487
+ }
488
+ function mergeHeaderScopedQuota(existing, incoming) {
489
+ if (!('scoped' in existing))
490
+ return incoming.scoped;
491
+ if (!Array.isArray(existing.scoped) || existing.scoped.length === 0) {
492
+ return existing.scoped;
493
+ }
494
+ if (!Array.isArray(incoming.scoped) || incoming.scoped.length === 0) {
495
+ return existing.scoped;
496
+ }
497
+ const merged = new Map(incoming.scoped.map((window) => [window.id, window]));
498
+ for (const window of existing.scoped) {
499
+ const candidate = merged.get(window.id);
500
+ if (!candidate || window.checkedAt >= candidate.checkedAt) {
501
+ merged.set(window.id, window);
502
+ }
503
+ }
504
+ return [...merged.values()];
505
+ }
506
+ function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
507
+ const existing = existingSnapshot[key];
508
+ const incoming = incomingSnapshot[key];
509
+ if (!incoming)
510
+ return existing;
511
+ if (!existing)
512
+ return incoming;
513
+ if (incoming.checkedAt > existing.checkedAt)
514
+ return incoming;
515
+ if (incoming.checkedAt < existing.checkedAt)
516
+ return existing;
517
+ return quotaSourcePrecedence(existingSnapshot) >
518
+ quotaSourcePrecedence(incomingSnapshot)
519
+ ? existing
520
+ : incoming;
521
+ }
522
+ function mergeHeaderQuotaForPersistence(existing, incoming) {
523
+ if (!existing || incoming.source !== 'headers')
524
+ return incoming;
525
+ const preservePollBinding = existing.bindingWindowSource === 'poll';
526
+ return {
527
+ ...existing,
528
+ ...incoming,
529
+ five_hour: mergeHeaderOwnedWindow(existing, incoming, 'five_hour'),
530
+ seven_day: mergeHeaderOwnedWindow(existing, incoming, 'seven_day'),
531
+ scoped: mergeHeaderScopedQuota(existing, incoming),
532
+ extraUsage: existing.extraUsage ?? incoming.extraUsage,
533
+ bindingWindow: preservePollBinding
534
+ ? existing.bindingWindow
535
+ : (incoming.bindingWindow ?? existing.bindingWindow),
536
+ bindingWindowSource: preservePollBinding
537
+ ? 'poll'
538
+ : (incoming.bindingWindowSource ?? existing.bindingWindowSource),
539
+ };
540
+ }
400
541
  function mergeAccountRuntimeState(existing, incoming) {
401
542
  if (!isRecord(existing))
402
543
  return incoming;
403
544
  const existingEntry = existing;
404
- const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
405
- const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(incoming.quota);
406
545
  const tokenChanged = Boolean((existingEntry.access &&
407
546
  incoming.access &&
408
547
  existingEntry.access !== incoming.access) ||
409
548
  (existingEntry.refresh &&
410
549
  incoming.refresh &&
411
550
  existingEntry.refresh !== incoming.refresh));
412
- if (existingQuotaCheckedAt > incomingQuotaCheckedAt) {
551
+ const mergesHeaderQuota = Boolean(!tokenChanged && incoming.quota?.source === 'headers');
552
+ const effectiveIncoming = mergesHeaderQuota && incoming.quota
553
+ ? {
554
+ ...incoming,
555
+ quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incoming.quota),
556
+ }
557
+ : incoming;
558
+ const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
559
+ const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota);
560
+ const existingQuotaWinsEqualTimestamp = Boolean(existingQuotaCheckedAt === incomingQuotaCheckedAt &&
561
+ quotaSourcePrecedence(existingEntry.quota) >
562
+ quotaSourcePrecedence(effectiveIncoming.quota));
563
+ if (!mergesHeaderQuota &&
564
+ (existingQuotaCheckedAt > incomingQuotaCheckedAt ||
565
+ existingQuotaWinsEqualTimestamp)) {
413
566
  const existingRefreshAt = existingEntry.lastRefreshedAt ?? 0;
414
- const incomingRefreshAt = incoming.lastRefreshedAt ?? 0;
567
+ const incomingRefreshAt = effectiveIncoming.lastRefreshedAt ?? 0;
415
568
  if (tokenChanged && incomingRefreshAt <= existingRefreshAt) {
416
569
  const merged = { ...existingEntry };
417
- if (typeof incoming.lastUsed === 'number' &&
570
+ if (typeof effectiveIncoming.lastUsed === 'number' &&
418
571
  (!(typeof existingEntry.lastUsed === 'number') ||
419
- incoming.lastUsed > existingEntry.lastUsed)) {
420
- merged.lastUsed = incoming.lastUsed;
572
+ effectiveIncoming.lastUsed > existingEntry.lastUsed)) {
573
+ merged.lastUsed = effectiveIncoming.lastUsed;
421
574
  }
422
575
  return merged;
423
576
  }
424
- const merged = { ...existingEntry, ...incoming };
577
+ const merged = {
578
+ ...existingEntry,
579
+ ...effectiveIncoming,
580
+ };
425
581
  if (tokenChanged) {
426
- if ('quota' in incoming) {
427
- merged.quota = existingEntry.quota;
428
- if ('lastQuotaRefreshError' in existingEntry) {
429
- merged.lastQuotaRefreshError = existingEntry.lastQuotaRefreshError;
430
- }
431
- else {
432
- delete merged.lastQuotaRefreshError;
433
- }
582
+ if (!('profile' in effectiveIncoming))
583
+ delete merged.profile;
584
+ if (effectiveIncoming.quota?.source) {
585
+ merged.quota = effectiveIncoming.quota;
434
586
  }
435
587
  else {
436
588
  delete merged.quota;
589
+ }
590
+ if (!('lastQuotaRefreshError' in effectiveIncoming)) {
437
591
  delete merged.lastQuotaRefreshError;
438
592
  }
439
- if (!('lastRefreshError' in incoming))
593
+ if (!('lastRefreshError' in effectiveIncoming)) {
440
594
  delete merged.lastRefreshError;
595
+ }
441
596
  return merged;
442
597
  }
443
598
  return {
@@ -446,11 +601,20 @@ function mergeAccountRuntimeState(existing, incoming) {
446
601
  lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
447
602
  };
448
603
  }
449
- const merged = { ...existingEntry, ...incoming };
450
- if (!('lastQuotaRefreshError' in incoming)) {
604
+ const merged = {
605
+ ...existingEntry,
606
+ ...effectiveIncoming,
607
+ };
608
+ if (tokenChanged) {
609
+ if (!('profile' in effectiveIncoming))
610
+ delete merged.profile;
611
+ if (!effectiveIncoming.quota?.source)
612
+ delete merged.quota;
613
+ }
614
+ if (!('lastQuotaRefreshError' in effectiveIncoming)) {
451
615
  delete merged.lastQuotaRefreshError;
452
616
  }
453
- if (!('lastRefreshError' in incoming)) {
617
+ if (!('lastRefreshError' in effectiveIncoming)) {
454
618
  delete merged.lastRefreshError;
455
619
  }
456
620
  return merged;
@@ -475,7 +639,7 @@ function configFromStorage(storage) {
475
639
  : undefined;
476
640
  return omitUndefinedTopLevel({
477
641
  version: 1,
478
- main: storage.main,
642
+ main: { type: 'opencode', provider: 'anthropic' },
479
643
  routing: storage.routing,
480
644
  fallbackOn: storage.fallbackOn,
481
645
  refresh,
@@ -517,32 +681,142 @@ async function writeJsonAtomic(path, value) {
517
681
  throw error;
518
682
  }
519
683
  }
520
- export function saveAccounts(storage, path = getAccountStoragePath()) {
521
- const resolvedPath = path;
522
- return enqueueSave(() => saveAccountsLocked(storage, resolvedPath));
523
- }
524
- async function saveAccountsLocked(storage, path) {
525
- const existing = await loadExistingTopLevelFields(path);
526
- const nextConfig = { ...existing, ...configFromStorage(storage) };
527
- await writeJsonAtomic(path, nextConfig);
528
- await saveAccountStateUnlocked(storage, path, {
529
- mainQuota: true,
530
- mainRefresh: true,
531
- accounts: true,
684
+ function sameAccountIdentity(left, right) {
685
+ return (left.id === right.id ||
686
+ Boolean(left.label && right.label && left.label === right.label));
687
+ }
688
+ function mergeAccountsForSave(existing, incoming, options) {
689
+ const removedIds = new Set(options.removedAccountIds ?? []);
690
+ const current = existing.filter((account) => !removedIds.has(account.id));
691
+ const next = incoming.filter((account) => !removedIds.has(account.id));
692
+ const missing = current.filter((account) => !next.some((candidate) => sameAccountIdentity(candidate, account)));
693
+ if (!missing.length)
694
+ return [...next];
695
+ if (options.preserveExistingAccountOrder === false) {
696
+ return [...next, ...missing];
697
+ }
698
+ const usedIncoming = new Set();
699
+ const merged = current.map((account) => {
700
+ const index = next.findIndex((candidate, candidateIndex) => !usedIncoming.has(candidateIndex) &&
701
+ sameAccountIdentity(candidate, account));
702
+ const candidate = next[index];
703
+ if (!candidate)
704
+ return account;
705
+ usedIncoming.add(index);
706
+ return candidate;
707
+ });
708
+ for (let index = 0; index < next.length; index++) {
709
+ const candidate = next[index];
710
+ if (!usedIncoming.has(index) && candidate)
711
+ merged.push(candidate);
712
+ }
713
+ return merged;
714
+ }
715
+ const ACCOUNT_CONFIG_LOCK_TTL_MS = 10_000;
716
+ const ACCOUNT_CONFIG_LOCK_WAIT_MS = 12_000;
717
+ const ACCOUNT_STATE_LOCK_TTL_MS = 10_000;
718
+ const ACCOUNT_STATE_LOCK_WAIT_MS = 12_000;
719
+ async function acquireAccountWriteLock(input) {
720
+ const { path, name, ttlMs, waitMs, description } = input;
721
+ await mkdir(dirname(path), { recursive: true });
722
+ const deadline = Date.now() + waitMs;
723
+ while (true) {
724
+ const lock = await acquireRefreshFileLock({
725
+ name,
726
+ ttlMs,
727
+ path,
728
+ renew: true,
729
+ });
730
+ if (lock)
731
+ return lock;
732
+ if (Date.now() >= deadline) {
733
+ throw new Error(`Timed out waiting for the account ${description} lock`);
734
+ }
735
+ await new Promise((resolve) => setTimeout(resolve, 25));
736
+ }
737
+ }
738
+ function acquireAccountConfigWriteLock(path) {
739
+ return acquireAccountWriteLock({
740
+ path,
741
+ name: 'config-write',
742
+ ttlMs: ACCOUNT_CONFIG_LOCK_TTL_MS,
743
+ waitMs: ACCOUNT_CONFIG_LOCK_WAIT_MS,
744
+ description: 'configuration write',
532
745
  });
533
746
  }
747
+ function acquireAccountStateWriteLock(path) {
748
+ return acquireAccountWriteLock({
749
+ path,
750
+ name: 'state-write',
751
+ ttlMs: ACCOUNT_STATE_LOCK_TTL_MS,
752
+ waitMs: ACCOUNT_STATE_LOCK_WAIT_MS,
753
+ description: 'state write',
754
+ });
755
+ }
756
+ export function saveAccounts(storage, path = getAccountStoragePath(), options = {}) {
757
+ const resolvedPath = path;
758
+ return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
759
+ }
760
+ async function saveAccountsLocked(storage, path, options) {
761
+ const lock = await acquireAccountConfigWriteLock(path);
762
+ try {
763
+ const current = await loadAccounts(path);
764
+ const nextStorage = {
765
+ ...storage,
766
+ accounts: mergeAccountsForSave(current?.accounts ?? [], storage.accounts, options),
767
+ };
768
+ const existing = await loadExistingTopLevelFields(path);
769
+ const nextConfig = { ...existing, ...configFromStorage(nextStorage) };
770
+ await writeJsonAtomic(path, nextConfig);
771
+ // Config precedes state everywhere both locks are needed; reversing this
772
+ // order can deadlock profile mutations against full account saves.
773
+ const stateLock = await acquireAccountStateWriteLock(path);
774
+ try {
775
+ await saveAccountStateUnlocked(nextStorage, path, {
776
+ mainQuota: true,
777
+ mainRefresh: true,
778
+ accounts: true,
779
+ });
780
+ }
781
+ finally {
782
+ await stateLock.release();
783
+ }
784
+ }
785
+ finally {
786
+ await lock.release();
787
+ }
788
+ }
789
+ function applyMainProfileStatePatch(state, storage) {
790
+ state.main = state.main ?? {};
791
+ state.main.profile = storage.main?.profile;
792
+ }
534
793
  function applyMainQuotaStatePatch(state, storage) {
535
794
  state.main = state.main ?? {};
795
+ const incomingQuota = storage.quota?.mainQuota;
796
+ const sameToken = Boolean(state.main.quotaToken &&
797
+ storage.quota?.mainQuotaToken &&
798
+ state.main.quotaToken === storage.quota.mainQuotaToken);
799
+ const effectiveIncomingQuota = sameToken && incomingQuota?.source === 'headers'
800
+ ? mergeHeaderQuotaForPersistence(state.main.quota, incomingQuota)
801
+ : incomingQuota;
802
+ const mergesHeaderQuota = Boolean(sameToken && incomingQuota?.source === 'headers');
536
803
  const existingCheckedAt = typeof state.main.quotaCheckedAt === 'number'
537
804
  ? state.main.quotaCheckedAt
538
805
  : quotaSnapshotCheckedAt(state.main.quota);
539
806
  const incomingCheckedAt = typeof storage.quota?.mainQuotaCheckedAt === 'number'
540
807
  ? storage.quota.mainQuotaCheckedAt
541
- : quotaSnapshotCheckedAt(storage.quota?.mainQuota);
542
- if (existingCheckedAt > incomingCheckedAt)
808
+ : quotaSnapshotCheckedAt(effectiveIncomingQuota);
809
+ if (!mergesHeaderQuota &&
810
+ (existingCheckedAt > incomingCheckedAt ||
811
+ (existingCheckedAt === incomingCheckedAt &&
812
+ quotaSourcePrecedence(state.main.quota) >
813
+ quotaSourcePrecedence(effectiveIncomingQuota)))) {
543
814
  return;
544
- state.main.quota = storage.quota?.mainQuota;
545
- state.main.quotaCheckedAt = storage.quota?.mainQuotaCheckedAt;
815
+ }
816
+ state.main.quota = effectiveIncomingQuota;
817
+ state.main.quotaCheckedAt = mergesHeaderQuota
818
+ ? Math.max(existingCheckedAt, incomingCheckedAt)
819
+ : storage.quota?.mainQuotaCheckedAt;
546
820
  state.main.quotaToken = storage.quota?.mainQuotaToken;
547
821
  state.main.lastQuotaApiError = storage.quota?.mainLastQuotaApiError;
548
822
  }
@@ -563,12 +837,97 @@ function pruneUndefined(value) {
563
837
  .map(([key, entry]) => [key, pruneUndefined(entry)]));
564
838
  }
565
839
  export function saveAccountState(storage, path = getAccountStoragePath(), scope = {
840
+ mainProfile: true,
566
841
  mainQuota: true,
567
842
  mainRefresh: true,
568
843
  accounts: true,
569
844
  }) {
570
845
  const resolvedPath = path;
571
- return enqueueSave(() => saveAccountStateUnlocked(storage, resolvedPath, scope));
846
+ return enqueueSave(async () => {
847
+ const lock = await acquireAccountStateWriteLock(resolvedPath);
848
+ try {
849
+ await saveAccountStateUnlocked(storage, resolvedPath, scope);
850
+ }
851
+ finally {
852
+ await lock.release();
853
+ }
854
+ });
855
+ }
856
+ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
857
+ const resolvedPath = path;
858
+ return enqueueSave(async () => {
859
+ const configLock = await acquireAccountConfigWriteLock(resolvedPath);
860
+ try {
861
+ const stateLock = await acquireAccountStateWriteLock(resolvedPath);
862
+ try {
863
+ const current = await loadAccounts(resolvedPath);
864
+ const statePath = getAccountStatePath(resolvedPath);
865
+ const existing = (await readJsonIfPresent(statePath)).value;
866
+ const next = isRecord(existing)
867
+ ? { ...existing, version: 1 }
868
+ : { version: 1 };
869
+ const { accountId, expectedTokenFingerprint, profile } = input;
870
+ if (profile?.tokenFingerprint &&
871
+ profile.tokenFingerprint !== expectedTokenFingerprint) {
872
+ return false;
873
+ }
874
+ if (accountId === 'main') {
875
+ next.main = { ...(next.main ?? {}) };
876
+ const existingProfile = normalizeOAuthAccountProfile(next.main.profile);
877
+ const persistedToken = next.main.profileToken ?? existingProfile?.tokenFingerprint;
878
+ if (!profile &&
879
+ existingProfile &&
880
+ persistedToken === expectedTokenFingerprint) {
881
+ return false;
882
+ }
883
+ if (profile) {
884
+ if (persistedToken && persistedToken !== expectedTokenFingerprint) {
885
+ return false;
886
+ }
887
+ if (existingProfile &&
888
+ existingProfile.checkedAt > profile.checkedAt) {
889
+ return false;
890
+ }
891
+ }
892
+ next.main.profile = profile;
893
+ next.main.profileToken = expectedTokenFingerprint;
894
+ }
895
+ else {
896
+ const account = current?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
897
+ if (!account?.access ||
898
+ tokenFingerprint(account.access) !== expectedTokenFingerprint) {
899
+ return false;
900
+ }
901
+ next.accounts = {
902
+ ...(isRecord(next.accounts) ? next.accounts : {}),
903
+ };
904
+ const existingEntry = isRecord(next.accounts[accountId])
905
+ ? { ...next.accounts[accountId] }
906
+ : {};
907
+ const existingProfile = normalizeOAuthAccountProfile(existingEntry.profile);
908
+ if (!profile &&
909
+ existingProfile?.tokenFingerprint === expectedTokenFingerprint) {
910
+ return false;
911
+ }
912
+ if (profile &&
913
+ existingProfile &&
914
+ existingProfile.checkedAt > profile.checkedAt) {
915
+ return false;
916
+ }
917
+ existingEntry.profile = profile;
918
+ next.accounts[accountId] = existingEntry;
919
+ }
920
+ await writeJsonAtomic(statePath, pruneUndefined(next));
921
+ return true;
922
+ }
923
+ finally {
924
+ await stateLock.release();
925
+ }
926
+ }
927
+ finally {
928
+ await configLock.release();
929
+ }
930
+ });
572
931
  }
573
932
  async function saveAccountStateUnlocked(storage, path, scope) {
574
933
  const statePath = getAccountStatePath(path);
@@ -576,6 +935,8 @@ async function saveAccountStateUnlocked(storage, path, scope) {
576
935
  const next = isRecord(existing)
577
936
  ? { ...existing, version: 1 }
578
937
  : { version: 1 };
938
+ if (scope.mainProfile)
939
+ applyMainProfileStatePatch(next, storage);
579
940
  if (scope.mainQuota)
580
941
  applyMainQuotaStatePatch(next, storage);
581
942
  if (scope.mainRefresh)
@@ -877,13 +1238,27 @@ export async function setFastModePersistentEnabled(enabled, path = getAccountSto
877
1238
  export async function setCacheKeepPersistentWindow(startHour, endHour, path = getAccountStoragePath()) {
878
1239
  const storage = (await loadAccounts(path)) ?? createEmptyStorage();
879
1240
  storage.cacheKeep = {
1241
+ ...(storage.cacheKeep ?? {}),
880
1242
  enabled: true,
1243
+ always: false,
881
1244
  startHour,
882
1245
  endHour,
883
1246
  };
884
1247
  await saveAccounts(storage, path);
885
1248
  return storage;
886
1249
  }
1250
+ export async function setCacheKeepPersistentAlways(path = getAccountStoragePath()) {
1251
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1252
+ storage.cacheKeep = {
1253
+ ...(storage.cacheKeep ?? {}),
1254
+ enabled: true,
1255
+ always: true,
1256
+ };
1257
+ delete storage.cacheKeep.startHour;
1258
+ delete storage.cacheKeep.endHour;
1259
+ await saveAccounts(storage, path);
1260
+ return storage;
1261
+ }
887
1262
  export async function setCacheKeepPersistentEnabled(enabled, path = getAccountStoragePath()) {
888
1263
  const storage = (await loadAccounts(path)) ?? createEmptyStorage();
889
1264
  storage.cacheKeep = {
@@ -911,7 +1286,7 @@ function getFallbackStatuses(storage) {
911
1286
  export function shouldFallbackStatus(status, storage) {
912
1287
  return getFallbackStatuses(storage).includes(status);
913
1288
  }
914
- function normalizeThresholds(storage) {
1289
+ export function getQuotaMinimumRemainingThresholds(storage) {
915
1290
  const configured = storage?.quota?.minimumRemaining || {};
916
1291
  return {
917
1292
  five_hour: configured.five_hour ??
@@ -1141,7 +1516,7 @@ export function quotaSnapshotPassesModelScope(quota, model) {
1141
1516
  export function quotaSnapshotPassesPolicy(quota, storage) {
1142
1517
  if (!quotaEnabled(storage))
1143
1518
  return true;
1144
- const thresholds = normalizeThresholds(storage);
1519
+ const thresholds = getQuotaMinimumRemainingThresholds(storage);
1145
1520
  for (const key of ['five_hour', 'seven_day']) {
1146
1521
  const window = quota?.[key];
1147
1522
  if (!window)
@@ -1293,8 +1668,9 @@ export async function removeAccountPersistent(id, path = getAccountStoragePath()
1293
1668
  if (!storage)
1294
1669
  return false;
1295
1670
  const existed = removeAccount(storage, id);
1296
- if (existed)
1297
- await saveAccounts(storage, path);
1671
+ if (existed) {
1672
+ await saveAccounts(storage, path, { removedAccountIds: [id] });
1673
+ }
1298
1674
  return existed;
1299
1675
  }
1300
1676
  export async function reorderAccountsPersistent(orderedIds, path = getAccountStoragePath()) {
@@ -1302,7 +1678,7 @@ export async function reorderAccountsPersistent(orderedIds, path = getAccountSto
1302
1678
  if (!storage)
1303
1679
  return;
1304
1680
  reorderAccounts(storage, orderedIds);
1305
- await saveAccounts(storage, path);
1681
+ await saveAccounts(storage, path, { preserveExistingAccountOrder: false });
1306
1682
  }
1307
1683
  export async function setAccountEnabledPersistent(id, enabled, path = getAccountStoragePath()) {
1308
1684
  const storage = await loadAccounts(path);
@@ -1319,25 +1695,35 @@ export async function addAccountPersistent(account, path = getAccountStoragePath
1319
1695
  await saveAccounts(storage, path);
1320
1696
  }
1321
1697
  export function getQuotaNextRefreshAt(quota, storage, now) {
1698
+ const intervalMs = getQuotaCheckIntervalMs(storage);
1322
1699
  if (!quotaEnabled(storage))
1323
- return now + getQuotaCheckIntervalMs(storage);
1324
- const thresholds = normalizeThresholds(storage);
1700
+ return now + intervalMs;
1701
+ const windowFreshnessDeadline = Math.min(...[
1702
+ ...['five_hour', 'seven_day'].map((key) => quota?.[key]?.checkedAt),
1703
+ ...(quota?.scoped ?? []).map((window) => window.checkedAt),
1704
+ ]
1705
+ .filter((checkedAt) => Number.isFinite(checkedAt))
1706
+ .map((checkedAt) => checkedAt + intervalMs));
1707
+ const capAtOldestWindow = (candidate) => Number.isFinite(windowFreshnessDeadline)
1708
+ ? Math.min(candidate, windowFreshnessDeadline)
1709
+ : candidate;
1710
+ const thresholds = getQuotaMinimumRemainingThresholds(storage);
1325
1711
  const blockedResetTimes = [];
1326
1712
  for (const key of ['five_hour', 'seven_day']) {
1327
1713
  const window = quota?.[key];
1328
1714
  if (!window)
1329
- return now + getQuotaCheckIntervalMs(storage);
1715
+ return capAtOldestWindow(now + intervalMs);
1330
1716
  if (window.remainingPercent >= thresholds[key])
1331
1717
  continue;
1332
1718
  const resetTime = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN;
1333
1719
  if (!Number.isFinite(resetTime) || resetTime <= now) {
1334
- return now + getQuotaCheckIntervalMs(storage);
1720
+ return capAtOldestWindow(now + intervalMs);
1335
1721
  }
1336
1722
  blockedResetTimes.push(resetTime);
1337
1723
  }
1338
1724
  if (!blockedResetTimes.length)
1339
- return now + getQuotaCheckIntervalMs(storage);
1340
- return Math.min(...blockedResetTimes) + 60_000;
1725
+ return capAtOldestWindow(now + intervalMs);
1726
+ return capAtOldestWindow(Math.min(...blockedResetTimes) + 60_000);
1341
1727
  }
1342
1728
  function tokenNeedsRefresh(account, storage, now) {
1343
1729
  return (!account.access ||
@@ -1353,8 +1739,11 @@ function quotaSnapshotIsFresh(quota, storage, now) {
1353
1739
  return Boolean(window && now - window.checkedAt < maxAge);
1354
1740
  });
1355
1741
  }
1356
- function quotaIsStale(account, storage, now) {
1357
- return !quotaSnapshotIsFresh(account.quota, storage, now);
1742
+ function quotaIsStale(account, storage, now, modelId) {
1743
+ if (!quotaSnapshotIsFresh(account.quota, storage, now))
1744
+ return true;
1745
+ const scoped = getScopedQuotaWindowForModel(account.quota, modelId);
1746
+ return Boolean(scoped && now - scoped.checkedAt >= getQuotaCheckIntervalMs(storage));
1358
1747
  }
1359
1748
  function cachedQuotaWindowStillRelevant(window, now) {
1360
1749
  if (!window)
@@ -1450,6 +1839,61 @@ function mapScopedWeeklyLimits(limits, checkedAt) {
1450
1839
  }
1451
1840
  return scoped;
1452
1841
  }
1842
+ function mapExtraUsage(usage) {
1843
+ if (usage.extra_usage?.is_enabled !== true)
1844
+ return undefined;
1845
+ const usedAmount = usage.extra_usage.used_credits;
1846
+ const limitAmount = usage.extra_usage.monthly_limit;
1847
+ if (typeof usedAmount !== 'number' ||
1848
+ !Number.isFinite(usedAmount) ||
1849
+ typeof limitAmount !== 'number' ||
1850
+ !Number.isFinite(limitAmount)) {
1851
+ return undefined;
1852
+ }
1853
+ const rawCurrency = usage.spend?.limit?.currency;
1854
+ const currency = rawCurrency == null ? 'USD' : nonEmptyString(rawCurrency);
1855
+ const rawExponent = usage.spend?.limit?.exponent;
1856
+ const moneyExponent = rawExponent == null ? 2 : rawExponent;
1857
+ if (!currency ||
1858
+ !/^[A-Za-z]{3}$/.test(currency) ||
1859
+ !Number.isInteger(moneyExponent) ||
1860
+ moneyExponent < 0 ||
1861
+ moneyExponent > 20) {
1862
+ return undefined;
1863
+ }
1864
+ return {
1865
+ used: { amountMinor: usedAmount, currency, exponent: moneyExponent },
1866
+ limit: { amountMinor: limitAmount, currency, exponent: moneyExponent },
1867
+ ...(typeof usage.extra_usage.utilization === 'number' &&
1868
+ Number.isFinite(usage.extra_usage.utilization) && {
1869
+ utilizationPercent: usage.extra_usage.utilization,
1870
+ }),
1871
+ ...(nonEmptyString(usage.spend?.severity) && {
1872
+ severity: nonEmptyString(usage.spend?.severity),
1873
+ }),
1874
+ exhausted: usedAmount >= limitAmount,
1875
+ };
1876
+ }
1877
+ function mapBindingWindow(limits) {
1878
+ if (!Array.isArray(limits))
1879
+ return undefined;
1880
+ const active = limits.find((limit) => limit?.is_active === true);
1881
+ if (!active)
1882
+ return undefined;
1883
+ if (active.kind === 'session')
1884
+ return 'five_hour';
1885
+ if (active.kind === 'weekly_all')
1886
+ return 'seven_day';
1887
+ if (active.kind !== 'weekly_scoped' || active.group !== 'weekly') {
1888
+ return undefined;
1889
+ }
1890
+ const modelName = nonEmptyString(active.scope?.model?.display_name);
1891
+ if (!modelName)
1892
+ return undefined;
1893
+ const identity = nonEmptyString(active.scope?.model?.id) ?? modelName;
1894
+ const slug = slugForQuotaIdentity(identity);
1895
+ return slug ? `claude-weekly-scoped-${slug}` : undefined;
1896
+ }
1453
1897
  function mapUsageWindow(window, checkedAt) {
1454
1898
  if (typeof window?.utilization !== 'number')
1455
1899
  return undefined;
@@ -1485,10 +1929,17 @@ export async function fetchOAuthQuotaSnapshot(input) {
1485
1929
  }
1486
1930
  const checkedAt = input.now?.() ?? Date.now();
1487
1931
  const usage = (await response.json());
1932
+ const bindingWindow = mapBindingWindow(usage.limits);
1488
1933
  return {
1489
1934
  five_hour: mapUsageWindow(usage.five_hour, checkedAt),
1490
1935
  seven_day: mapUsageWindow(usage.seven_day, checkedAt),
1491
1936
  scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
1937
+ extraUsage: mapExtraUsage(usage),
1938
+ ...(bindingWindow && {
1939
+ bindingWindow,
1940
+ bindingWindowSource: 'poll',
1941
+ }),
1942
+ source: 'poll',
1492
1943
  checkedAt,
1493
1944
  };
1494
1945
  }
@@ -1507,6 +1958,7 @@ export function upsertAccount(storage, account) {
1507
1958
  addedAt: storage.accounts[index]?.addedAt ?? account.addedAt,
1508
1959
  ...(account.type === 'oauth' && {
1509
1960
  quota: account.quota,
1961
+ profile: account.profile,
1510
1962
  lastRefreshedAt: account.lastRefreshedAt,
1511
1963
  lastRefreshError: account.lastRefreshError,
1512
1964
  lastQuotaRefreshError: account.lastQuotaRefreshError,
@@ -1656,8 +2108,8 @@ export class FallbackAccountManager {
1656
2108
  }
1657
2109
  this.seedFallbackQuota(next, storage);
1658
2110
  const stale = this.quotaManager
1659
- ? this.quotaManager.isFallbackStale(next.id, next.access)
1660
- : quotaIsStale(next, storage, this.now());
2111
+ ? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId)
2112
+ : quotaIsStale(next, storage, this.now(), options.modelId);
1661
2113
  // Skip the request-time refresh when this account's quota API is
1662
2114
  // backed off (recent 429/5xx). Hitting it again would extend the
1663
2115
  // backoff; evaluate policy on the cached/seeded quota instead. Mirrors