@cortexkit/anthropic-auth-core 1.20.0 → 1.21.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,7 +5,6 @@ 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';
9
8
  const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
10
9
  const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
11
10
  export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
@@ -147,6 +146,10 @@ function normalizeOAuthAccountProfile(value) {
147
146
  tier: value.tier.trim(),
148
147
  orgType: value.orgType.trim(),
149
148
  checkedAt: value.checkedAt,
149
+ ...(typeof value.accountIdentity === 'string' &&
150
+ value.accountIdentity.trim() && {
151
+ accountIdentity: value.accountIdentity.trim(),
152
+ }),
150
153
  ...(typeof value.tokenFingerprint === 'string' &&
151
154
  value.tokenFingerprint.trim() && {
152
155
  tokenFingerprint: value.tokenFingerprint.trim(),
@@ -169,6 +172,9 @@ function normalizeOperationError(value) {
169
172
  checkedAt,
170
173
  nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
171
174
  retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
175
+ accountIdentity: typeof value.accountIdentity === 'string' && value.accountIdentity.trim()
176
+ ? value.accountIdentity.trim()
177
+ : undefined,
172
178
  tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
173
179
  // Preserve the dead-token discriminators across save/load. Without these,
174
180
  // a retry-exhausted transient (permanent=false, 24h backoff) would lose its
@@ -234,6 +240,10 @@ function normalizeQuota(value) {
234
240
  if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt)) {
235
241
  quota.checkedAt = value.checkedAt;
236
242
  }
243
+ if (typeof value.accountIdentity === 'string' &&
244
+ value.accountIdentity.trim()) {
245
+ quota.accountIdentity = value.accountIdentity.trim();
246
+ }
237
247
  if (Array.isArray(value.scoped)) {
238
248
  const scoped = value.scoped
239
249
  .map((entry) => {
@@ -332,6 +342,9 @@ function normalizeStorage(value) {
332
342
  return null;
333
343
  return {
334
344
  version: 1,
345
+ mainAccountId: typeof value.mainAccountId === 'string' && value.mainAccountId.trim()
346
+ ? value.mainAccountId.trim()
347
+ : undefined,
335
348
  main: {
336
349
  type: 'opencode',
337
350
  provider: 'anthropic',
@@ -343,6 +356,9 @@ function normalizeStorage(value) {
343
356
  : undefined,
344
357
  refresh: isRecord(value.refresh) ? value.refresh : undefined,
345
358
  quota: isRecord(value.quota) ? value.quota : undefined,
359
+ quotaHeaderFeed: isRecord(value.quotaHeaderFeed)
360
+ ? value.quotaHeaderFeed
361
+ : undefined,
346
362
  claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
347
363
  dump: isRecord(value.dump) ? value.dump : undefined,
348
364
  claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
@@ -403,6 +419,14 @@ function objectWithDefinedEntries(value) {
403
419
  function numericField(value) {
404
420
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
405
421
  }
422
+ // A clear is a monotonic tombstone; older or absent writes must not reopen backoff.
423
+ export function mergeMainQuotaErrorClearedAt(existing, incoming) {
424
+ if (incoming === undefined || !Number.isFinite(incoming))
425
+ return existing;
426
+ if (existing === undefined || !Number.isFinite(existing))
427
+ return incoming;
428
+ return Math.max(existing, incoming);
429
+ }
406
430
  function accountCredentialTimestamp(value) {
407
431
  return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
408
432
  }
@@ -447,6 +471,19 @@ function mergeConfigAndState(configValue, stateValue) {
447
471
  const refreshConfig = isRecord(configValue.refresh) ? configValue.refresh : {};
448
472
  const mainQuotaSource = mainState ?? quotaConfig;
449
473
  const mainRefreshSource = mainState ?? refreshConfig;
474
+ const mainQuotaErrorClearedAt = typeof mainState?.quotaErrorClearedAt === 'number' &&
475
+ Number.isFinite(mainState.quotaErrorClearedAt) &&
476
+ mainState.quotaErrorClearedAt >= 0
477
+ ? mainState.quotaErrorClearedAt
478
+ : undefined;
479
+ const configQuotaError = quotaConfig.mainLastQuotaApiError;
480
+ const mainLastQuotaApiError = mainState?.lastQuotaApiError ??
481
+ (mainQuotaErrorClearedAt !== undefined &&
482
+ isRecord(configQuotaError) &&
483
+ typeof configQuotaError.checkedAt === 'number' &&
484
+ configQuotaError.checkedAt <= mainQuotaErrorClearedAt
485
+ ? undefined
486
+ : configQuotaError);
450
487
  const accounts = Array.isArray(configValue.accounts)
451
488
  ? configValue.accounts.map((account) => {
452
489
  if (!isRecord(account))
@@ -476,7 +513,9 @@ function mergeConfigAndState(configValue, stateValue) {
476
513
  mainQuota: mainQuotaSource.quota,
477
514
  mainQuotaCheckedAt: mainQuotaSource.quotaCheckedAt,
478
515
  mainQuotaToken: mainQuotaSource.quotaToken,
479
- mainLastQuotaApiError: mainQuotaSource.lastQuotaApiError,
516
+ mainLastQuotaApiError,
517
+ mainQuotaErrorGeneration: mainQuotaSource.quotaErrorGeneration,
518
+ mainQuotaErrorClearedAt: mainQuotaSource.quotaErrorClearedAt,
480
519
  }),
481
520
  // Carry the main-side prime counters from the state file back into the
482
521
  // merged storage so a subsequent read sees the cumulative counters. The
@@ -610,7 +649,7 @@ function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
610
649
  ? existing
611
650
  : incoming;
612
651
  }
613
- function mergeHeaderQuotaForPersistence(existing, incoming) {
652
+ export function mergeHeaderQuotaForPersistence(existing, incoming) {
614
653
  if (!existing || incoming.source !== 'headers')
615
654
  return incoming;
616
655
  const preservePollBinding = existing.bindingWindowSource === 'poll';
@@ -730,11 +769,13 @@ function configFromStorage(storage) {
730
769
  : undefined;
731
770
  return omitUndefinedTopLevel({
732
771
  version: 1,
772
+ mainAccountId: storage.mainAccountId,
733
773
  main: { type: 'opencode', provider: 'anthropic' },
734
774
  routing: storage.routing,
735
775
  fallbackOn: storage.fallbackOn,
736
776
  refresh,
737
777
  quota,
778
+ quotaHeaderFeed: storage.quotaHeaderFeed,
738
779
  claudeCache: storage.claudeCache,
739
780
  dump: storage.dump,
740
781
  logging: storage.logging,
@@ -857,6 +898,27 @@ export function saveAccounts(storage, path = getAccountStoragePath(), options =
857
898
  const resolvedPath = path;
858
899
  return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
859
900
  }
901
+ export async function getOrCreateMainAccountId(path = getAccountStoragePath(), createId = randomUUID) {
902
+ return enqueueSave(async () => {
903
+ const lock = await acquireAccountConfigWriteLock(path);
904
+ try {
905
+ const storage = (await loadAccounts(path)) ?? createEmptyStorage();
906
+ if (storage.mainAccountId)
907
+ return storage.mainAccountId;
908
+ const mainAccountId = createId();
909
+ const nextStorage = { ...storage, mainAccountId };
910
+ const existing = await loadExistingTopLevelFields(path);
911
+ await writeJsonAtomic(path, {
912
+ ...existing,
913
+ ...configFromStorage(nextStorage),
914
+ });
915
+ return mainAccountId;
916
+ }
917
+ finally {
918
+ await lock.release();
919
+ }
920
+ });
921
+ }
860
922
  async function saveAccountsLocked(storage, path, options) {
861
923
  const lock = await acquireAccountConfigWriteLock(path);
862
924
  try {
@@ -892,6 +954,74 @@ function applyMainProfileStatePatch(state, storage) {
892
954
  }
893
955
  function applyMainQuotaStatePatch(state, storage) {
894
956
  state.main = state.main ?? {};
957
+ const incomingError = storage.quota?.mainLastQuotaApiError;
958
+ const incomingGeneration = typeof storage.quota?.mainQuotaErrorGeneration === 'number' &&
959
+ Number.isSafeInteger(storage.quota.mainQuotaErrorGeneration) &&
960
+ storage.quota.mainQuotaErrorGeneration >= 0
961
+ ? storage.quota.mainQuotaErrorGeneration
962
+ : undefined;
963
+ const incomingClearedAt = typeof storage.quota?.mainQuotaErrorClearedAt === 'number' &&
964
+ Number.isFinite(storage.quota.mainQuotaErrorClearedAt) &&
965
+ storage.quota.mainQuotaErrorClearedAt >= 0
966
+ ? storage.quota.mainQuotaErrorClearedAt
967
+ : undefined;
968
+ const existingGeneration = typeof state.main.quotaErrorGeneration === 'number' &&
969
+ Number.isSafeInteger(state.main.quotaErrorGeneration) &&
970
+ state.main.quotaErrorGeneration >= 0
971
+ ? state.main.quotaErrorGeneration
972
+ : 0;
973
+ const existingErrorObservedAt = typeof state.main.lastQuotaApiError?.checkedAt === 'number' &&
974
+ Number.isFinite(state.main.lastQuotaApiError.checkedAt) &&
975
+ state.main.lastQuotaApiError.checkedAt >= 0
976
+ ? state.main.lastQuotaApiError.checkedAt
977
+ : undefined;
978
+ const existingClearedAt = typeof state.main.quotaErrorClearedAt === 'number' &&
979
+ Number.isFinite(state.main.quotaErrorClearedAt) &&
980
+ state.main.quotaErrorClearedAt >= 0
981
+ ? state.main.quotaErrorClearedAt
982
+ : undefined;
983
+ const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
984
+ const incomingErrorObservedAt = typeof incomingError?.checkedAt === 'number' &&
985
+ Number.isFinite(incomingError.checkedAt) &&
986
+ incomingError.checkedAt >= 0
987
+ ? incomingError.checkedAt
988
+ : undefined;
989
+ // Error transitions are ordered by observation time, not write order. All
990
+ // instances share one host clock, so an error observed before a clear cannot
991
+ // resurrect that clear even when both processes allocated the same generation.
992
+ if (incomingError === undefined &&
993
+ incomingClearedAt !== undefined &&
994
+ incomingClearedAt >= existingObservedAt) {
995
+ state.main.lastQuotaApiError = undefined;
996
+ state.main.quotaErrorClearedAt = mergeMainQuotaErrorClearedAt(existingClearedAt, incomingClearedAt);
997
+ if (incomingGeneration !== undefined &&
998
+ incomingGeneration >= existingGeneration) {
999
+ state.main.quotaErrorGeneration = incomingGeneration;
1000
+ }
1001
+ }
1002
+ else if (incomingError) {
1003
+ const acceptsByObservation = incomingErrorObservedAt !== undefined &&
1004
+ incomingErrorObservedAt > existingObservedAt;
1005
+ const acceptsEqualObservation = incomingErrorObservedAt !== undefined &&
1006
+ incomingErrorObservedAt === existingObservedAt &&
1007
+ existingErrorObservedAt !== undefined &&
1008
+ existingClearedAt === undefined &&
1009
+ incomingGeneration !== undefined &&
1010
+ incomingGeneration >= existingGeneration;
1011
+ const acceptsLegacyGeneration = incomingErrorObservedAt === undefined &&
1012
+ incomingClearedAt === undefined &&
1013
+ incomingGeneration !== undefined &&
1014
+ incomingGeneration >= existingGeneration &&
1015
+ existingClearedAt === undefined;
1016
+ if (acceptsByObservation ||
1017
+ acceptsEqualObservation ||
1018
+ acceptsLegacyGeneration) {
1019
+ state.main.lastQuotaApiError = incomingError;
1020
+ if (incomingGeneration !== undefined) {
1021
+ state.main.quotaErrorGeneration = Math.max(existingGeneration, incomingGeneration);
1022
+ }
1023
+ }
1024
+ }
895
1025
  const incomingQuota = storage.quota?.mainQuota;
896
1026
  const sameToken = Boolean(state.main.quotaToken &&
897
1027
  storage.quota?.mainQuotaToken &&
@@ -918,7 +1048,6 @@ function applyMainQuotaStatePatch(state, storage) {
918
1048
  ? Math.max(existingCheckedAt, incomingCheckedAt)
919
1049
  : storage.quota?.mainQuotaCheckedAt;
920
1050
  state.main.quotaToken = storage.quota?.mainQuotaToken;
921
- state.main.lastQuotaApiError = storage.quota?.mainLastQuotaApiError;
922
1051
  }
923
1052
  function applyMainRefreshStatePatch(state, storage) {
924
1053
  state.main = state.main ?? {};
@@ -989,38 +1118,41 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
989
1118
  const next = isRecord(existing)
990
1119
  ? { ...existing, version: 1 }
991
1120
  : { version: 1 };
992
- const { accountId, expectedTokenFingerprint, profile } = input;
993
- if (profile?.tokenFingerprint &&
994
- profile.tokenFingerprint !== expectedTokenFingerprint) {
995
- return false;
996
- }
1121
+ const { accountId, accountIdentity, profile } = input;
997
1122
  if (accountId === 'main') {
998
1123
  next.main = { ...(next.main ?? {}) };
999
1124
  const existingProfile = normalizeOAuthAccountProfile(next.main.profile);
1000
- const persistedToken = next.main.profileToken ?? existingProfile?.tokenFingerprint;
1001
- if (!profile &&
1002
- existingProfile &&
1003
- persistedToken === expectedTokenFingerprint) {
1004
- return false;
1005
- }
1006
- if (profile) {
1007
- if (persistedToken && persistedToken !== expectedTokenFingerprint) {
1008
- return false;
1009
- }
1125
+ if (!profile) {
1010
1126
  if (existingProfile &&
1011
- existingProfile.checkedAt > profile.checkedAt) {
1127
+ (accountIdentity === undefined ||
1128
+ existingProfile.accountIdentity === undefined ||
1129
+ existingProfile.accountIdentity === accountIdentity)) {
1012
1130
  return false;
1013
1131
  }
1132
+ next.main.profile = undefined;
1133
+ return await writeJsonAtomic(statePath, pruneUndefined(next)).then(() => true);
1014
1134
  }
1015
- next.main.profile = profile;
1016
- next.main.profileToken = expectedTokenFingerprint;
1135
+ if (profile.accountIdentity !== undefined &&
1136
+ profile.accountIdentity !== accountIdentity) {
1137
+ return false;
1138
+ }
1139
+ const persistedProfile = accountIdentity === undefined ||
1140
+ profile.accountIdentity === accountIdentity
1141
+ ? profile
1142
+ : { ...profile, accountIdentity };
1143
+ if (existingProfile &&
1144
+ (accountIdentity === undefined ||
1145
+ existingProfile.accountIdentity === undefined ||
1146
+ existingProfile.accountIdentity === accountIdentity) &&
1147
+ existingProfile.checkedAt > persistedProfile.checkedAt) {
1148
+ return false;
1149
+ }
1150
+ next.main.profile = persistedProfile;
1017
1151
  }
1018
1152
  else {
1019
1153
  const account = current?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
1020
- if (!account?.access ||
1021
- tokenFingerprint(account.access) !== expectedTokenFingerprint) {
1154
+ if (!account)
1022
1155
  return false;
1023
- }
1024
1156
  next.accounts = {
1025
1157
  ...(isRecord(next.accounts) ? next.accounts : {}),
1026
1158
  };
@@ -1028,16 +1160,33 @@ export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
1028
1160
  ? { ...next.accounts[accountId] }
1029
1161
  : {};
1030
1162
  const existingProfile = normalizeOAuthAccountProfile(existingEntry.profile);
1031
- if (!profile &&
1032
- existingProfile?.tokenFingerprint === expectedTokenFingerprint) {
1033
- return false;
1163
+ if (!profile) {
1164
+ if (existingProfile &&
1165
+ (accountIdentity === undefined ||
1166
+ existingProfile.accountIdentity === undefined ||
1167
+ existingProfile.accountIdentity === accountIdentity)) {
1168
+ return false;
1169
+ }
1170
+ existingEntry.profile = undefined;
1034
1171
  }
1035
- if (profile &&
1036
- existingProfile &&
1037
- existingProfile.checkedAt > profile.checkedAt) {
1038
- return false;
1172
+ else {
1173
+ if (profile.accountIdentity !== undefined &&
1174
+ profile.accountIdentity !== accountIdentity) {
1175
+ return false;
1176
+ }
1177
+ const persistedProfile = accountIdentity === undefined ||
1178
+ profile.accountIdentity === accountIdentity
1179
+ ? profile
1180
+ : { ...profile, accountIdentity };
1181
+ if (existingProfile &&
1182
+ (accountIdentity === undefined ||
1183
+ existingProfile.accountIdentity === undefined ||
1184
+ existingProfile.accountIdentity === accountIdentity) &&
1185
+ existingProfile.checkedAt > persistedProfile.checkedAt) {
1186
+ return false;
1187
+ }
1188
+ existingEntry.profile = persistedProfile;
1039
1189
  }
1040
- existingEntry.profile = profile;
1041
1190
  next.accounts[accountId] = existingEntry;
1042
1191
  }
1043
1192
  await writeJsonAtomic(statePath, pruneUndefined(next));
@@ -1421,12 +1570,8 @@ export async function setPrimePersistentEnabled(enabled, path = getAccountStorag
1421
1570
  await saveAccounts(storage, path);
1422
1571
  return storage;
1423
1572
  }
1424
- /**
1425
- * Return the stable prime marker identity for an OAuth account, seeding it in
1426
- * runtime state when older storage has no lineage yet. Main lookups return
1427
- * undefined when the live refresh token is unavailable and no lineage exists.
1428
- */
1429
- export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccountStoragePath(), mainRefreshToken) {
1573
+ /** Return the stable prime marker identity for an OAuth account. */
1574
+ export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccountStoragePath()) {
1430
1575
  return enqueueSave(async () => {
1431
1576
  const configLock = await acquireAccountConfigWriteLock(path);
1432
1577
  try {
@@ -1435,52 +1580,23 @@ export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccount
1435
1580
  const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1436
1581
  if (accountId === 'main') {
1437
1582
  const existing = storage.prime?.mainAuthLineageId;
1438
- const observedFingerprint = mainRefreshToken
1439
- ? tokenFingerprint(mainRefreshToken)
1440
- : undefined;
1441
- const boundFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
1442
- if (existing) {
1443
- if (!observedFingerprint ||
1444
- boundFingerprint === observedFingerprint)
1445
- return existing;
1446
- if (boundFingerprint &&
1447
- storage.refresh?.mainRefreshLeaseUntil &&
1448
- storage.refresh.mainRefreshLeaseUntil > Date.now()) {
1449
- // The owner binds before publishing its rotated credential. An
1450
- // active lease makes either side of that handoff ambiguous, so
1451
- // suppress this tick instead of classifying it as a re-login.
1452
- return existing;
1453
- }
1454
- if (!boundFingerprint) {
1455
- // Legacy lineages predate refresh-token binding. The first
1456
- // observation attaches the credential without changing markers.
1457
- storage.prime = {
1458
- ...(storage.prime ?? {}),
1459
- mainAuthLineageRefreshTokenFingerprint: observedFingerprint,
1460
- };
1461
- await saveAccountStateUnlocked(storage, path, {
1462
- mainPrime: true,
1463
- });
1464
- return existing;
1465
- }
1583
+ if (existing)
1584
+ return existing;
1585
+ const mainAccountId = storage.mainAccountId ?? randomUUID();
1586
+ if (!storage.mainAccountId) {
1587
+ const existingConfig = await loadExistingTopLevelFields(path);
1588
+ await writeJsonAtomic(path, {
1589
+ ...existingConfig,
1590
+ ...configFromStorage({ ...storage, mainAccountId }),
1591
+ });
1592
+ storage.mainAccountId = mainAccountId;
1466
1593
  }
1467
- // Without the live host credential there is no safe way to bind a
1468
- // new identity. Skip this tick rather than creating an unbound
1469
- // namespace that could churn or suppress another account.
1470
- if (!observedFingerprint)
1471
- return undefined;
1472
- // A bound mismatch that did not pass through the owned refresh path
1473
- // is a host credential replacement and needs a fresh marker identity.
1474
- const authLineageId = randomUUID();
1475
1594
  storage.prime = {
1476
1595
  ...(storage.prime ?? {}),
1477
- mainAuthLineageId: authLineageId,
1478
- ...(observedFingerprint && {
1479
- mainAuthLineageRefreshTokenFingerprint: observedFingerprint,
1480
- }),
1596
+ mainAuthLineageId: mainAccountId,
1481
1597
  };
1482
1598
  await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1483
- return authLineageId;
1599
+ return mainAccountId;
1484
1600
  }
1485
1601
  const account = storage.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
1486
1602
  if (!account) {
@@ -1507,42 +1623,6 @@ export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccount
1507
1623
  }
1508
1624
  });
1509
1625
  }
1510
- /** Advance a main lineage across a refresh performed by this plugin. */
1511
- export async function continueMainPrimeAuthLineageAfterRefresh(input, path = getAccountStoragePath()) {
1512
- return enqueueSave(async () => {
1513
- const configLock = await acquireAccountConfigWriteLock(path);
1514
- try {
1515
- const stateLock = await acquireAccountStateWriteLock(path);
1516
- try {
1517
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1518
- const authLineageId = storage.prime?.mainAuthLineageId;
1519
- if (!authLineageId)
1520
- return;
1521
- const previousFingerprint = tokenFingerprint(input.previousRefreshToken);
1522
- const currentFingerprint = tokenFingerprint(input.currentRefreshToken);
1523
- const boundFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
1524
- if (boundFingerprint &&
1525
- boundFingerprint !== previousFingerprint &&
1526
- boundFingerprint !== currentFingerprint) {
1527
- // Another process observed a host replacement after this refresh
1528
- // started; the stale rotation must not rebind that newer lineage.
1529
- return;
1530
- }
1531
- storage.prime = {
1532
- ...(storage.prime ?? {}),
1533
- mainAuthLineageRefreshTokenFingerprint: currentFingerprint,
1534
- };
1535
- await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1536
- }
1537
- finally {
1538
- await stateLock.release();
1539
- }
1540
- }
1541
- finally {
1542
- await configLock.release();
1543
- }
1544
- });
1545
- }
1546
1626
  /**
1547
1627
  * Atomically increment an account's cumulative prime counters and persist via
1548
1628
  * the scoped runtime-state path. The `main` account lives at state.main.prime;
@@ -1659,9 +1739,8 @@ function isTransientRefreshError(error) {
1659
1739
  error.code === 'UND_ERR_CONNECT_TIMEOUT')));
1660
1740
  }
1661
1741
  export function buildRefreshOperationError(input) {
1662
- const tokenHash = hashRefreshToken(input.refreshToken);
1663
- const previousRetryCount = input.previous?.tokenHash === tokenHash
1664
- ? (input.previous.retryCount ?? 0)
1742
+ const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
1743
+ ? (input.previous?.retryCount ?? 0)
1665
1744
  : 0;
1666
1745
  const retryCount = previousRetryCount + 1;
1667
1746
  const retryAfterFromError = input.error
@@ -1696,7 +1775,7 @@ export function buildRefreshOperationError(input) {
1696
1775
  checkedAt: input.now,
1697
1776
  nextRetryAt: input.now + delay,
1698
1777
  retryCount,
1699
- tokenHash,
1778
+ accountIdentity: input.accountIdentity,
1700
1779
  status,
1701
1780
  permanent: status === 400 && isInvalidGrant,
1702
1781
  };
@@ -1729,12 +1808,14 @@ export function isPermanentRefreshError(error) {
1729
1808
  }
1730
1809
  return false;
1731
1810
  }
1732
- export function refreshBackoffActive(error, refreshToken, now) {
1811
+ export function refreshBackoffActive(error, accountIdentity, now) {
1733
1812
  if (!error?.nextRetryAt || error.nextRetryAt <= now)
1734
1813
  return false;
1735
- if (!refreshToken)
1814
+ if (!error.accountIdentity)
1815
+ return true;
1816
+ if (!accountIdentity)
1736
1817
  return true;
1737
- return error.tokenHash === hashRefreshToken(refreshToken);
1818
+ return error.accountIdentity === accountIdentity;
1738
1819
  }
1739
1820
  export function formatRefreshBackoffMessage(error, now) {
1740
1821
  const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
@@ -1768,7 +1849,9 @@ export function isQuotaPolicyAuthError(error) {
1768
1849
  return /Claude quota check failed: 403\b/.test(formatErrorMessage(error));
1769
1850
  }
1770
1851
  export function buildQuotaOperationError(input) {
1771
- const previousRetryCount = input.previous?.retryCount ?? 0;
1852
+ const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
1853
+ ? (input.previous?.retryCount ?? 0)
1854
+ : 0;
1772
1855
  const retryCount = previousRetryCount + 1;
1773
1856
  const delay = isTransientQuotaError(input.error)
1774
1857
  ? Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
@@ -1778,6 +1861,9 @@ export function buildQuotaOperationError(input) {
1778
1861
  checkedAt: input.now,
1779
1862
  nextRetryAt: input.now + delay,
1780
1863
  retryCount,
1864
+ ...(input.accountIdentity !== undefined && {
1865
+ accountIdentity: input.accountIdentity,
1866
+ }),
1781
1867
  };
1782
1868
  }
1783
1869
  export function quotaBackoffActive(error, now) {
@@ -1813,6 +1899,7 @@ export function getPersistedMainQuota(storage) {
1813
1899
  quota: storage.quota.mainQuota,
1814
1900
  checkedAt: storage.quota.mainQuotaCheckedAt,
1815
1901
  tokenFingerprint: storage.quota.mainQuotaToken,
1902
+ accountIdentity: storage.quota.mainQuota.accountIdentity,
1816
1903
  };
1817
1904
  }
1818
1905
  /**
@@ -1879,6 +1966,9 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
1879
1966
  }
1880
1967
  return true;
1881
1968
  }
1969
+ export function quotaSnapshotHasStandardWindows(quota) {
1970
+ return Boolean(quota?.five_hour && quota.seven_day);
1971
+ }
1882
1972
  // ---------------------------------------------------------------------------
1883
1973
  // Killswitch — hard-block requests when remaining quota drops below per-account
1884
1974
  // thresholds, even if the API would still accept them.
@@ -2351,7 +2441,7 @@ function recordRefreshError(account, error, now) {
2351
2441
  account.lastRefreshError = buildRefreshOperationError({
2352
2442
  error,
2353
2443
  now,
2354
- refreshToken: account.refresh,
2444
+ accountIdentity: account.id,
2355
2445
  previous: account.lastRefreshError,
2356
2446
  });
2357
2447
  }
@@ -2406,7 +2496,7 @@ export class FallbackAccountManager {
2406
2496
  const checkedAt = quotaSnapshotCheckedAt(account.quota);
2407
2497
  if (checkedAt <= 0)
2408
2498
  return;
2409
- const existing = this.quotaManager.getFallback(account.id, account.access);
2499
+ const existing = this.quotaManager.getFallback(account.id, account);
2410
2500
  if (existing && existing.checkedAt >= checkedAt)
2411
2501
  return;
2412
2502
  const checkInterval = getQuotaCheckIntervalMs(storage);
@@ -2414,7 +2504,7 @@ export class FallbackAccountManager {
2414
2504
  quota: account.quota,
2415
2505
  refreshAfter: checkedAt + checkInterval,
2416
2506
  checkedAt,
2417
- }, account.access);
2507
+ }, account);
2418
2508
  }
2419
2509
  async load() {
2420
2510
  return loadAccounts(this.configPath);
@@ -2455,12 +2545,12 @@ export class FallbackAccountManager {
2455
2545
  for (const account of storage.accounts) {
2456
2546
  if (account.enabled === false || !isOAuthAccount(account))
2457
2547
  continue;
2548
+ let next = account;
2458
2549
  try {
2459
- let next = account;
2460
2550
  if (tokenNeedsRefresh(next, storage, this.now())) {
2461
2551
  const refreshError = next.lastRefreshError;
2462
2552
  if (refreshError &&
2463
- refreshBackoffActive(refreshError, next.refresh, this.now())) {
2553
+ refreshBackoffActive(refreshError, next.id, this.now())) {
2464
2554
  throw createRefreshBackoffActiveError(refreshError, this.now());
2465
2555
  }
2466
2556
  next = await this.refreshAccount(next, storage);
@@ -2468,7 +2558,7 @@ export class FallbackAccountManager {
2468
2558
  }
2469
2559
  this.seedFallbackQuota(next, storage);
2470
2560
  const stale = this.quotaManager
2471
- ? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId)
2561
+ ? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId, next)
2472
2562
  : quotaIsStale(next, storage, this.now(), options.modelId);
2473
2563
  // Skip the request-time refresh when this account's quota API is
2474
2564
  // backed off (recent 429/5xx). Hitting it again would extend the
@@ -2488,20 +2578,21 @@ export class FallbackAccountManager {
2488
2578
  usable.push(next);
2489
2579
  }
2490
2580
  catch (error) {
2491
- if (canUseCachedQuotaAfterRefreshError(account, storage, error, this.now())) {
2581
+ if (canUseCachedQuotaAfterRefreshError(next, storage, error, this.now())) {
2492
2582
  log('[refresh] fallback quota using cached quota after refresh error', {
2493
- accountId: account.id,
2583
+ accountId: next.id,
2494
2584
  error: formatErrorMessage(error),
2495
2585
  });
2496
- if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(account), storage, {
2586
+ if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
2497
2587
  modelId: options.modelId,
2498
2588
  })) {
2499
- usable.push(account);
2589
+ usable.push(next);
2500
2590
  }
2501
2591
  }
2502
2592
  else if (!failClosedOnUnknownQuota(storage) &&
2503
- quotaSnapshotPassesModelScope(account.quota, options.modelId)) {
2504
- usable.push(account);
2593
+ !refreshBackoffActive(next.lastRefreshError, next.id, this.now()) &&
2594
+ quotaSnapshotPassesModelScope(next.quota, options.modelId)) {
2595
+ usable.push(next);
2505
2596
  }
2506
2597
  }
2507
2598
  }
@@ -2532,7 +2623,7 @@ export class FallbackAccountManager {
2532
2623
  quotaPolicyAccount(account) {
2533
2624
  if (!this.quotaManager)
2534
2625
  return account;
2535
- const cached = this.quotaManager.getFallback(account.id, account.access)?.quota;
2626
+ const cached = this.quotaManager.getFallback(account.id, account)?.quota;
2536
2627
  return cached ? { ...account, quota: cached } : account;
2537
2628
  }
2538
2629
  async refreshDueAccounts() {
@@ -2545,7 +2636,7 @@ export class FallbackAccountManager {
2545
2636
  continue;
2546
2637
  if (!tokenNeedsRefresh(account, storage, this.now()))
2547
2638
  continue;
2548
- if (refreshBackoffActive(account.lastRefreshError, account.refresh, this.now())) {
2639
+ if (refreshBackoffActive(account.lastRefreshError, account.id, this.now())) {
2549
2640
  // Backoff skips are steady-state while a fallback account is waiting for
2550
2641
  // its next retry. Logging every background tick from every OpenCode
2551
2642
  // process creates noise without adding new diagnostic signal; the
@@ -2588,7 +2679,7 @@ export class FallbackAccountManager {
2588
2679
  let next = account;
2589
2680
  try {
2590
2681
  if (tokenNeedsRefresh(next, storage, this.now())) {
2591
- if (refreshBackoffActive(next.lastRefreshError, next.refresh, this.now())) {
2682
+ if (refreshBackoffActive(next.lastRefreshError, next.id, this.now())) {
2592
2683
  continue;
2593
2684
  }
2594
2685
  next = await this.refreshAccount(next, storage);
@@ -2635,7 +2726,7 @@ export class FallbackAccountManager {
2635
2726
  if (tokenNeedsRefresh(next, storage, this.now())) {
2636
2727
  const refreshError = next.lastRefreshError;
2637
2728
  if (refreshError &&
2638
- refreshBackoffActive(refreshError, next.refresh, this.now())) {
2729
+ refreshBackoffActive(refreshError, next.id, this.now())) {
2639
2730
  throw createRefreshBackoffActiveError(refreshError, this.now());
2640
2731
  }
2641
2732
  next = await this.refreshAccount(next, storage);
@@ -2709,7 +2800,7 @@ export class FallbackAccountManager {
2709
2800
  }
2710
2801
  const refreshError = latestAccount.lastRefreshError;
2711
2802
  if (refreshError &&
2712
- refreshBackoffActive(refreshError, latestAccount.refresh, this.now())) {
2803
+ refreshBackoffActive(refreshError, latestAccount.id, this.now())) {
2713
2804
  updateStoredAccount(storage, latestAccount);
2714
2805
  throw createRefreshBackoffActiveError(refreshError, this.now());
2715
2806
  }
@@ -2797,7 +2888,7 @@ export class FallbackAccountManager {
2797
2888
  // deduplication and 429 backoff gating around the same quota API. Fall back
2798
2889
  // to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
2799
2890
  const fetchSnapshot = (accessToken) => this.quotaManager
2800
- ? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken)
2891
+ ? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken, target)
2801
2892
  : fetchOAuthQuotaSnapshot({
2802
2893
  accessToken,
2803
2894
  fetchImpl: this.fetchImpl,
@@ -2853,7 +2944,7 @@ export class FallbackAccountManager {
2853
2944
  quota: target.quota,
2854
2945
  refreshAfter: now + getQuotaCheckIntervalMs(storage),
2855
2946
  checkedAt: now,
2856
- }, target.access);
2947
+ }, target);
2857
2948
  }
2858
2949
  return { account: target, fetched };
2859
2950
  }