@cortexkit/anthropic-auth-core 1.22.0 → 2.0.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.
Files changed (133) hide show
  1. package/README.md +2 -0
  2. package/dist/accounts.d.ts +52 -8
  3. package/dist/cachekeep.d.ts +27 -1
  4. package/dist/cch.d.ts +11 -1
  5. package/dist/claude-code.d.ts +23 -1
  6. package/dist/claustrum-enrollment.d.ts +72 -0
  7. package/dist/claustrum-scoped-roster.d.ts +27 -0
  8. package/dist/claustrum-scoped-runtime.d.ts +31 -0
  9. package/dist/claustrum-scoped.d.ts +53 -0
  10. package/dist/claustrum.d.ts +16 -82
  11. package/dist/commands/account.d.ts +38 -11
  12. package/dist/constants.d.ts +3 -2
  13. package/dist/custom-headers.d.ts +3 -0
  14. package/dist/index.d.ts +50 -0
  15. package/dist/index.js +29700 -31
  16. package/dist/internal-types/LICENSE +21 -0
  17. package/dist/internal-types/commands/builtins.d.ts +71 -0
  18. package/dist/internal-types/commands/index.d.ts +10 -0
  19. package/dist/internal-types/commands/menu.d.ts +61 -0
  20. package/dist/internal-types/commands/model.d.ts +238 -0
  21. package/dist/internal-types/commands/pi.d.ts +23 -0
  22. package/dist/internal-types/commands/seam.d.ts +72 -0
  23. package/dist/internal-types/fs/refresh-file-lock.d.ts +33 -0
  24. package/dist/internal-types/logger/capture-sink.d.ts +14 -0
  25. package/dist/internal-types/logger/engine.d.ts +100 -0
  26. package/dist/internal-types/logger/index.d.ts +6 -0
  27. package/dist/internal-types/logger/redact.d.ts +11 -0
  28. package/dist/internal-types/pi-slot/index.d.ts +3 -0
  29. package/dist/internal-types/pi-slot/slot.d.ts +49 -0
  30. package/dist/internal-types/quota/codec.d.ts +11 -0
  31. package/dist/internal-types/quota/format.d.ts +46 -0
  32. package/dist/internal-types/quota/index.d.ts +10 -0
  33. package/dist/internal-types/quota/map.d.ts +63 -0
  34. package/dist/internal-types/quota/merge.d.ts +48 -0
  35. package/dist/internal-types/quota/projection.d.ts +64 -0
  36. package/dist/internal-types/store/attribution.d.ts +28 -0
  37. package/dist/internal-types/store/errors.d.ts +55 -0
  38. package/dist/internal-types/store/hooks.d.ts +12 -0
  39. package/dist/internal-types/store/identity.d.ts +39 -0
  40. package/dist/internal-types/store/index.d.ts +21 -0
  41. package/dist/internal-types/store/mutate.d.ts +189 -0
  42. package/dist/internal-types/store/pool.d.ts +196 -0
  43. package/dist/internal-types/store/provider-state.d.ts +131 -0
  44. package/dist/internal-types/store/publication.d.ts +47 -0
  45. package/dist/internal-types/store/pull.d.ts +36 -0
  46. package/dist/internal-types/store/refresh-lock.d.ts +63 -0
  47. package/dist/internal-types/store/refresh.d.ts +72 -0
  48. package/dist/internal-types/store/rows.d.ts +313 -0
  49. package/dist/internal-types/store/runtime.d.ts +36 -0
  50. package/dist/internal-types/store/schema.d.ts +443 -0
  51. package/dist/internal-types/store/settings.d.ts +63 -0
  52. package/dist/internal-types/store/torn.d.ts +78 -0
  53. package/dist/migration-source.d.ts +33 -0
  54. package/dist/model-remap.d.ts +27 -0
  55. package/dist/models.d.ts +60 -0
  56. package/dist/native-account-runtime.d.ts +121 -0
  57. package/dist/native-account-view.d.ts +126 -0
  58. package/dist/native-command-effects.d.ts +59 -0
  59. package/dist/native-credential-validation.d.ts +42 -0
  60. package/dist/native-custody-activation.d.ts +45 -0
  61. package/dist/native-custody.d.ts +114 -0
  62. package/dist/native-enrollment-authority.d.ts +2 -0
  63. package/dist/native-host-auth-read.d.ts +3 -0
  64. package/dist/native-host-auth-write.d.ts +51 -0
  65. package/dist/native-host-auth.d.ts +12 -0
  66. package/dist/native-local-credential-service.d.ts +76 -0
  67. package/dist/native-local-runtime-readers.d.ts +22 -0
  68. package/dist/native-lock-observer.d.ts +12 -0
  69. package/dist/native-menu-executor.d.ts +243 -0
  70. package/dist/native-menu-model.d.ts +142 -0
  71. package/dist/native-migration-proof.d.ts +28 -0
  72. package/dist/native-migration-source.d.ts +47 -0
  73. package/dist/native-migration.d.ts +66 -0
  74. package/dist/native-quota-codec.d.ts +32 -0
  75. package/dist/native-refresh-coordinator.d.ts +240 -0
  76. package/dist/native-roster-seed.d.ts +115 -0
  77. package/dist/native-runtime-registry.d.ts +19 -0
  78. package/dist/native-runtime.d.ts +204 -0
  79. package/dist/native-transient-account.d.ts +38 -0
  80. package/dist/native-ui.d.ts +45 -0
  81. package/dist/native-usage-publication.d.ts +11 -0
  82. package/dist/native-vault-admission.d.ts +8 -0
  83. package/dist/native-vault-connection.d.ts +33 -0
  84. package/dist/native-vault-paths.d.ts +13 -0
  85. package/dist/native-vault-runtime.d.ts +102 -0
  86. package/dist/native-vault-seed.d.ts +16 -0
  87. package/dist/oauth-profile.d.ts +2 -1
  88. package/dist/pool-authority.d.ts +159 -0
  89. package/dist/pool-binding.d.ts +32 -0
  90. package/dist/pool-paths.d.ts +21 -0
  91. package/dist/pool-store.d.ts +32 -0
  92. package/dist/prime.d.ts +11 -3
  93. package/dist/provider-error.d.ts +139 -0
  94. package/dist/quota-header-feed.d.ts +13 -0
  95. package/dist/quota-manager.d.ts +30 -5
  96. package/dist/relay.d.ts +22 -1
  97. package/dist/request-history.d.ts +34 -0
  98. package/dist/sse.d.ts +81 -0
  99. package/dist/sticky-routing.d.ts +29 -0
  100. package/package.json +9 -3
  101. package/dist/accounts.js +0 -3330
  102. package/dist/auth.js +0 -182
  103. package/dist/cache1h.js +0 -97
  104. package/dist/cachekeep-registry.js +0 -124
  105. package/dist/cachekeep.js +0 -578
  106. package/dist/cch.js +0 -127
  107. package/dist/claude-code.js +0 -388
  108. package/dist/claustrum.js +0 -529
  109. package/dist/commands/account.js +0 -239
  110. package/dist/constants.js +0 -82
  111. package/dist/dump.js +0 -616
  112. package/dist/fast.js +0 -66
  113. package/dist/json.js +0 -10
  114. package/dist/killswitch.js +0 -132
  115. package/dist/logger.js +0 -286
  116. package/dist/logging.js +0 -59
  117. package/dist/mid-conversation-output-config.js +0 -89
  118. package/dist/models.js +0 -141
  119. package/dist/network-errors.js +0 -27
  120. package/dist/oauth-profile.js +0 -54
  121. package/dist/pkce.js +0 -17
  122. package/dist/prime.js +0 -823
  123. package/dist/provider.js +0 -0
  124. package/dist/quota-header-feed.js +0 -279
  125. package/dist/quota-headers.js +0 -121
  126. package/dist/quota-manager.js +0 -805
  127. package/dist/quotas.js +0 -141
  128. package/dist/relay.js +0 -1243
  129. package/dist/routing.js +0 -94
  130. package/dist/start.js +0 -29
  131. package/dist/sticky-routing.js +0 -523
  132. package/dist/thinking-binding.js +0 -54
  133. package/dist/token-fingerprint.js +0 -8
package/dist/accounts.js DELETED
@@ -1,3330 +0,0 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
- import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
- import { parseRetryAfterHeader, refreshClaudeOAuthToken } from './auth.js';
6
- import { CustodyTombstoneRefreshError } from './claustrum.js';
7
- import { CACHE_1H_MODES, CLAUDE_CODE_VERSION, DEFAULT_CACHE_1H_MODE, } from './constants.js';
8
- import { parseJsonRedacted } from './json.js';
9
- import { log, logger } from './logger.js';
10
- import { isTransientNetworkError } from './network-errors.js';
11
- import { tokenFingerprint } from './token-fingerprint.js';
12
- const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis);
13
- const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis);
14
- export const ACCOUNT_FILE_NAME = 'anthropic-auth.json';
15
- export const ACCOUNT_STATE_FILE_NAME = 'anthropic-auth-state.json';
16
- export const QUOTA_URL = 'https://api.anthropic.com/api/oauth/usage';
17
- export const QUOTA_FIELD_NAMES = [
18
- 'five_hour',
19
- 'seven_day',
20
- 'scoped',
21
- 'extraUsage',
22
- 'bindingWindow',
23
- 'fallbackAdvised',
24
- ];
25
- export function isOAuthAccount(account) {
26
- return account.type === 'oauth';
27
- }
28
- export function isApiKeyAccount(account) {
29
- return account.type === 'api';
30
- }
31
- export function isValidApiBaseURL(value) {
32
- const raw = value?.trim();
33
- if (!raw)
34
- return false;
35
- try {
36
- const url = new URL(raw);
37
- return ((url.protocol === 'http:' || url.protocol === 'https:') &&
38
- !url.username &&
39
- !url.password);
40
- }
41
- catch {
42
- return false;
43
- }
44
- }
45
- /**
46
- * Whether Anthropic OAuth model costs should be zeroed in the provider hook.
47
- * Defaults to enabled; only an explicit `costZeroing.enabled === false` opts out
48
- * (to display the provider's real model costs).
49
- */
50
- export function isCostZeroingEnabled(storage) {
51
- return storage.costZeroing?.enabled !== false;
52
- }
53
- const DEFAULT_FALLBACK_ON = [401, 403, 429];
54
- const MIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240;
55
- const DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES = MIN_REFRESH_BEFORE_EXPIRY_MINUTES;
56
- const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
57
- const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
58
- const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
59
- const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000;
60
- const MIN_QUOTA_RETRY_DELAY_MS = 60_000;
61
- const MAX_QUOTA_RETRY_DELAY_MS = 15 * 60_000;
62
- const NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 5 * 60_000;
63
- const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5;
64
- const DEFAULT_MINIMUM_REMAINING = {
65
- five_hour: 0,
66
- seven_day: 0,
67
- };
68
- const DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA = true;
69
- export const FALLBACK_BACKGROUND_TICK_MS = 60_000;
70
- const BACKGROUND_TICK_JITTER_MS = 60_000;
71
- const FALLBACK_REFRESH_LOCK_TTL_MS = 10 * 60_000;
72
- const FALLBACK_REFRESH_JOIN_WAIT_MS = 10_000;
73
- const FALLBACK_REFRESH_JOIN_POLL_MS = 100;
74
- function getConfigDir() {
75
- if (process.env.OPENCODE_CONFIG_DIR?.trim()) {
76
- return process.env.OPENCODE_CONFIG_DIR.trim();
77
- }
78
- return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'opencode');
79
- }
80
- export function getAccountStoragePath() {
81
- return (process.env.OPENCODE_ANTHROPIC_AUTH_FILE?.trim() ||
82
- join(getConfigDir(), ACCOUNT_FILE_NAME));
83
- }
84
- export function getAccountStatePath(configPath = getAccountStoragePath()) {
85
- const explicit = process.env.OPENCODE_ANTHROPIC_AUTH_STATE_FILE?.trim();
86
- if (explicit)
87
- return explicit;
88
- return configPath.endsWith(ACCOUNT_FILE_NAME)
89
- ? join(dirname(configPath), ACCOUNT_STATE_FILE_NAME)
90
- : `${configPath}.state.json`;
91
- }
92
- function isRecord(value) {
93
- return value != null && typeof value === 'object' && !Array.isArray(value);
94
- }
95
- function normalizeAccountBase(value) {
96
- return {
97
- id: typeof value.id === 'string' && value.id.trim()
98
- ? value.id.trim()
99
- : randomUUID(),
100
- label: typeof value.label === 'string' ? value.label : undefined,
101
- enabled: typeof value.enabled === 'boolean' ? value.enabled : undefined,
102
- addedAt: typeof value.addedAt === 'number' ? value.addedAt : undefined,
103
- lastUsed: typeof value.lastUsed === 'number' ? value.lastUsed : undefined,
104
- };
105
- }
106
- function normalizeAccount(value) {
107
- if (!isRecord(value))
108
- return null;
109
- if (value.type === 'api') {
110
- const baseURL = typeof value.baseURL === 'string' ? value.baseURL.trim() : '';
111
- const apiKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : '';
112
- if (!isValidApiBaseURL(baseURL))
113
- return null;
114
- const authHeader = value.authHeader === 'x-api-key' ? 'x-api-key' : 'authorization-bearer';
115
- return {
116
- ...normalizeAccountBase(value),
117
- type: 'api',
118
- apiKey: apiKey || undefined,
119
- baseURL,
120
- authHeader,
121
- };
122
- }
123
- if (value.type !== 'oauth')
124
- return null;
125
- if (typeof value.refresh !== 'string' || !value.refresh.trim())
126
- return null;
127
- return {
128
- ...normalizeAccountBase(value),
129
- type: 'oauth',
130
- authLineageId: typeof value.authLineageId === 'string' && value.authLineageId.trim()
131
- ? value.authLineageId
132
- : undefined,
133
- claustrumHandle: typeof value.claustrumHandle === 'string' && value.claustrumHandle.trim()
134
- ? value.claustrumHandle.trim()
135
- : undefined,
136
- access: typeof value.access === 'string' ? value.access : undefined,
137
- refresh: value.refresh,
138
- expires: typeof value.expires === 'number' ? value.expires : undefined,
139
- lastRefreshedAt: typeof value.lastRefreshedAt === 'number'
140
- ? value.lastRefreshedAt
141
- : undefined,
142
- lastRefreshError: normalizeOperationError(value.lastRefreshError),
143
- lastQuotaRefreshError: normalizeOperationError(value.lastQuotaRefreshError),
144
- quota: normalizeQuota(value.quota),
145
- profile: normalizeOAuthAccountProfile(value.profile),
146
- prime: normalizePrimeUsageCounters(value.prime),
147
- };
148
- }
149
- function normalizeOAuthAccountProfile(value) {
150
- if (!isRecord(value))
151
- return undefined;
152
- if (typeof value.tier !== 'string' ||
153
- !value.tier.trim() ||
154
- typeof value.orgType !== 'string' ||
155
- !value.orgType.trim() ||
156
- typeof value.checkedAt !== 'number' ||
157
- !Number.isFinite(value.checkedAt)) {
158
- return undefined;
159
- }
160
- return {
161
- tier: value.tier.trim(),
162
- orgType: value.orgType.trim(),
163
- checkedAt: value.checkedAt,
164
- ...(typeof value.accountIdentity === 'string' &&
165
- value.accountIdentity.trim() && {
166
- accountIdentity: value.accountIdentity.trim(),
167
- }),
168
- ...(typeof value.tokenFingerprint === 'string' &&
169
- value.tokenFingerprint.trim() && {
170
- tokenFingerprint: value.tokenFingerprint.trim(),
171
- }),
172
- };
173
- }
174
- function normalizeOperationError(value) {
175
- if (!isRecord(value))
176
- return undefined;
177
- if (typeof value.message !== 'string')
178
- return undefined;
179
- const checkedAt = Number(value.checkedAt);
180
- if (!Number.isFinite(checkedAt))
181
- return undefined;
182
- const nextRetryAt = Number(value.nextRetryAt);
183
- const retryCount = Number(value.retryCount);
184
- const status = Number(value.status);
185
- return {
186
- message: value.message,
187
- checkedAt,
188
- nextRetryAt: Number.isFinite(nextRetryAt) ? nextRetryAt : undefined,
189
- retryCount: Number.isFinite(retryCount) ? retryCount : undefined,
190
- accountIdentity: typeof value.accountIdentity === 'string' && value.accountIdentity.trim()
191
- ? value.accountIdentity.trim()
192
- : undefined,
193
- tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : undefined,
194
- refreshTokenFingerprint: typeof value.refreshTokenFingerprint === 'string' &&
195
- value.refreshTokenFingerprint.trim()
196
- ? value.refreshTokenFingerprint.trim()
197
- : undefined,
198
- // Preserve the dead-token discriminators across save/load. Without these,
199
- // a retry-exhausted transient (permanent=false, 24h backoff) would lose its
200
- // flag on reload and the 24h-delay heuristic would wrongly re-classify it
201
- // permanent → false "needs re-login" nag.
202
- status: Number.isFinite(status) ? status : undefined,
203
- permanent: typeof value.permanent === 'boolean' ? value.permanent : undefined,
204
- };
205
- }
206
- function normalizeQuotaWindow(value) {
207
- if (!isRecord(value))
208
- return undefined;
209
- const usedPercent = Number(value.usedPercent);
210
- const remainingPercent = Number(value.remainingPercent);
211
- const checkedAt = Number(value.checkedAt);
212
- if (!Number.isFinite(usedPercent) ||
213
- !Number.isFinite(remainingPercent) ||
214
- !Number.isFinite(checkedAt)) {
215
- return undefined;
216
- }
217
- return {
218
- usedPercent,
219
- remainingPercent,
220
- checkedAt,
221
- resetsAt: typeof value.resetsAt === 'string' ? value.resetsAt : undefined,
222
- };
223
- }
224
- function normalizePrimeUsageCounters(value) {
225
- if (!isRecord(value))
226
- return undefined;
227
- const count = Number(value.count);
228
- const inputTokens = Number(value.inputTokens);
229
- const outputTokens = Number(value.outputTokens);
230
- const since = Number(value.since);
231
- if (![count, inputTokens, outputTokens, since].every(Number.isFinite) ||
232
- count < 0 ||
233
- inputTokens < 0 ||
234
- outputTokens < 0 ||
235
- since < 0) {
236
- return undefined;
237
- }
238
- return {
239
- count: Math.floor(count),
240
- inputTokens: Math.floor(inputTokens),
241
- outputTokens: Math.floor(outputTokens),
242
- since: Math.floor(since),
243
- };
244
- }
245
- function normalizeQuota(value) {
246
- if (!isRecord(value))
247
- return undefined;
248
- const quota = {};
249
- for (const key of ['five_hour', 'seven_day']) {
250
- const normalized = normalizeQuotaWindow(value[key]);
251
- if (normalized)
252
- quota[key] = normalized;
253
- }
254
- // Persist a top-level snapshot checkedAt through normalize so the
255
- // mergeAccountRuntimeState freshness comparison stays meaningful when the
256
- // snapshot has no per-window checkedAt (e.g. {scoped:[]}). Pre-feature
257
- // inputs without this key are unaffected — only on-disk snapshots that
258
- // already carry it reach this branch.
259
- if (typeof value.checkedAt === 'number' && Number.isFinite(value.checkedAt)) {
260
- quota.checkedAt = value.checkedAt;
261
- }
262
- if (typeof value.accountIdentity === 'string' &&
263
- value.accountIdentity.trim()) {
264
- quota.accountIdentity = value.accountIdentity.trim();
265
- }
266
- if (Array.isArray(value.scoped)) {
267
- const scoped = value.scoped
268
- .map((entry) => {
269
- if (!isRecord(entry))
270
- return undefined;
271
- const window = normalizeQuotaWindow(entry);
272
- if (!window)
273
- return undefined;
274
- if (typeof entry.id !== 'string' || !entry.id.trim())
275
- return undefined;
276
- if (typeof entry.title !== 'string' || !entry.title.trim()) {
277
- return undefined;
278
- }
279
- if (typeof entry.modelName !== 'string' || !entry.modelName.trim()) {
280
- return undefined;
281
- }
282
- const modelId = typeof entry.modelId === 'string' && entry.modelId.trim()
283
- ? entry.modelId.trim()
284
- : undefined;
285
- return {
286
- ...window,
287
- id: entry.id.trim(),
288
- title: entry.title.trim(),
289
- ...(modelId && { modelId }),
290
- modelName: entry.modelName.trim(),
291
- };
292
- })
293
- .filter((entry) => entry != null);
294
- // Preserve empty `[]` so a downstream reader can distinguish "scoped
295
- // owned by anthropic-auth, none visible" from "no scoped data on this
296
- // snapshot". Pre-feature inputs without a `scoped` key are not affected
297
- // — only inputs that already carried an array reach this line.
298
- quota.scoped = scoped;
299
- }
300
- if (isRecord(value.extraUsage)) {
301
- const used = normalizeQuotaMoney(value.extraUsage.used);
302
- const limit = normalizeQuotaMoney(value.extraUsage.limit);
303
- if (used && limit && typeof value.extraUsage.exhausted === 'boolean') {
304
- quota.extraUsage = {
305
- used,
306
- limit,
307
- ...(typeof value.extraUsage.utilizationPercent === 'number' &&
308
- Number.isFinite(value.extraUsage.utilizationPercent) && {
309
- utilizationPercent: value.extraUsage.utilizationPercent,
310
- }),
311
- ...(typeof value.extraUsage.severity === 'string' && {
312
- severity: value.extraUsage.severity,
313
- }),
314
- exhausted: value.extraUsage.exhausted,
315
- };
316
- }
317
- }
318
- if (typeof value.bindingWindow === 'string' && value.bindingWindow.trim()) {
319
- quota.bindingWindow = value.bindingWindow.trim();
320
- }
321
- if (value.bindingWindowSource === 'poll' ||
322
- value.bindingWindowSource === 'headers') {
323
- quota.bindingWindowSource = value.bindingWindowSource;
324
- }
325
- if (typeof value.fallbackAdvised === 'boolean') {
326
- quota.fallbackAdvised = value.fallbackAdvised;
327
- }
328
- if (value.source === 'poll' || value.source === 'headers') {
329
- quota.source = value.source;
330
- }
331
- if (isRecord(value.fieldSources)) {
332
- const fieldSources = {};
333
- for (const field of QUOTA_FIELD_NAMES) {
334
- if (quota[field] === undefined)
335
- continue;
336
- const source = value.fieldSources[field];
337
- if (source === 'poll' || source === 'headers') {
338
- fieldSources[field] = source;
339
- }
340
- }
341
- if (Object.keys(fieldSources).length > 0) {
342
- quota.fieldSources = fieldSources;
343
- }
344
- }
345
- return Object.keys(quota).length ? quota : undefined;
346
- }
347
- function normalizeQuotaMoney(value) {
348
- if (!isRecord(value))
349
- return undefined;
350
- if (typeof value.amountMinor !== 'number' ||
351
- !Number.isFinite(value.amountMinor) ||
352
- typeof value.currency !== 'string' ||
353
- !value.currency.trim() ||
354
- typeof value.exponent !== 'number' ||
355
- !Number.isFinite(value.exponent)) {
356
- return undefined;
357
- }
358
- return {
359
- amountMinor: value.amountMinor,
360
- currency: value.currency.trim(),
361
- exponent: value.exponent,
362
- };
363
- }
364
- // Fresh empty storage shell — main OpenCode OAuth account, no fallback
365
- // accounts. Returns a new object each call so mutating callers don't alias.
366
- export function createEmptyStorage() {
367
- return {
368
- version: 1,
369
- main: { type: 'opencode', provider: 'anthropic' },
370
- accounts: [],
371
- };
372
- }
373
- function normalizeStorage(value) {
374
- if (!isRecord(value) || !Array.isArray(value.accounts))
375
- return null;
376
- return {
377
- version: 1,
378
- mainAccountId: typeof value.mainAccountId === 'string' && value.mainAccountId.trim()
379
- ? value.mainAccountId.trim()
380
- : undefined,
381
- main: {
382
- type: 'opencode',
383
- provider: 'anthropic',
384
- profile: normalizeOAuthAccountProfile(isRecord(value.main) ? value.main.profile : undefined),
385
- },
386
- routing: isRecord(value.routing) ? value.routing : undefined,
387
- fallbackOn: Array.isArray(value.fallbackOn)
388
- ? value.fallbackOn.filter((status) => Number.isInteger(status))
389
- : undefined,
390
- refresh: isRecord(value.refresh) ? value.refresh : undefined,
391
- quota: isRecord(value.quota) ? value.quota : undefined,
392
- quotaHeaderFeed: isRecord(value.quotaHeaderFeed)
393
- ? value.quotaHeaderFeed
394
- : undefined,
395
- claudeCache: isRecord(value.claudeCache) ? value.claudeCache : undefined,
396
- dump: isRecord(value.dump) ? value.dump : undefined,
397
- claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
398
- thinkingBinding: isRecord(value.thinkingBinding)
399
- ? value.thinkingBinding
400
- : undefined,
401
- costZeroing: isRecord(value.costZeroing) ? value.costZeroing : undefined,
402
- cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
403
- relay: isRecord(value.relay) ? value.relay : undefined,
404
- claustrum: normalizeClaustrumConfig(value.claustrum),
405
- logging: isRecord(value.logging) ? value.logging : undefined,
406
- killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
407
- prime: (() => {
408
- if (!isRecord(value.prime))
409
- return undefined;
410
- const enabled = typeof value.prime.enabled === 'boolean'
411
- ? value.prime.enabled
412
- : undefined;
413
- const main = normalizePrimeUsageCounters(value.prime.main);
414
- const mainAuthLineageId = typeof value.prime.mainAuthLineageId === 'string' &&
415
- value.prime.mainAuthLineageId.trim()
416
- ? value.prime.mainAuthLineageId
417
- : undefined;
418
- const mainAuthLineageRefreshTokenFingerprint = typeof value.prime.mainAuthLineageRefreshTokenFingerprint ===
419
- 'string' && value.prime.mainAuthLineageRefreshTokenFingerprint.trim()
420
- ? value.prime.mainAuthLineageRefreshTokenFingerprint
421
- : undefined;
422
- if (enabled === undefined &&
423
- !main &&
424
- !mainAuthLineageId &&
425
- !mainAuthLineageRefreshTokenFingerprint)
426
- return undefined;
427
- return {
428
- ...(enabled !== undefined && { enabled }),
429
- ...(main && { main }),
430
- ...(mainAuthLineageId && { mainAuthLineageId }),
431
- ...(mainAuthLineageRefreshTokenFingerprint && {
432
- mainAuthLineageRefreshTokenFingerprint,
433
- }),
434
- };
435
- })(),
436
- accounts: value.accounts
437
- .map(normalizeAccount)
438
- .filter((account) => account != null),
439
- };
440
- }
441
- function normalizeClaustrumConfig(value) {
442
- if (!isRecord(value) || !isRecord(value.accounts))
443
- return undefined;
444
- const accounts = Object.fromEntries(Object.entries(value.accounts).flatMap(([id, entry]) => {
445
- if (!isRecord(entry))
446
- return [];
447
- return [
448
- [
449
- id,
450
- {
451
- ...(typeof entry.enabled === 'boolean' && {
452
- enabled: entry.enabled,
453
- }),
454
- },
455
- ],
456
- ];
457
- }));
458
- return Object.keys(accounts).length > 0 ? { accounts } : undefined;
459
- }
460
- async function readJsonIfPresent(path) {
461
- try {
462
- return {
463
- exists: true,
464
- value: parseJsonRedacted(await readFile(path, 'utf8')),
465
- };
466
- }
467
- catch (error) {
468
- if (error.code === 'ENOENT') {
469
- return { exists: false, value: null };
470
- }
471
- const cause = error instanceof Error ? error.message : String(error);
472
- throw new Error(`account store at ${path} is corrupt or unreadable (${cause}) — fix or remove it`);
473
- }
474
- }
475
- function objectWithDefinedEntries(value) {
476
- return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
477
- }
478
- function numericField(value) {
479
- return typeof value === 'number' && Number.isFinite(value) ? value : 0;
480
- }
481
- // A clear is a monotonic tombstone; older or absent writes must not reopen backoff.
482
- export function mergeMainQuotaErrorClearedAt(existing, incoming) {
483
- if (incoming === undefined || !Number.isFinite(incoming))
484
- return existing;
485
- if (existing === undefined || !Number.isFinite(existing))
486
- return incoming;
487
- return Math.max(existing, incoming);
488
- }
489
- export function mergeMainRefreshErrorClearedAt(existing, incoming) {
490
- if (incoming === undefined || !Number.isFinite(incoming))
491
- return existing;
492
- if (existing === undefined || !Number.isFinite(existing))
493
- return incoming;
494
- return Math.max(existing, incoming);
495
- }
496
- function accountCredentialTimestamp(value) {
497
- return Math.max(numericField(value.lastRefreshedAt), numericField(value.lastUsed), numericField(value.addedAt));
498
- }
499
- function legacyConfigCredentialsAreNewer(account, stateAccount) {
500
- if (account.type !== 'oauth')
501
- return false;
502
- if (typeof account.refresh !== 'string' || !account.refresh.trim()) {
503
- return false;
504
- }
505
- const tokenChanged = Boolean((typeof account.access === 'string' &&
506
- typeof stateAccount.access === 'string' &&
507
- account.access !== stateAccount.access) ||
508
- (typeof account.refresh === 'string' &&
509
- typeof stateAccount.refresh === 'string' &&
510
- account.refresh !== stateAccount.refresh));
511
- if (!tokenChanged)
512
- return false;
513
- return (accountCredentialTimestamp(account) >
514
- accountCredentialTimestamp(stateAccount));
515
- }
516
- function mergeConfigAccountAndState(account, stateAccount) {
517
- if (legacyConfigCredentialsAreNewer(account, stateAccount)) {
518
- const merged = { ...stateAccount, ...account };
519
- const configTimestamp = accountCredentialTimestamp(account);
520
- if (configTimestamp > numericField(merged.lastRefreshedAt)) {
521
- merged.lastRefreshedAt = configTimestamp;
522
- }
523
- delete merged.quota;
524
- delete merged.lastRefreshError;
525
- delete merged.lastQuotaRefreshError;
526
- return merged;
527
- }
528
- return { ...account, ...stateAccount };
529
- }
530
- function configAccountHasInvalidShape(account) {
531
- if (account.type !== 'api' && account.type !== 'oauth')
532
- return true;
533
- if (account.type === 'api' &&
534
- 'baseURL' in account &&
535
- (typeof account.baseURL !== 'string' || !isValidApiBaseURL(account.baseURL))) {
536
- return true;
537
- }
538
- if (account.type === 'oauth' &&
539
- 'refresh' in account &&
540
- (typeof account.refresh !== 'string' || !account.refresh.trim())) {
541
- return true;
542
- }
543
- return false;
544
- }
545
- function mergeConfigAndState(configValue, stateValue) {
546
- if (!isRecord(configValue))
547
- return configValue;
548
- const state = isRecord(stateValue) ? stateValue : {};
549
- const mainState = isRecord(state.main) ? state.main : undefined;
550
- const stateAccounts = isRecord(state.accounts) ? state.accounts : {};
551
- const quotaConfig = isRecord(configValue.quota) ? configValue.quota : {};
552
- const refreshConfig = isRecord(configValue.refresh) ? configValue.refresh : {};
553
- const mainQuotaSource = mainState ?? quotaConfig;
554
- const mainRefreshSource = mainState ?? refreshConfig;
555
- const mainQuotaErrorClearedAt = typeof mainState?.quotaErrorClearedAt === 'number' &&
556
- Number.isFinite(mainState.quotaErrorClearedAt) &&
557
- mainState.quotaErrorClearedAt >= 0
558
- ? mainState.quotaErrorClearedAt
559
- : undefined;
560
- const mainRefreshErrorClearedAt = typeof mainState?.refreshErrorClearedAt === 'number' &&
561
- Number.isFinite(mainState.refreshErrorClearedAt) &&
562
- mainState.refreshErrorClearedAt >= 0
563
- ? mainState.refreshErrorClearedAt
564
- : undefined;
565
- const configQuotaError = quotaConfig.mainLastQuotaApiError;
566
- const mainLastQuotaApiError = mainState?.lastQuotaApiError ??
567
- (mainQuotaErrorClearedAt !== undefined &&
568
- isRecord(configQuotaError) &&
569
- typeof configQuotaError.checkedAt === 'number' &&
570
- configQuotaError.checkedAt <= mainQuotaErrorClearedAt
571
- ? undefined
572
- : configQuotaError);
573
- const configRefreshError = refreshConfig.mainLastRefreshError;
574
- const mainLastRefreshError = mainState?.lastRefreshError ??
575
- (mainRefreshErrorClearedAt !== undefined &&
576
- isRecord(configRefreshError) &&
577
- typeof configRefreshError.checkedAt === 'number' &&
578
- configRefreshError.checkedAt <= mainRefreshErrorClearedAt
579
- ? undefined
580
- : configRefreshError);
581
- const accounts = Array.isArray(configValue.accounts)
582
- ? configValue.accounts.map((account) => {
583
- if (!isRecord(account))
584
- return account;
585
- const rawId = typeof account.id === 'string' ? account.id : undefined;
586
- const stateValue = rawId
587
- ? (stateAccounts[rawId] ?? stateAccounts[rawId.trim()])
588
- : undefined;
589
- const stateAccount = isRecord(stateValue)
590
- ? stateValue
591
- : {};
592
- return mergeConfigAccountAndState(account, stateAccount);
593
- })
594
- : [];
595
- return {
596
- ...configValue,
597
- main: {
598
- type: 'opencode',
599
- provider: 'anthropic',
600
- profile: normalizeOAuthAccountProfile(mainState?.profile),
601
- },
602
- refresh: objectWithDefinedEntries({
603
- ...refreshConfig,
604
- mainLastRefreshError,
605
- mainRefreshErrorClearedAt: mainRefreshSource.refreshErrorClearedAt,
606
- mainRefreshLeaseId: mainRefreshSource.refreshLeaseId,
607
- mainRefreshLeaseUntil: mainRefreshSource.refreshLeaseUntil,
608
- mainRefreshLeaseTokenHash: mainRefreshSource.refreshLeaseTokenHash,
609
- }),
610
- quota: objectWithDefinedEntries({
611
- ...quotaConfig,
612
- mainQuota: mainQuotaSource.quota,
613
- mainQuotaCheckedAt: mainQuotaSource.quotaCheckedAt,
614
- mainQuotaToken: mainQuotaSource.quotaToken,
615
- mainLastQuotaApiError,
616
- mainQuotaErrorGeneration: mainQuotaSource.quotaErrorGeneration,
617
- mainQuotaErrorClearedAt: mainQuotaSource.quotaErrorClearedAt,
618
- }),
619
- // Carry the main-side prime counters from the state file back into the
620
- // merged storage so a subsequent read sees the cumulative counters. The
621
- // `enabled` flag stays sourced from the config side; main-side counters
622
- // live exclusively on the state file.
623
- prime: (() => {
624
- const configPrime = isRecord(configValue.prime)
625
- ? configValue.prime
626
- : undefined;
627
- const mainCounters = normalizePrimeUsageCounters(mainState?.prime);
628
- const mainAuthLineageId = typeof mainState?.primeAuthLineageId === 'string' &&
629
- mainState.primeAuthLineageId.trim()
630
- ? mainState.primeAuthLineageId
631
- : undefined;
632
- const mainAuthLineageRefreshTokenFingerprint = typeof mainState?.primeAuthLineageRefreshTokenFingerprint ===
633
- 'string' && mainState.primeAuthLineageRefreshTokenFingerprint.trim()
634
- ? mainState.primeAuthLineageRefreshTokenFingerprint
635
- : undefined;
636
- if (!configPrime &&
637
- !mainCounters &&
638
- !mainAuthLineageId &&
639
- !mainAuthLineageRefreshTokenFingerprint)
640
- return undefined;
641
- return {
642
- ...(configPrime &&
643
- typeof configPrime.enabled === 'boolean' && {
644
- enabled: configPrime.enabled,
645
- }),
646
- ...(mainCounters && { main: mainCounters }),
647
- ...(mainAuthLineageId && { mainAuthLineageId }),
648
- ...(mainAuthLineageRefreshTokenFingerprint && {
649
- mainAuthLineageRefreshTokenFingerprint,
650
- }),
651
- };
652
- })(),
653
- accounts,
654
- };
655
- }
656
- export async function loadAccounts(path = getAccountStoragePath()) {
657
- const config = await readJsonIfPresent(path);
658
- const state = await readJsonIfPresent(getAccountStatePath(path));
659
- // Runtime-only flows (main-OAuth refresh with no fallback accounts) write the
660
- // state file but never the config file, so the store is absent only when
661
- // neither exists. Synthesize an empty config to merge state into otherwise.
662
- if (!config.exists && !state.exists)
663
- return null;
664
- const configValue = config.exists ? config.value : createEmptyStorage();
665
- return normalizeStorage(mergeConfigAndState(configValue, state.value));
666
- }
667
- async function loadExistingTopLevelFields(path) {
668
- const existing = await readJsonIfPresent(path);
669
- return isRecord(existing.value) ? existing.value : {};
670
- }
671
- function omitUndefinedTopLevel(value) {
672
- return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
673
- }
674
- function accountConfig(account) {
675
- return objectWithDefinedEntries({
676
- id: account.id,
677
- label: account.label,
678
- type: account.type,
679
- enabled: account.enabled,
680
- addedAt: account.addedAt,
681
- baseURL: account.type === 'api' ? account.baseURL : undefined,
682
- authHeader: account.type === 'api' ? account.authHeader : undefined,
683
- });
684
- }
685
- function accountRuntimeState(account) {
686
- if (account.type === 'api') {
687
- return objectWithDefinedEntries({
688
- apiKey: account.apiKey,
689
- lastUsed: account.lastUsed,
690
- });
691
- }
692
- return objectWithDefinedEntries({
693
- authLineageId: account.authLineageId,
694
- claustrumHandle: account.claustrumHandle,
695
- access: account.access,
696
- refresh: account.refresh,
697
- expires: account.expires,
698
- lastUsed: account.lastUsed,
699
- lastRefreshedAt: account.lastRefreshedAt,
700
- lastRefreshError: account.lastRefreshError,
701
- lastQuotaRefreshError: account.lastQuotaRefreshError,
702
- quota: account.quota,
703
- profile: account.profile,
704
- prime: account.prime,
705
- });
706
- }
707
- /** Returns the latest timestamp carried by any quota snapshot window. */
708
- export function quotaSnapshotCheckedAt(quota) {
709
- return Math.max(quota?.five_hour?.checkedAt ?? 0, quota?.seven_day?.checkedAt ?? 0, ...(quota?.scoped?.map((window) => window.checkedAt) ?? []), quota?.checkedAt ?? 0);
710
- }
711
- function quotaSourcePrecedence(quota) {
712
- if (quota?.source === 'poll')
713
- return 2;
714
- if (quota?.source === 'headers')
715
- return 1;
716
- return 0;
717
- }
718
- function mergeHeaderScopedQuota(existing, incoming) {
719
- if (!('scoped' in existing))
720
- return incoming.scoped;
721
- if (!Array.isArray(existing.scoped) || existing.scoped.length === 0) {
722
- return existing.scoped;
723
- }
724
- if (!Array.isArray(incoming.scoped) || incoming.scoped.length === 0) {
725
- return existing.scoped;
726
- }
727
- const merged = new Map(incoming.scoped.map((window) => [window.id, window]));
728
- for (const window of existing.scoped) {
729
- const candidate = merged.get(window.id);
730
- if (!candidate || window.checkedAt >= candidate.checkedAt) {
731
- merged.set(window.id, window);
732
- }
733
- }
734
- return [...merged.values()];
735
- }
736
- function mergeHeaderOwnedWindow(existingSnapshot, incomingSnapshot, key) {
737
- const existing = existingSnapshot[key];
738
- const incoming = incomingSnapshot[key];
739
- if (!incoming)
740
- return existing;
741
- if (!existing)
742
- return incoming;
743
- if (incoming.checkedAt > existing.checkedAt)
744
- return incoming;
745
- if (incoming.checkedAt < existing.checkedAt)
746
- return existing;
747
- return quotaSourcePrecedence(existingSnapshot) >
748
- quotaSourcePrecedence(incomingSnapshot)
749
- ? existing
750
- : incoming;
751
- }
752
- export function quotaFieldSource(snapshot, field) {
753
- if (!snapshot || snapshot[field] === undefined)
754
- return undefined;
755
- return (snapshot.fieldSources?.[field] ??
756
- (snapshot.source === 'poll' || snapshot.source === 'headers'
757
- ? snapshot.source
758
- : undefined));
759
- }
760
- function fieldSourcesForMergedQuota(existing, incoming, merged) {
761
- const fieldSources = {};
762
- for (const field of QUOTA_FIELD_NAMES) {
763
- if (merged[field] === undefined)
764
- continue;
765
- if (field === 'scoped' || field === 'extraUsage') {
766
- fieldSources[field] = 'poll';
767
- continue;
768
- }
769
- if (field === 'bindingWindow' &&
770
- existing.bindingWindowSource === 'poll' &&
771
- existing.bindingWindow === merged.bindingWindow) {
772
- fieldSources[field] = 'poll';
773
- continue;
774
- }
775
- if (field === 'fallbackAdvised' &&
776
- incoming.fieldSources?.fallbackAdvised === undefined) {
777
- const source = quotaFieldSource(existing, field);
778
- if (source)
779
- fieldSources[field] = source;
780
- continue;
781
- }
782
- const source = incoming[field] === merged[field]
783
- ? quotaFieldSource(incoming, field)
784
- : existing[field] === merged[field]
785
- ? quotaFieldSource(existing, field)
786
- : (quotaFieldSource(existing, field) ??
787
- quotaFieldSource(incoming, field));
788
- if (source)
789
- fieldSources[field] = source;
790
- }
791
- return Object.keys(fieldSources).length > 0 ? fieldSources : undefined;
792
- }
793
- export function mergeHeaderQuotaForPersistence(existing, incoming) {
794
- if (!existing || incoming.source !== 'headers')
795
- return incoming;
796
- const preservePollBinding = existing.bindingWindowSource === 'poll';
797
- const merged = {
798
- ...existing,
799
- ...incoming,
800
- five_hour: mergeHeaderOwnedWindow(existing, incoming, 'five_hour'),
801
- seven_day: mergeHeaderOwnedWindow(existing, incoming, 'seven_day'),
802
- scoped: mergeHeaderScopedQuota(existing, incoming),
803
- extraUsage: existing.extraUsage ?? incoming.extraUsage,
804
- fallbackAdvised: incoming.fieldSources?.fallbackAdvised !== undefined
805
- ? incoming.fallbackAdvised
806
- : (existing.fallbackAdvised ?? incoming.fallbackAdvised),
807
- bindingWindow: preservePollBinding
808
- ? existing.bindingWindow
809
- : (incoming.bindingWindow ?? existing.bindingWindow),
810
- bindingWindowSource: preservePollBinding
811
- ? 'poll'
812
- : (incoming.bindingWindowSource ?? existing.bindingWindowSource),
813
- };
814
- const fieldSources = fieldSourcesForMergedQuota(existing, incoming, merged);
815
- return {
816
- ...merged,
817
- ...(fieldSources && { fieldSources }),
818
- };
819
- }
820
- function mergeAccountRuntimeState(existing, incoming) {
821
- if (!isRecord(existing))
822
- return incoming;
823
- const existingEntry = existing;
824
- const tokenChanged = Boolean((existingEntry.access &&
825
- incoming.access &&
826
- existingEntry.access !== incoming.access) ||
827
- (existingEntry.refresh &&
828
- incoming.refresh &&
829
- existingEntry.refresh !== incoming.refresh));
830
- const mergesHeaderQuota = Boolean(!tokenChanged && incoming.quota?.source === 'headers');
831
- const effectiveIncoming = mergesHeaderQuota && incoming.quota
832
- ? {
833
- ...incoming,
834
- quota: mergeHeaderQuotaForPersistence(existingEntry.quota, incoming.quota),
835
- }
836
- : incoming;
837
- const preferredRefreshError = (() => {
838
- const existingError = existingEntry.lastRefreshError;
839
- const incomingError = effectiveIncoming.lastRefreshError;
840
- if (!existingError)
841
- return incomingError;
842
- if (incomingError) {
843
- return incomingError.checkedAt >= existingError.checkedAt
844
- ? incomingError
845
- : existingError;
846
- }
847
- return (effectiveIncoming.lastRefreshedAt ?? 0) >
848
- (existingEntry.lastRefreshedAt ?? 0)
849
- ? undefined
850
- : existingError;
851
- })();
852
- const existingQuotaCheckedAt = quotaSnapshotCheckedAt(existingEntry.quota);
853
- const incomingQuotaCheckedAt = quotaSnapshotCheckedAt(effectiveIncoming.quota);
854
- const existingQuotaWinsEqualTimestamp = Boolean(existingQuotaCheckedAt === incomingQuotaCheckedAt &&
855
- quotaSourcePrecedence(existingEntry.quota) >
856
- quotaSourcePrecedence(effectiveIncoming.quota));
857
- if (!mergesHeaderQuota &&
858
- (existingQuotaCheckedAt > incomingQuotaCheckedAt ||
859
- existingQuotaWinsEqualTimestamp)) {
860
- const existingRefreshAt = existingEntry.lastRefreshedAt ?? 0;
861
- const incomingRefreshAt = effectiveIncoming.lastRefreshedAt ?? 0;
862
- if (tokenChanged && incomingRefreshAt <= existingRefreshAt) {
863
- const merged = { ...existingEntry };
864
- if (typeof effectiveIncoming.lastUsed === 'number' &&
865
- (!(typeof existingEntry.lastUsed === 'number') ||
866
- effectiveIncoming.lastUsed > existingEntry.lastUsed)) {
867
- merged.lastUsed = effectiveIncoming.lastUsed;
868
- }
869
- return merged;
870
- }
871
- const merged = {
872
- ...existingEntry,
873
- ...effectiveIncoming,
874
- };
875
- if (tokenChanged) {
876
- if (!('profile' in effectiveIncoming))
877
- delete merged.profile;
878
- if (effectiveIncoming.quota?.source) {
879
- merged.quota = effectiveIncoming.quota;
880
- }
881
- else {
882
- delete merged.quota;
883
- }
884
- if (!('lastQuotaRefreshError' in effectiveIncoming)) {
885
- delete merged.lastQuotaRefreshError;
886
- }
887
- merged.lastRefreshError = preferredRefreshError;
888
- return merged;
889
- }
890
- return {
891
- ...merged,
892
- quota: existingEntry.quota,
893
- lastQuotaRefreshError: existingEntry.lastQuotaRefreshError,
894
- lastRefreshError: preferredRefreshError,
895
- };
896
- }
897
- const merged = {
898
- ...existingEntry,
899
- ...effectiveIncoming,
900
- };
901
- if (tokenChanged) {
902
- if (!('profile' in effectiveIncoming))
903
- delete merged.profile;
904
- if (!effectiveIncoming.quota?.source)
905
- delete merged.quota;
906
- }
907
- if (!('lastQuotaRefreshError' in effectiveIncoming)) {
908
- delete merged.lastQuotaRefreshError;
909
- }
910
- merged.lastRefreshError = preferredRefreshError;
911
- return merged;
912
- }
913
- function configFromStorage(storage) {
914
- const refresh = storage.refresh
915
- ? objectWithDefinedEntries({
916
- enabled: storage.refresh.enabled,
917
- intervalMinutes: storage.refresh.intervalMinutes,
918
- refreshBeforeExpiryMinutes: storage.refresh.refreshBeforeExpiryMinutes,
919
- })
920
- : undefined;
921
- const quota = storage.quota
922
- ? objectWithDefinedEntries({
923
- enabled: storage.quota.enabled,
924
- checkIntervalMinutes: storage.quota.checkIntervalMinutes,
925
- refreshEveryNRequests: storage.quota.refreshEveryNRequests,
926
- minimumRemaining: storage.quota.minimumRemaining,
927
- failClosedOnUnknownQuota: storage.quota.failClosedOnUnknownQuota,
928
- showToasts: storage.quota.showToasts,
929
- })
930
- : undefined;
931
- return omitUndefinedTopLevel({
932
- version: 1,
933
- mainAccountId: storage.mainAccountId,
934
- main: { type: 'opencode', provider: 'anthropic' },
935
- routing: storage.routing,
936
- fallbackOn: storage.fallbackOn,
937
- refresh,
938
- quota,
939
- quotaHeaderFeed: storage.quotaHeaderFeed,
940
- claudeCache: storage.claudeCache,
941
- dump: storage.dump,
942
- logging: storage.logging,
943
- claudeFast: storage.claudeFast,
944
- thinkingBinding: storage.thinkingBinding,
945
- costZeroing: storage.costZeroing,
946
- cacheKeep: storage.cacheKeep,
947
- relay: storage.relay,
948
- claustrum: storage.claustrum,
949
- killswitch: storage.killswitch,
950
- prime: (() => {
951
- // Config side carries ONLY the `enabled` flag — runtime counters and
952
- // lineage bindings stay on the state file. Write `enabled` whenever it was explicitly set so a
953
- // toggle off persists `{ enabled: false }` and is visible to a stale
954
- // reader that only inspects the config.
955
- if (typeof storage.prime?.enabled !== 'boolean')
956
- return undefined;
957
- return { enabled: storage.prime.enabled };
958
- })(),
959
- accounts: storage.accounts.map(accountConfig),
960
- });
961
- }
962
- export function isClaustrumEnabledForAccount(storage, accountId) {
963
- return storage.claustrum?.accounts?.[accountId]?.enabled === true;
964
- }
965
- // ---------------------------------------------------------------------------
966
- // In-process save mutex — serializes all account-store writes so concurrent
967
- // read-modify-write callers (background timers that call saveAccountState with
968
- // different section flags) don't lose each other's updates (#9).
969
- // ---------------------------------------------------------------------------
970
- let saveChain = Promise.resolve();
971
- function enqueueSave(fn) {
972
- return new Promise((resolve, reject) => {
973
- saveChain = saveChain.then(() => fn().then(resolve, reject), () => fn().then(resolve, reject));
974
- });
975
- }
976
- async function writeJsonAtomic(path, value) {
977
- await mkdir(dirname(path), { recursive: true });
978
- const tempPath = `${path}.${randomUUID()}.tmp`;
979
- await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
980
- encoding: 'utf8',
981
- mode: 0o600,
982
- });
983
- try {
984
- await rename(tempPath, path);
985
- }
986
- catch (error) {
987
- await rm(tempPath, { force: true }).catch(() => { });
988
- throw error;
989
- }
990
- }
991
- function sameAccountIdentity(left, right) {
992
- return (left.id === right.id ||
993
- Boolean(left.label && right.label && left.label === right.label));
994
- }
995
- function mergeAccountsForSave(existing, incoming, options) {
996
- const removedIds = new Set(options.removedAccountIds ?? []);
997
- const current = existing.filter((account) => !removedIds.has(account.id));
998
- const next = incoming.filter((account) => !removedIds.has(account.id));
999
- const missing = current.filter((account) => !next.some((candidate) => sameAccountIdentity(candidate, account)));
1000
- if (!missing.length)
1001
- return [...next];
1002
- if (options.preserveExistingAccountOrder === false) {
1003
- return [...next, ...missing];
1004
- }
1005
- const usedIncoming = new Set();
1006
- const merged = current.map((account) => {
1007
- const index = next.findIndex((candidate, candidateIndex) => !usedIncoming.has(candidateIndex) &&
1008
- sameAccountIdentity(candidate, account));
1009
- const candidate = next[index];
1010
- if (!candidate)
1011
- return account;
1012
- usedIncoming.add(index);
1013
- return candidate;
1014
- });
1015
- for (let index = 0; index < next.length; index++) {
1016
- const candidate = next[index];
1017
- if (!usedIncoming.has(index) && candidate)
1018
- merged.push(candidate);
1019
- }
1020
- return merged;
1021
- }
1022
- const ACCOUNT_CONFIG_LOCK_TTL_MS = 10_000;
1023
- const ACCOUNT_CONFIG_LOCK_WAIT_MS = 12_000;
1024
- const ACCOUNT_STATE_LOCK_TTL_MS = 10_000;
1025
- const ACCOUNT_STATE_LOCK_WAIT_MS = 12_000;
1026
- async function acquireAccountWriteLock(input) {
1027
- const { path, name, ttlMs, waitMs, description } = input;
1028
- await mkdir(dirname(path), { recursive: true });
1029
- const deadline = Date.now() + waitMs;
1030
- while (true) {
1031
- const lock = await acquireRefreshFileLock({
1032
- name,
1033
- ttlMs,
1034
- path,
1035
- renew: true,
1036
- });
1037
- if (lock)
1038
- return lock;
1039
- if (Date.now() >= deadline) {
1040
- throw new Error(`Timed out waiting for the account ${description} lock`);
1041
- }
1042
- await new Promise((resolve) => setTimeout(resolve, 25));
1043
- }
1044
- }
1045
- function acquireAccountConfigWriteLock(path) {
1046
- return acquireAccountWriteLock({
1047
- path,
1048
- name: 'config-write',
1049
- ttlMs: ACCOUNT_CONFIG_LOCK_TTL_MS,
1050
- waitMs: ACCOUNT_CONFIG_LOCK_WAIT_MS,
1051
- description: 'configuration write',
1052
- });
1053
- }
1054
- function acquireAccountStateWriteLock(path) {
1055
- return acquireAccountWriteLock({
1056
- path,
1057
- name: 'state-write',
1058
- ttlMs: ACCOUNT_STATE_LOCK_TTL_MS,
1059
- waitMs: ACCOUNT_STATE_LOCK_WAIT_MS,
1060
- description: 'state write',
1061
- });
1062
- }
1063
- export function saveAccounts(storage, path = getAccountStoragePath(), options = {}) {
1064
- const resolvedPath = path;
1065
- return enqueueSave(() => saveAccountsLocked(storage, resolvedPath, options));
1066
- }
1067
- export async function getOrCreateMainAccountId(path = getAccountStoragePath(), createId = randomUUID) {
1068
- return enqueueSave(async () => {
1069
- const lock = await acquireAccountConfigWriteLock(path);
1070
- try {
1071
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1072
- if (storage.mainAccountId)
1073
- return storage.mainAccountId;
1074
- const mainAccountId = createId();
1075
- const nextStorage = { ...storage, mainAccountId };
1076
- const existing = await loadExistingTopLevelFields(path);
1077
- await writeJsonAtomic(path, {
1078
- ...existing,
1079
- ...configFromStorage(nextStorage),
1080
- });
1081
- return mainAccountId;
1082
- }
1083
- finally {
1084
- await lock.release();
1085
- }
1086
- });
1087
- }
1088
- async function saveAccountsLocked(storage, path, options) {
1089
- const lock = await acquireAccountConfigWriteLock(path);
1090
- try {
1091
- const current = await loadAccounts(path);
1092
- const nextStorage = {
1093
- ...storage,
1094
- accounts: mergeAccountsForSave(current?.accounts ?? [], storage.accounts, options),
1095
- };
1096
- const existing = await loadExistingTopLevelFields(path);
1097
- const nextConfig = { ...existing, ...configFromStorage(nextStorage) };
1098
- await writeJsonAtomic(path, nextConfig);
1099
- // Config precedes state everywhere both locks are needed; reversing this
1100
- // order can deadlock profile mutations against full account saves.
1101
- const stateLock = await acquireAccountStateWriteLock(path);
1102
- try {
1103
- await saveAccountStateUnlocked(nextStorage, path, {
1104
- mainQuota: true,
1105
- mainRefresh: true,
1106
- accounts: true,
1107
- });
1108
- }
1109
- finally {
1110
- await stateLock.release();
1111
- }
1112
- }
1113
- finally {
1114
- await lock.release();
1115
- }
1116
- }
1117
- function applyMainProfileStatePatch(state, storage) {
1118
- state.main = state.main ?? {};
1119
- state.main.profile = storage.main?.profile;
1120
- }
1121
- function applyMainQuotaStatePatch(state, storage) {
1122
- state.main = state.main ?? {};
1123
- const incomingError = storage.quota?.mainLastQuotaApiError;
1124
- const incomingGeneration = typeof storage.quota?.mainQuotaErrorGeneration === 'number' &&
1125
- Number.isSafeInteger(storage.quota.mainQuotaErrorGeneration) &&
1126
- storage.quota.mainQuotaErrorGeneration >= 0
1127
- ? storage.quota.mainQuotaErrorGeneration
1128
- : undefined;
1129
- const incomingClearedAt = typeof storage.quota?.mainQuotaErrorClearedAt === 'number' &&
1130
- Number.isFinite(storage.quota.mainQuotaErrorClearedAt) &&
1131
- storage.quota.mainQuotaErrorClearedAt >= 0
1132
- ? storage.quota.mainQuotaErrorClearedAt
1133
- : undefined;
1134
- const existingGeneration = typeof state.main.quotaErrorGeneration === 'number' &&
1135
- Number.isSafeInteger(state.main.quotaErrorGeneration) &&
1136
- state.main.quotaErrorGeneration >= 0
1137
- ? state.main.quotaErrorGeneration
1138
- : 0;
1139
- const existingErrorObservedAt = typeof state.main.lastQuotaApiError?.checkedAt === 'number' &&
1140
- Number.isFinite(state.main.lastQuotaApiError.checkedAt) &&
1141
- state.main.lastQuotaApiError.checkedAt >= 0
1142
- ? state.main.lastQuotaApiError.checkedAt
1143
- : undefined;
1144
- const existingClearedAt = typeof state.main.quotaErrorClearedAt === 'number' &&
1145
- Number.isFinite(state.main.quotaErrorClearedAt) &&
1146
- state.main.quotaErrorClearedAt >= 0
1147
- ? state.main.quotaErrorClearedAt
1148
- : undefined;
1149
- const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
1150
- const incomingErrorObservedAt = typeof incomingError?.checkedAt === 'number' &&
1151
- Number.isFinite(incomingError.checkedAt) &&
1152
- incomingError.checkedAt >= 0
1153
- ? incomingError.checkedAt
1154
- : undefined;
1155
- // Error transitions are ordered by observation time, not write order. All
1156
- // instances share one host clock, so an error observed before a clear cannot
1157
- // resurrect that clear even when both processes allocated the same generation.
1158
- if (incomingError === undefined &&
1159
- incomingClearedAt !== undefined &&
1160
- incomingClearedAt >= existingObservedAt) {
1161
- state.main.lastQuotaApiError = undefined;
1162
- state.main.quotaErrorClearedAt = mergeMainQuotaErrorClearedAt(existingClearedAt, incomingClearedAt);
1163
- if (incomingGeneration !== undefined &&
1164
- incomingGeneration >= existingGeneration) {
1165
- state.main.quotaErrorGeneration = incomingGeneration;
1166
- }
1167
- }
1168
- else if (incomingError &&
1169
- typeof incomingError.checkedAt === 'number' &&
1170
- Number.isFinite(incomingError.checkedAt) &&
1171
- incomingError.checkedAt > existingObservedAt) {
1172
- const acceptsByObservation = incomingErrorObservedAt !== undefined &&
1173
- incomingErrorObservedAt > existingObservedAt;
1174
- const acceptsEqualObservation = incomingErrorObservedAt !== undefined &&
1175
- incomingErrorObservedAt === existingObservedAt &&
1176
- existingErrorObservedAt !== undefined &&
1177
- existingClearedAt === undefined &&
1178
- incomingGeneration !== undefined &&
1179
- incomingGeneration >= existingGeneration;
1180
- const acceptsLegacyGeneration = incomingErrorObservedAt === undefined &&
1181
- incomingClearedAt === undefined &&
1182
- incomingGeneration !== undefined &&
1183
- incomingGeneration >= existingGeneration &&
1184
- existingClearedAt === undefined;
1185
- if (acceptsByObservation ||
1186
- acceptsEqualObservation ||
1187
- acceptsLegacyGeneration) {
1188
- state.main.lastQuotaApiError = incomingError;
1189
- if (incomingGeneration !== undefined) {
1190
- state.main.quotaErrorGeneration = Math.max(existingGeneration, incomingGeneration);
1191
- }
1192
- }
1193
- }
1194
- const incomingQuota = storage.quota?.mainQuota;
1195
- const sameToken = Boolean(state.main.quotaToken &&
1196
- storage.quota?.mainQuotaToken &&
1197
- state.main.quotaToken === storage.quota.mainQuotaToken);
1198
- const effectiveIncomingQuota = sameToken && incomingQuota?.source === 'headers'
1199
- ? mergeHeaderQuotaForPersistence(state.main.quota, incomingQuota)
1200
- : incomingQuota;
1201
- const mergesHeaderQuota = Boolean(sameToken && incomingQuota?.source === 'headers');
1202
- const existingCheckedAt = typeof state.main.quotaCheckedAt === 'number'
1203
- ? state.main.quotaCheckedAt
1204
- : quotaSnapshotCheckedAt(state.main.quota);
1205
- const incomingCheckedAt = typeof storage.quota?.mainQuotaCheckedAt === 'number'
1206
- ? storage.quota.mainQuotaCheckedAt
1207
- : quotaSnapshotCheckedAt(effectiveIncomingQuota);
1208
- if (!mergesHeaderQuota &&
1209
- (existingCheckedAt > incomingCheckedAt ||
1210
- (existingCheckedAt === incomingCheckedAt &&
1211
- quotaSourcePrecedence(state.main.quota) >
1212
- quotaSourcePrecedence(effectiveIncomingQuota)))) {
1213
- return;
1214
- }
1215
- state.main.quota = effectiveIncomingQuota;
1216
- state.main.quotaCheckedAt = mergesHeaderQuota
1217
- ? Math.max(existingCheckedAt, incomingCheckedAt)
1218
- : storage.quota?.mainQuotaCheckedAt;
1219
- state.main.quotaToken = storage.quota?.mainQuotaToken;
1220
- }
1221
- function applyMainRefreshStatePatch(state, storage) {
1222
- state.main = state.main ?? {};
1223
- const incomingError = storage.refresh?.mainLastRefreshError;
1224
- const incomingClearedAt = storage.refresh?.mainRefreshErrorClearedAt;
1225
- const existingErrorObservedAt = state.main.lastRefreshError?.checkedAt;
1226
- const existingClearedAt = state.main.refreshErrorClearedAt;
1227
- const existingObservedAt = Math.max(existingErrorObservedAt ?? 0, existingClearedAt ?? 0);
1228
- if (incomingError === undefined &&
1229
- incomingClearedAt !== undefined &&
1230
- incomingClearedAt >= existingObservedAt) {
1231
- state.main.lastRefreshError = undefined;
1232
- state.main.refreshErrorClearedAt = mergeMainRefreshErrorClearedAt(existingClearedAt, incomingClearedAt);
1233
- }
1234
- else if (incomingError &&
1235
- typeof incomingError.checkedAt === 'number' &&
1236
- Number.isFinite(incomingError.checkedAt) &&
1237
- incomingError.checkedAt > existingObservedAt) {
1238
- state.main.lastRefreshError = incomingError;
1239
- }
1240
- state.main.refreshLeaseId = storage.refresh?.mainRefreshLeaseId;
1241
- state.main.refreshLeaseUntil = storage.refresh?.mainRefreshLeaseUntil;
1242
- state.main.refreshLeaseTokenHash = storage.refresh?.mainRefreshLeaseTokenHash;
1243
- }
1244
- function applyMainPrimeStatePatch(state, storage) {
1245
- const incoming = storage.prime?.main;
1246
- const incomingAuthLineageId = storage.prime?.mainAuthLineageId;
1247
- const incomingAuthLineageRefreshTokenFingerprint = storage.prime?.mainAuthLineageRefreshTokenFingerprint;
1248
- if (!incoming &&
1249
- !incomingAuthLineageId &&
1250
- !incomingAuthLineageRefreshTokenFingerprint)
1251
- return;
1252
- state.main = state.main ?? {};
1253
- // Last-writer-wins on prime counters — the only writer is the prime manager
1254
- // itself, monotonically accumulating per success, so there is no race for an
1255
- // older write to overwrite a newer one within this process. Across processes
1256
- // the cross-process claim marker (#1247) keeps the fire exclusive.
1257
- if (incoming)
1258
- state.main.prime = incoming;
1259
- if (incomingAuthLineageId) {
1260
- state.main.primeAuthLineageId = incomingAuthLineageId;
1261
- }
1262
- if (incomingAuthLineageRefreshTokenFingerprint) {
1263
- state.main.primeAuthLineageRefreshTokenFingerprint =
1264
- incomingAuthLineageRefreshTokenFingerprint;
1265
- }
1266
- }
1267
- function pruneUndefined(value) {
1268
- if (Array.isArray(value))
1269
- return value.map(pruneUndefined);
1270
- if (!isRecord(value))
1271
- return value;
1272
- return Object.fromEntries(Object.entries(value)
1273
- .filter(([, entry]) => entry !== undefined)
1274
- .map(([key, entry]) => [key, pruneUndefined(entry)]));
1275
- }
1276
- export function saveAccountState(storage, path = getAccountStoragePath(), scope = {
1277
- mainProfile: true,
1278
- mainQuota: true,
1279
- mainRefresh: true,
1280
- accounts: true,
1281
- }) {
1282
- const resolvedPath = path;
1283
- return enqueueSave(async () => {
1284
- const lock = await acquireAccountStateWriteLock(resolvedPath);
1285
- try {
1286
- await saveAccountStateUnlocked(storage, resolvedPath, scope);
1287
- }
1288
- finally {
1289
- await lock.release();
1290
- }
1291
- });
1292
- }
1293
- export function saveOAuthProfileState(input, path = getAccountStoragePath()) {
1294
- const resolvedPath = path;
1295
- return enqueueSave(async () => {
1296
- const configLock = await acquireAccountConfigWriteLock(resolvedPath);
1297
- try {
1298
- const stateLock = await acquireAccountStateWriteLock(resolvedPath);
1299
- try {
1300
- const current = await loadAccounts(resolvedPath);
1301
- const statePath = getAccountStatePath(resolvedPath);
1302
- const existing = (await readJsonIfPresent(statePath)).value;
1303
- const next = isRecord(existing)
1304
- ? { ...existing, version: 1 }
1305
- : { version: 1 };
1306
- const { accountId, accountIdentity, profile } = input;
1307
- if (accountId === 'main') {
1308
- next.main = { ...(next.main ?? {}) };
1309
- const existingProfile = normalizeOAuthAccountProfile(next.main.profile);
1310
- if (!profile) {
1311
- if (existingProfile &&
1312
- (accountIdentity === undefined ||
1313
- existingProfile.accountIdentity === undefined ||
1314
- existingProfile.accountIdentity === accountIdentity)) {
1315
- return false;
1316
- }
1317
- next.main.profile = undefined;
1318
- return await writeJsonAtomic(statePath, pruneUndefined(next)).then(() => true);
1319
- }
1320
- if (profile.accountIdentity !== undefined &&
1321
- profile.accountIdentity !== accountIdentity) {
1322
- return false;
1323
- }
1324
- const persistedProfile = accountIdentity === undefined ||
1325
- profile.accountIdentity === accountIdentity
1326
- ? profile
1327
- : { ...profile, accountIdentity };
1328
- if (existingProfile &&
1329
- (accountIdentity === undefined ||
1330
- existingProfile.accountIdentity === undefined ||
1331
- existingProfile.accountIdentity === accountIdentity) &&
1332
- existingProfile.checkedAt > persistedProfile.checkedAt) {
1333
- return false;
1334
- }
1335
- next.main.profile = persistedProfile;
1336
- }
1337
- else {
1338
- const account = current?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
1339
- if (!account)
1340
- return false;
1341
- next.accounts = {
1342
- ...(isRecord(next.accounts) ? next.accounts : {}),
1343
- };
1344
- const existingEntry = isRecord(next.accounts[accountId])
1345
- ? { ...next.accounts[accountId] }
1346
- : {};
1347
- const existingProfile = normalizeOAuthAccountProfile(existingEntry.profile);
1348
- if (!profile) {
1349
- if (existingProfile &&
1350
- (accountIdentity === undefined ||
1351
- existingProfile.accountIdentity === undefined ||
1352
- existingProfile.accountIdentity === accountIdentity)) {
1353
- return false;
1354
- }
1355
- existingEntry.profile = undefined;
1356
- }
1357
- else {
1358
- if (profile.accountIdentity !== undefined &&
1359
- profile.accountIdentity !== accountIdentity) {
1360
- return false;
1361
- }
1362
- const persistedProfile = accountIdentity === undefined ||
1363
- profile.accountIdentity === accountIdentity
1364
- ? profile
1365
- : { ...profile, accountIdentity };
1366
- if (existingProfile &&
1367
- (accountIdentity === undefined ||
1368
- existingProfile.accountIdentity === undefined ||
1369
- existingProfile.accountIdentity === accountIdentity) &&
1370
- existingProfile.checkedAt > persistedProfile.checkedAt) {
1371
- return false;
1372
- }
1373
- existingEntry.profile = persistedProfile;
1374
- }
1375
- next.accounts[accountId] = existingEntry;
1376
- }
1377
- await writeJsonAtomic(statePath, pruneUndefined(next));
1378
- return true;
1379
- }
1380
- finally {
1381
- await stateLock.release();
1382
- }
1383
- }
1384
- finally {
1385
- await configLock.release();
1386
- }
1387
- });
1388
- }
1389
- export function clearClaustrumRefreshErrorPersistent(accountId, handle, path = getAccountStoragePath()) {
1390
- return enqueueSave(async () => {
1391
- const configLock = await acquireAccountConfigWriteLock(path);
1392
- try {
1393
- const stateLock = await acquireAccountStateWriteLock(path);
1394
- try {
1395
- // Re-read under both locks so this field-scoped clear cannot write a stale credential or config snapshot.
1396
- const storage = await loadAccounts(path);
1397
- const account = storage?.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
1398
- if (!storage ||
1399
- !account ||
1400
- account.claustrumHandle !== handle ||
1401
- !isClaustrumEnabledForAccount(storage, accountId) ||
1402
- !account.lastRefreshError) {
1403
- return false;
1404
- }
1405
- account.lastRefreshError = undefined;
1406
- await saveAccountStateUnlocked(storage, path, { accounts: [accountId] });
1407
- const statePath = getAccountStatePath(path);
1408
- const state = (await readJsonIfPresent(statePath)).value;
1409
- if (isRecord(state) && isRecord(state.accounts)) {
1410
- const entry = state.accounts[accountId];
1411
- if (isRecord(entry)) {
1412
- delete entry.lastRefreshError;
1413
- await writeJsonAtomic(statePath, pruneUndefined(state));
1414
- }
1415
- }
1416
- return true;
1417
- }
1418
- finally {
1419
- await stateLock.release();
1420
- }
1421
- }
1422
- finally {
1423
- await configLock.release();
1424
- }
1425
- });
1426
- }
1427
- async function saveAccountStateUnlocked(storage, path, scope) {
1428
- const statePath = getAccountStatePath(path);
1429
- const existing = (await readJsonIfPresent(statePath)).value;
1430
- const next = isRecord(existing)
1431
- ? { ...existing, version: 1 }
1432
- : { version: 1 };
1433
- if (scope.mainProfile)
1434
- applyMainProfileStatePatch(next, storage);
1435
- if (scope.mainQuota)
1436
- applyMainQuotaStatePatch(next, storage);
1437
- if (scope.mainRefresh)
1438
- applyMainRefreshStatePatch(next, storage);
1439
- if (scope.mainPrime)
1440
- applyMainPrimeStatePatch(next, storage);
1441
- if (scope.accounts) {
1442
- const ids = scope.accounts === true ? null : new Set(scope.accounts);
1443
- const config = (await readJsonIfPresent(path)).value;
1444
- const configuredIds = (() => {
1445
- if (!isRecord(config) || !Array.isArray(config.accounts))
1446
- return null;
1447
- if (config.accounts.length === 0)
1448
- return new Set();
1449
- const stateAccounts = isRecord(next.accounts) ? next.accounts : {};
1450
- const incomingAccounts = Object.fromEntries(storage.accounts.map((account) => [
1451
- account.id.trim(),
1452
- accountRuntimeState(account),
1453
- ]));
1454
- // Keep both forms because legacy state may still use either key while
1455
- // scoped saves must preserve entries not superseded by an incoming account.
1456
- const memberships = config.accounts.map((account) => {
1457
- if (!isRecord(account) || typeof account.id !== 'string')
1458
- return null;
1459
- const id = account.id.trim();
1460
- if (!id)
1461
- return null;
1462
- if (configAccountHasInvalidShape(account))
1463
- return null;
1464
- const stateValue = stateAccounts[account.id] ?? stateAccounts[id];
1465
- const stateAccount = isRecord(stateValue)
1466
- ? stateValue
1467
- : {};
1468
- const incomingAccount = incomingAccounts[id];
1469
- return normalizeAccount(mergeConfigAccountAndState(account, incomingAccount ?? stateAccount))
1470
- ? [account.id, id]
1471
- : null;
1472
- });
1473
- // A populated config with any unparseable entry cannot establish safe membership.
1474
- return memberships.every((membership) => membership !== null)
1475
- ? new Set(memberships.flatMap((membership) => membership ?? []))
1476
- : null;
1477
- })();
1478
- next.accounts = { ...(isRecord(next.accounts) ? next.accounts : {}) };
1479
- for (const account of storage.accounts) {
1480
- const accountId = account.id.trim();
1481
- if (ids && !ids.has(account.id) && !ids.has(accountId))
1482
- continue;
1483
- if (configuredIds &&
1484
- !configuredIds.has(account.id) &&
1485
- !configuredIds.has(accountId))
1486
- continue;
1487
- const legacyKeys = Object.keys(next.accounts).filter((key) => key !== accountId && key.trim() === accountId);
1488
- const legacyKey = legacyKeys[0];
1489
- const existingAccount = next.accounts[accountId] ??
1490
- (legacyKey ? next.accounts[legacyKey] : undefined);
1491
- for (const key of legacyKeys)
1492
- delete next.accounts[key];
1493
- next.accounts[accountId] = mergeAccountRuntimeState(existingAccount, accountRuntimeState(account));
1494
- }
1495
- if (configuredIds) {
1496
- // Config membership is authoritative for scoped writes too; otherwise a
1497
- // stale writer can preserve state for an account removed out of band.
1498
- for (const id of Object.keys(next.accounts)) {
1499
- if (!configuredIds.has(id))
1500
- delete next.accounts[id];
1501
- }
1502
- }
1503
- if (ids) {
1504
- for (const id of ids) {
1505
- if (!storage.accounts.some((account) => account.id === id)) {
1506
- delete next.accounts[id];
1507
- }
1508
- }
1509
- }
1510
- }
1511
- await writeJsonAtomic(statePath, pruneUndefined(next));
1512
- }
1513
- export async function acquireRefreshFileLock(options) {
1514
- const accountPath = options.path ?? getAccountStoragePath();
1515
- const lockPath = `${accountPath}.${options.name}.lock`;
1516
- const legacyOwnerPath = join(lockPath, 'owner.json');
1517
- const ownerId = randomUUID();
1518
- const now = options.now ?? Date.now;
1519
- let renewTimer = null;
1520
- let released = false;
1521
- async function readOwner() {
1522
- try {
1523
- return JSON.parse(await readFile(lockPath, 'utf8'));
1524
- }
1525
- catch (error) {
1526
- const code = error.code;
1527
- if (code !== 'EISDIR')
1528
- throw error;
1529
- return JSON.parse(await readFile(legacyOwnerPath, 'utf8'));
1530
- }
1531
- }
1532
- async function writeOwner() {
1533
- await writeFile(lockPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600 });
1534
- }
1535
- async function tryAcquire() {
1536
- try {
1537
- await writeFile(lockPath, `${JSON.stringify({ ownerId, expiresAt: now() + options.ttlMs })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
1538
- return true;
1539
- }
1540
- catch (error) {
1541
- const code = error.code;
1542
- if (code === 'EEXIST' || code === 'EISDIR')
1543
- return false;
1544
- throw error;
1545
- }
1546
- }
1547
- function scheduleRenewal() {
1548
- if (!options.renew || released)
1549
- return;
1550
- const intervalMs = options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3));
1551
- renewTimer = setRefreshLockRenewalTimeout(() => {
1552
- void (async () => {
1553
- try {
1554
- const owner = await readOwner();
1555
- const currentNow = now();
1556
- if (released ||
1557
- owner?.ownerId !== ownerId ||
1558
- Number(owner?.expiresAt) <= currentNow) {
1559
- return;
1560
- }
1561
- await writeOwner();
1562
- scheduleRenewal();
1563
- }
1564
- catch {
1565
- // If renewal fails, contenders will wait until the last written expiry.
1566
- }
1567
- })();
1568
- }, intervalMs);
1569
- if ('unref' in renewTimer)
1570
- renewTimer.unref();
1571
- }
1572
- let acquired = await tryAcquire();
1573
- if (!acquired) {
1574
- const evictPath = `${lockPath}.evicting`;
1575
- const evictOwnerPath = join(evictPath, 'owner.json');
1576
- const evictOwnerId = randomUUID();
1577
- const EVICT_TTL = 5_000;
1578
- const MAX_STEAL_ATTEMPTS = 8;
1579
- async function backoff() {
1580
- await new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 4)));
1581
- }
1582
- async function lockIsLive() {
1583
- try {
1584
- const currentOwner = await readOwner();
1585
- return Number(currentOwner?.expiresAt) > now();
1586
- }
1587
- catch {
1588
- try {
1589
- const current = await stat(lockPath);
1590
- return current.mtimeMs + options.ttlMs > now();
1591
- }
1592
- catch {
1593
- // Lock doesn't exist — safe to acquire.
1594
- return false;
1595
- }
1596
- }
1597
- }
1598
- async function ownsEvictionMarker() {
1599
- try {
1600
- const owner = JSON.parse(await readFile(evictOwnerPath, 'utf8'));
1601
- return owner?.ownerId === evictOwnerId;
1602
- }
1603
- catch {
1604
- return false;
1605
- }
1606
- }
1607
- async function tryAcquireEvictionMarker() {
1608
- await mkdir(evictPath);
1609
- try {
1610
- await writeFile(evictOwnerPath, `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
1611
- }
1612
- catch (error) {
1613
- const code = error.code;
1614
- // Another contender can rename the marker directory between mkdir and
1615
- // this exclusive create. Darwin/Bun reports that lost-parent race as
1616
- // either ENOENT or EINVAL; both mean this contender lost the marker.
1617
- if (code === 'ENOENT' || code === 'EINVAL')
1618
- return false;
1619
- await releaseEvictionMarker();
1620
- throw error;
1621
- }
1622
- await options.onStep?.('eviction-marker-acquired');
1623
- return true;
1624
- }
1625
- async function releaseEvictionMarker() {
1626
- if (await ownsEvictionMarker()) {
1627
- await rm(evictPath, { recursive: true, force: true }).catch(() => { });
1628
- }
1629
- }
1630
- for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
1631
- acquired = await tryAcquire();
1632
- if (acquired)
1633
- break;
1634
- if (await lockIsLive())
1635
- return null;
1636
- try {
1637
- if (!(await tryAcquireEvictionMarker())) {
1638
- await backoff();
1639
- continue;
1640
- }
1641
- }
1642
- catch (evictError) {
1643
- const code = evictError.code;
1644
- if (code !== 'EEXIST')
1645
- throw evictError;
1646
- let evictStat;
1647
- try {
1648
- evictStat = await stat(evictPath);
1649
- }
1650
- catch (statError) {
1651
- if (statError.code === 'ENOENT') {
1652
- await backoff();
1653
- continue;
1654
- }
1655
- throw statError;
1656
- }
1657
- if (evictStat.mtimeMs + EVICT_TTL > now())
1658
- return null;
1659
- await options.onStep?.('stale-marker-stat');
1660
- const claimedPath = `${evictPath}.${randomUUID()}`;
1661
- try {
1662
- await rename(evictPath, claimedPath);
1663
- }
1664
- catch (renameError) {
1665
- if (renameError.code === 'ENOENT') {
1666
- await backoff();
1667
- continue;
1668
- }
1669
- throw renameError;
1670
- }
1671
- await options.onStep?.('stale-marker-claimed');
1672
- await rm(claimedPath, { recursive: true, force: true }).catch(() => { });
1673
- await backoff();
1674
- continue;
1675
- }
1676
- try {
1677
- if (await lockIsLive())
1678
- return null;
1679
- if (!(await ownsEvictionMarker()))
1680
- return null;
1681
- await options.onStep?.('stale-lock-confirmed');
1682
- if (!(await ownsEvictionMarker()))
1683
- return null;
1684
- await rm(lockPath, { recursive: true, force: true }).catch(() => { });
1685
- if (!(await ownsEvictionMarker()))
1686
- return null;
1687
- acquired = await tryAcquire();
1688
- if (!acquired)
1689
- return null;
1690
- if (!(await ownsEvictionMarker())) {
1691
- await rm(lockPath, { recursive: true, force: true }).catch(() => { });
1692
- acquired = false;
1693
- return null;
1694
- }
1695
- break;
1696
- }
1697
- finally {
1698
- await releaseEvictionMarker();
1699
- }
1700
- }
1701
- }
1702
- if (!acquired)
1703
- return null;
1704
- scheduleRenewal();
1705
- return {
1706
- release: async () => {
1707
- released = true;
1708
- if (renewTimer) {
1709
- clearRefreshLockRenewalTimeout(renewTimer);
1710
- renewTimer = null;
1711
- }
1712
- try {
1713
- const owner = await readOwner();
1714
- if (owner?.ownerId !== ownerId)
1715
- return;
1716
- }
1717
- catch {
1718
- return;
1719
- }
1720
- await rm(lockPath, { recursive: true, force: true }).catch(() => { });
1721
- },
1722
- };
1723
- }
1724
- export function isCache1hPersistentlyEnabled(storage) {
1725
- return storage?.claudeCache?.enabled === true;
1726
- }
1727
- function normalizeCache1hMode(value) {
1728
- return typeof value === 'string' &&
1729
- CACHE_1H_MODES.includes(value)
1730
- ? value
1731
- : DEFAULT_CACHE_1H_MODE;
1732
- }
1733
- export function getCache1hPersistentMode(storage) {
1734
- return normalizeCache1hMode(storage?.claudeCache?.mode);
1735
- }
1736
- export async function setCache1hPersistentEnabled(enabled, mode, path = getAccountStoragePath()) {
1737
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1738
- storage.claudeCache = {
1739
- ...(storage.claudeCache ?? {}),
1740
- enabled,
1741
- mode: mode ?? getCache1hPersistentMode(storage),
1742
- };
1743
- await saveAccounts(storage, path);
1744
- return storage;
1745
- }
1746
- export async function setCache1hPersistentMode(mode, path = getAccountStoragePath()) {
1747
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1748
- storage.claudeCache = {
1749
- ...(storage.claudeCache ?? {}),
1750
- enabled: storage.claudeCache?.enabled === true,
1751
- mode,
1752
- };
1753
- await saveAccounts(storage, path);
1754
- return storage;
1755
- }
1756
- export function isDumpPersistentlyEnabled(storage) {
1757
- return storage?.dump?.enabled === true;
1758
- }
1759
- export async function setDumpPersistentEnabled(enabled, path = getAccountStoragePath()) {
1760
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1761
- storage.dump = {
1762
- ...(storage.dump ?? {}),
1763
- enabled,
1764
- };
1765
- await saveAccounts(storage, path);
1766
- return storage;
1767
- }
1768
- export function isFastModePersistentlyEnabled(storage) {
1769
- return storage?.claudeFast?.enabled === true;
1770
- }
1771
- export async function setFastModePersistentEnabled(enabled, path = getAccountStoragePath()) {
1772
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1773
- storage.claudeFast = {
1774
- ...(storage.claudeFast ?? {}),
1775
- enabled,
1776
- };
1777
- await saveAccounts(storage, path);
1778
- return storage;
1779
- }
1780
- export async function setCacheKeepPersistentWindow(startHour, endHour, path = getAccountStoragePath()) {
1781
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1782
- storage.cacheKeep = {
1783
- ...(storage.cacheKeep ?? {}),
1784
- enabled: true,
1785
- always: false,
1786
- startHour,
1787
- endHour,
1788
- };
1789
- await saveAccounts(storage, path);
1790
- return storage;
1791
- }
1792
- export async function setCacheKeepPersistentAlways(path = getAccountStoragePath()) {
1793
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1794
- storage.cacheKeep = {
1795
- ...(storage.cacheKeep ?? {}),
1796
- enabled: true,
1797
- always: true,
1798
- };
1799
- delete storage.cacheKeep.startHour;
1800
- delete storage.cacheKeep.endHour;
1801
- await saveAccounts(storage, path);
1802
- return storage;
1803
- }
1804
- export async function setCacheKeepPersistentEnabled(enabled, path = getAccountStoragePath()) {
1805
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1806
- storage.cacheKeep = {
1807
- ...(storage.cacheKeep ?? {}),
1808
- enabled,
1809
- };
1810
- await saveAccounts(storage, path);
1811
- return storage;
1812
- }
1813
- export function isCacheKeepSubagentsEnabled(storage) {
1814
- return storage?.cacheKeep?.subagents === true;
1815
- }
1816
- export async function setCacheKeepSubagentsEnabled(enabled, path = getAccountStoragePath()) {
1817
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1818
- storage.cacheKeep = {
1819
- ...(storage.cacheKeep ?? {}),
1820
- subagents: enabled,
1821
- };
1822
- await saveAccounts(storage, path);
1823
- return storage;
1824
- }
1825
- export function isPrimePersistentlyEnabled(storage) {
1826
- return storage?.prime?.enabled === true;
1827
- }
1828
- export async function setPrimePersistentEnabled(enabled, path = getAccountStoragePath()) {
1829
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1830
- storage.prime = {
1831
- ...(storage.prime ?? {}),
1832
- enabled,
1833
- };
1834
- await saveAccounts(storage, path);
1835
- return storage;
1836
- }
1837
- /** Return the stable prime marker identity for an OAuth account. */
1838
- export async function getOrCreatePrimeAuthLineageId(accountId, path = getAccountStoragePath()) {
1839
- return enqueueSave(async () => {
1840
- const configLock = await acquireAccountConfigWriteLock(path);
1841
- try {
1842
- const stateLock = await acquireAccountStateWriteLock(path);
1843
- try {
1844
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1845
- if (accountId === 'main') {
1846
- const existing = storage.prime?.mainAuthLineageId;
1847
- if (existing)
1848
- return existing;
1849
- const mainAccountId = storage.mainAccountId ?? randomUUID();
1850
- if (!storage.mainAccountId) {
1851
- const existingConfig = await loadExistingTopLevelFields(path);
1852
- await writeJsonAtomic(path, {
1853
- ...existingConfig,
1854
- ...configFromStorage({ ...storage, mainAccountId }),
1855
- });
1856
- storage.mainAccountId = mainAccountId;
1857
- }
1858
- storage.prime = {
1859
- ...(storage.prime ?? {}),
1860
- mainAuthLineageId: mainAccountId,
1861
- };
1862
- await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1863
- return mainAccountId;
1864
- }
1865
- const account = storage.accounts.find((candidate) => candidate.id === accountId && isOAuthAccount(candidate));
1866
- if (!account) {
1867
- throw new Error(`getOrCreatePrimeAuthLineageId: OAuth account "${accountId}" not found`);
1868
- }
1869
- if (account.authLineageId)
1870
- return account.authLineageId;
1871
- // Upgrading changes the marker namespace once, so an already-primed
1872
- // window may fire again; persisting the seed prevents every later token
1873
- // rotation from repeating that migration cost.
1874
- const authLineageId = randomUUID();
1875
- account.authLineageId = authLineageId;
1876
- await saveAccountStateUnlocked(storage, path, {
1877
- accounts: [accountId],
1878
- });
1879
- return authLineageId;
1880
- }
1881
- finally {
1882
- await stateLock.release();
1883
- }
1884
- }
1885
- finally {
1886
- await configLock.release();
1887
- }
1888
- });
1889
- }
1890
- /**
1891
- * Atomically increment an account's cumulative prime counters and persist via
1892
- * the scoped runtime-state path. The `main` account lives at state.main.prime;
1893
- * every other account lives at state.accounts[id].prime. Config-side writes
1894
- * are intentionally NOT triggered so prime counters cannot leak into
1895
- * `anthropic-auth.json`. Callers should not depend on this function to mutate
1896
- * the caller's storage object.
1897
- */
1898
- export async function incrementPrimeUsagePersistent(accountId, usage, path = getAccountStoragePath(), now = Date.now()) {
1899
- const inputTokens = Number.isFinite(usage?.inputTokens)
1900
- ? Math.max(0, Math.floor(usage.inputTokens))
1901
- : 0;
1902
- const outputTokens = Number.isFinite(usage?.outputTokens)
1903
- ? Math.max(0, Math.floor(usage.outputTokens))
1904
- : 0;
1905
- return enqueueSave(async () => {
1906
- // The config-write lock is the repository-wide outer lock for config and
1907
- // runtime-state RMW operations; taking it before the state write preserves
1908
- // saveAccountsLocked's config → state ordering across processes.
1909
- const lock = await acquireAccountConfigWriteLock(path);
1910
- try {
1911
- const stateLock = await acquireAccountStateWriteLock(path);
1912
- try {
1913
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
1914
- if (accountId === 'main') {
1915
- const existing = storage.prime?.main;
1916
- const next = {
1917
- count: (existing?.count ?? 0) + 1,
1918
- inputTokens: (existing?.inputTokens ?? 0) + inputTokens,
1919
- outputTokens: (existing?.outputTokens ?? 0) + outputTokens,
1920
- since: existing?.since ?? Math.floor(now),
1921
- };
1922
- storage.prime = { ...(storage.prime ?? {}), main: next };
1923
- await saveAccountStateUnlocked(storage, path, { mainPrime: true });
1924
- return next;
1925
- }
1926
- const index = storage.accounts.findIndex((account) => account.id === accountId);
1927
- if (index < 0) {
1928
- throw new Error(`incrementPrimeUsagePersistent: account "${accountId}" not found`);
1929
- }
1930
- const account = storage.accounts[index];
1931
- const existing = account.prime;
1932
- const next = {
1933
- count: (existing?.count ?? 0) + 1,
1934
- inputTokens: (existing?.inputTokens ?? 0) + inputTokens,
1935
- outputTokens: (existing?.outputTokens ?? 0) + outputTokens,
1936
- since: existing?.since ?? Math.floor(now),
1937
- };
1938
- storage.accounts[index] = { ...account, prime: next };
1939
- await saveAccountStateUnlocked(storage, path, {
1940
- accounts: [accountId],
1941
- });
1942
- return next;
1943
- }
1944
- finally {
1945
- await stateLock.release();
1946
- }
1947
- }
1948
- finally {
1949
- await lock.release();
1950
- }
1951
- });
1952
- }
1953
- function getFallbackStatuses(storage) {
1954
- return storage?.fallbackOn?.length ? storage.fallbackOn : DEFAULT_FALLBACK_ON;
1955
- }
1956
- export function shouldFallbackStatus(status, storage) {
1957
- return getFallbackStatuses(storage).includes(status);
1958
- }
1959
- export function getQuotaMinimumRemainingThresholds(storage) {
1960
- const configured = storage?.quota?.minimumRemaining || {};
1961
- return {
1962
- five_hour: configured.five_hour ??
1963
- configured['5h'] ??
1964
- DEFAULT_MINIMUM_REMAINING.five_hour,
1965
- seven_day: configured.seven_day ??
1966
- configured['1w'] ??
1967
- DEFAULT_MINIMUM_REMAINING.seven_day,
1968
- };
1969
- }
1970
- function quotaEnabled(storage) {
1971
- return storage?.quota?.enabled !== false;
1972
- }
1973
- function refreshEnabled(storage) {
1974
- return storage?.refresh?.enabled !== false;
1975
- }
1976
- function jitterMs(maxMs) {
1977
- return Math.floor(Math.random() * Math.max(0, maxMs));
1978
- }
1979
- function refreshBeforeExpiryMs(storage) {
1980
- const minutes = storage?.refresh?.refreshBeforeExpiryMinutes ??
1981
- DEFAULT_REFRESH_BEFORE_EXPIRY_MINUTES;
1982
- return Math.max(MIN_REFRESH_BEFORE_EXPIRY_MINUTES, minutes) * 60_000;
1983
- }
1984
- export function getRefreshBeforeExpiryMs(storage) {
1985
- return refreshBeforeExpiryMs(storage);
1986
- }
1987
- export function getRefreshIntervalMs(storage) {
1988
- const minutes = storage?.refresh?.intervalMinutes ?? DEFAULT_REFRESH_INTERVAL_MINUTES;
1989
- return Math.max(1, minutes) * 60_000;
1990
- }
1991
- export function hashRefreshToken(refreshToken) {
1992
- return createHash('sha256').update(refreshToken).digest('hex');
1993
- }
1994
- function isTransientRefreshError(error) {
1995
- const status = error.status;
1996
- if (typeof status === 'number' && Number.isFinite(status)) {
1997
- return status === 429 || status >= 500;
1998
- }
1999
- return isTransientNetworkError(error);
2000
- }
2001
- export function buildRefreshOperationError(input) {
2002
- const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity &&
2003
- input.previous?.refreshTokenFingerprint === input.refreshTokenFingerprint
2004
- ? (input.previous?.retryCount ?? 0)
2005
- : 0;
2006
- const retryCount = previousRetryCount + 1;
2007
- const retryAfterFromError = input.error
2008
- .retryAfter;
2009
- let delay;
2010
- if (typeof retryAfterFromError === 'number' && retryAfterFromError > 0) {
2011
- delay = retryAfterFromError * 1000;
2012
- }
2013
- else if (isTransientNetworkError(input.error)) {
2014
- delay = MIN_REFRESH_RETRY_DELAY_MS;
2015
- }
2016
- else if (isTransientRefreshError(input.error)) {
2017
- delay = Math.min(MAX_REFRESH_RETRY_DELAY_MS, MIN_REFRESH_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6));
2018
- }
2019
- else {
2020
- delay = NON_TRANSIENT_REFRESH_RETRY_DELAY_MS;
2021
- }
2022
- const statusFromError = input.error.status;
2023
- const status = typeof statusFromError === 'number' && Number.isFinite(statusFromError)
2024
- ? statusFromError
2025
- : undefined;
2026
- const message = formatErrorMessage(input.error);
2027
- // A token is permanently dead ONLY on 400 invalid_grant. The OAuth spec allows
2028
- // other 400s (invalid_client / invalid_request / unsupported_grant_type) that
2029
- // re-login does NOT fix — those must stay permanent=false so they are not
2030
- // falsely flagged "needs re-login". Network failures use a fixed five-minute
2031
- // retry interval so connectivity recovery cannot inherit an hour-long backoff.
2032
- // ClaudeOAuthRefreshError carries the raw
2033
- // OAuth body, and its message embeds it (`...: 400 — <body>`), so check both.
2034
- const body = typeof input.error.body === 'string'
2035
- ? input.error.body
2036
- : '';
2037
- const isInvalidGrant = body.includes('invalid_grant') || message.includes('invalid_grant');
2038
- return {
2039
- message,
2040
- checkedAt: input.now,
2041
- nextRetryAt: input.now + delay,
2042
- retryCount,
2043
- accountIdentity: input.accountIdentity,
2044
- refreshTokenFingerprint: input.refreshTokenFingerprint,
2045
- status,
2046
- permanent: status === 400 && isInvalidGrant,
2047
- };
2048
- }
2049
- /**
2050
- * True when a refresh error means the token is permanently dead and the account
2051
- * needs a re-login (vs a transient failure that recovers).
2052
- *
2053
- * Precedence:
2054
- * 1. the explicit `permanent` flag (set at construction from 400 invalid_grant)
2055
- * — the authoritative signal; correctly classifies a retry-exhausted/network
2056
- * error (long backoff, but NOT dead) as non-permanent;
2057
- * 2. else the captured HTTP `status` — 400 (for errors built before `permanent`
2058
- * existed but after `status`);
2059
- * 3. else the legacy 24h-delay heuristic — back-compat ONLY for errors persisted
2060
- * before either field existed (e.g. an operator's already-dead token: no
2061
- * status, ~24h backoff). It still flags those until the next refresh restamps
2062
- * the error with the explicit field.
2063
- */
2064
- export function isPermanentRefreshError(error) {
2065
- if (!error)
2066
- return false;
2067
- if (typeof error.permanent === 'boolean')
2068
- return error.permanent;
2069
- if (typeof error.status === 'number')
2070
- return error.status === 400;
2071
- if (isTransientNetworkError(error))
2072
- return false;
2073
- if (typeof error.nextRetryAt === 'number') {
2074
- return (error.nextRetryAt - error.checkedAt >=
2075
- NON_TRANSIENT_REFRESH_RETRY_DELAY_MS);
2076
- }
2077
- return false;
2078
- }
2079
- function effectiveRefreshRetryAt(error) {
2080
- const persistedRetryAt = error.nextRetryAt;
2081
- if (!persistedRetryAt || !isTransientNetworkError(error)) {
2082
- return persistedRetryAt;
2083
- }
2084
- return Math.min(persistedRetryAt, error.checkedAt + MIN_REFRESH_RETRY_DELAY_MS);
2085
- }
2086
- export function refreshBackoffActive(error, accountIdentity, now, currentRefreshTokenFingerprint) {
2087
- if (!error)
2088
- return false;
2089
- const retryAt = effectiveRefreshRetryAt(error);
2090
- if (!retryAt || retryAt <= now)
2091
- return false;
2092
- if (error.refreshTokenFingerprint &&
2093
- currentRefreshTokenFingerprint &&
2094
- error.refreshTokenFingerprint !== currentRefreshTokenFingerprint) {
2095
- return false;
2096
- }
2097
- if (!error.accountIdentity)
2098
- return true;
2099
- if (!accountIdentity)
2100
- return true;
2101
- return error.accountIdentity === accountIdentity;
2102
- }
2103
- export function formatRefreshBackoffMessage(error, now) {
2104
- const seconds = Math.max(1, Math.ceil(((effectiveRefreshRetryAt(error) ?? now) - now) / 1000));
2105
- return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
2106
- }
2107
- function createRefreshBackoffActiveError(error, now) {
2108
- return Object.assign(new Error(formatRefreshBackoffMessage(error, now)), {
2109
- refreshBackoffError: error,
2110
- });
2111
- }
2112
- function existingRefreshBackoffError(error) {
2113
- if (!error || typeof error !== 'object')
2114
- return undefined;
2115
- const existing = error
2116
- .refreshBackoffError;
2117
- return existing && typeof existing.message === 'string' ? existing : undefined;
2118
- }
2119
- export function getFallbackReauthLabels(storage) {
2120
- if (!storage)
2121
- return [];
2122
- return storage.accounts
2123
- .filter((account) => account.enabled !== false &&
2124
- isOAuthAccount(account) &&
2125
- isPermanentRefreshError(account.lastRefreshError))
2126
- .map((account) => account.label?.trim() || account.id);
2127
- }
2128
- export function isQuotaPolicyAuthError(error) {
2129
- const status = error.status;
2130
- if (status === 403)
2131
- return true;
2132
- return /Claude quota check failed: 403\b/.test(formatErrorMessage(error));
2133
- }
2134
- export function buildQuotaOperationError(input) {
2135
- const previousRetryCount = input.previous?.accountIdentity === input.accountIdentity
2136
- ? (input.previous?.retryCount ?? 0)
2137
- : 0;
2138
- const retryCount = previousRetryCount + 1;
2139
- const delay = isTransientQuotaError(input.error)
2140
- ? Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
2141
- : NON_TRANSIENT_QUOTA_RETRY_DELAY_MS;
2142
- return {
2143
- message: formatErrorMessage(input.error),
2144
- checkedAt: input.now,
2145
- nextRetryAt: input.now + delay,
2146
- retryCount,
2147
- ...(input.accountIdentity !== undefined && {
2148
- accountIdentity: input.accountIdentity,
2149
- }),
2150
- };
2151
- }
2152
- export function quotaBackoffActive(error, now) {
2153
- if (!error?.nextRetryAt || error.nextRetryAt <= now)
2154
- return false;
2155
- return true;
2156
- }
2157
- export function formatQuotaBackoffMessage(error, now) {
2158
- const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
2159
- return `Quota API backed off for ${seconds}s after: ${error.message}`;
2160
- }
2161
- export function getQuotaCheckIntervalMs(storage) {
2162
- const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
2163
- return Math.max(1, minutes) * 60_000;
2164
- }
2165
- export function getPersistedLogLevel(storage) {
2166
- return storage?.logging?.level;
2167
- }
2168
- export async function setLogLevelPersistent(level, path = getAccountStoragePath()) {
2169
- const { setLogLevel } = await import('./logger.js');
2170
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
2171
- storage.logging = {
2172
- ...(storage.logging ?? {}),
2173
- level,
2174
- };
2175
- await saveAccounts(storage, path);
2176
- setLogLevel(level);
2177
- }
2178
- export function getPersistedMainQuota(storage) {
2179
- if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt)
2180
- return null;
2181
- return {
2182
- quota: storage.quota.mainQuota,
2183
- checkedAt: storage.quota.mainQuotaCheckedAt,
2184
- tokenFingerprint: storage.quota.mainQuotaToken,
2185
- accountIdentity: storage.quota.mainQuota.accountIdentity,
2186
- };
2187
- }
2188
- /**
2189
- * How often (in requests) to force a quota refresh, independent of the timer.
2190
- * Returns 0 when disabled (default).
2191
- */
2192
- export function getQuotaRefreshEveryNRequests(storage) {
2193
- const n = storage?.quota?.refreshEveryNRequests;
2194
- return typeof n === 'number' && Number.isFinite(n) && n > 0
2195
- ? Math.floor(n)
2196
- : 0;
2197
- }
2198
- function failClosedOnUnknownQuota(storage) {
2199
- return (storage?.quota?.failClosedOnUnknownQuota ??
2200
- DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
2201
- }
2202
- function normalizeScopedQuotaModel(value) {
2203
- return value.toLowerCase().replace(/[^a-z0-9]/g, '');
2204
- }
2205
- function scopedQuotaModelKey(model) {
2206
- if (typeof model !== 'string')
2207
- return null;
2208
- const normalized = normalizeScopedQuotaModel(model);
2209
- if (normalized.includes('fable'))
2210
- return 'fable';
2211
- if (normalized.includes('mythos'))
2212
- return 'mythos';
2213
- return normalized;
2214
- }
2215
- export function getScopedQuotaWindowForModel(quota, model) {
2216
- const key = scopedQuotaModelKey(model);
2217
- if (!key)
2218
- return undefined;
2219
- return quota?.scoped?.find((window) => {
2220
- const haystack = [window.modelId, window.modelName, window.title]
2221
- .filter((value) => typeof value === 'string')
2222
- .map(normalizeScopedQuotaModel)
2223
- .join(' ');
2224
- return haystack.includes(key);
2225
- });
2226
- }
2227
- export function quotaSnapshotModelScopeIsExhausted(quota, model) {
2228
- const window = getScopedQuotaWindowForModel(quota, model);
2229
- return Boolean(window &&
2230
- Number.isFinite(window.remainingPercent) &&
2231
- window.remainingPercent <= 0);
2232
- }
2233
- export function quotaSnapshotPassesModelScope(quota, model) {
2234
- return !quotaSnapshotModelScopeIsExhausted(quota, model);
2235
- }
2236
- export function quotaSnapshotPassesPolicy(quota, storage) {
2237
- if (!quotaEnabled(storage))
2238
- return true;
2239
- const thresholds = getQuotaMinimumRemainingThresholds(storage);
2240
- for (const key of ['five_hour', 'seven_day']) {
2241
- const window = quota?.[key];
2242
- if (!window)
2243
- return !failClosedOnUnknownQuota(storage);
2244
- if (!Number.isFinite(window.remainingPercent)) {
2245
- return !failClosedOnUnknownQuota(storage);
2246
- }
2247
- if (window.remainingPercent < thresholds[key])
2248
- return false;
2249
- }
2250
- return true;
2251
- }
2252
- export function quotaSnapshotHasStandardWindows(quota) {
2253
- return Boolean(quota?.five_hour && quota.seven_day);
2254
- }
2255
- // ---------------------------------------------------------------------------
2256
- // Killswitch — hard-block requests when remaining quota drops below per-account
2257
- // thresholds, even if the API would still accept them.
2258
- // ---------------------------------------------------------------------------
2259
- export const DEFAULT_KILLSWITCH_THRESHOLDS = {
2260
- five_hour: 5,
2261
- seven_day: 10,
2262
- scoped: 0,
2263
- };
2264
- export function normalizeKillswitchThresholds(thresholds) {
2265
- const fiveHour = thresholds?.five_hour ?? thresholds?.['5h'];
2266
- const sevenDay = thresholds?.seven_day ?? thresholds?.['1w'];
2267
- const scoped = thresholds?.scoped;
2268
- return {
2269
- five_hour: typeof fiveHour === 'number' && Number.isFinite(fiveHour)
2270
- ? fiveHour
2271
- : DEFAULT_KILLSWITCH_THRESHOLDS.five_hour,
2272
- seven_day: typeof sevenDay === 'number' && Number.isFinite(sevenDay)
2273
- ? sevenDay
2274
- : DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
2275
- scoped: typeof scoped === 'number' && Number.isFinite(scoped)
2276
- ? scoped
2277
- : DEFAULT_KILLSWITCH_THRESHOLDS.scoped,
2278
- };
2279
- }
2280
- export function isKillswitchEnabled(storage) {
2281
- return storage?.killswitch?.enabled === true;
2282
- }
2283
- export function getKillswitchThresholdsForAccount(storage, accountId) {
2284
- if (!storage?.killswitch)
2285
- return DEFAULT_KILLSWITCH_THRESHOLDS;
2286
- if (accountId && storage.killswitch.accounts?.[accountId]) {
2287
- return normalizeKillswitchThresholds(storage.killswitch.accounts[accountId]);
2288
- }
2289
- return normalizeKillswitchThresholds(storage.killswitch.main);
2290
- }
2291
- /**
2292
- * Returns true if the account's quota is above its killswitch threshold.
2293
- * When killswitch is disabled, always returns true.
2294
- *
2295
- * When `modelId` is provided, the per-account `scoped` threshold is also
2296
- * evaluated against the quota window matching that model — additive to the
2297
- * 5h/7d check. A model with no matching scoped window is unaffected.
2298
- */
2299
- export function killswitchPassesPolicy(quota, storage, accountId, modelId) {
2300
- if (!isKillswitchEnabled(storage))
2301
- return true;
2302
- const thresholds = getKillswitchThresholdsForAccount(storage, accountId);
2303
- let sawUnknownWindow = false;
2304
- for (const key of ['five_hour', 'seven_day']) {
2305
- const window = quota?.[key];
2306
- // Defer the unknown-window decision: a quota snapshot can legally carry
2307
- // only one window, and a present window below its threshold must still
2308
- // block even if the other window is missing.
2309
- if (!window) {
2310
- sawUnknownWindow = true;
2311
- continue;
2312
- }
2313
- if (!Number.isFinite(window.remainingPercent)) {
2314
- sawUnknownWindow = true;
2315
- continue;
2316
- }
2317
- if (window.remainingPercent < thresholds[key])
2318
- return false;
2319
- }
2320
- // Scoped check is additive to the 5h/7d evaluation above and is an
2321
- // INDEPENDENT block reason — it must run before the unknown-window
2322
- // fail-closed decision, so an exhausted scoped window blocks even when
2323
- // 5h/7d is missing/non-finite (the latter only changes the fall-through
2324
- // for accounts that did not already block on scoped). A missing scoped
2325
- // window (no carve-out for this model) is not "unknown quota" — only a
2326
- // PRESENT window at/below threshold blocks. The comparison is inclusive
2327
- // (`<=`) so the default 0 fires at exhaustion.
2328
- if (modelId) {
2329
- const scopedWindow = getScopedQuotaWindowForModel(quota, modelId);
2330
- if (scopedWindow &&
2331
- Number.isFinite(scopedWindow.remainingPercent) &&
2332
- scopedWindow.remainingPercent <= thresholds.scoped) {
2333
- return false;
2334
- }
2335
- }
2336
- if (sawUnknownWindow)
2337
- return !failClosedOnUnknownQuota(storage);
2338
- return true;
2339
- }
2340
- /**
2341
- * Find the earliest reset time across all accounts' quota windows.
2342
- * Returns seconds from `now` until that reset, or 300 as a fallback.
2343
- *
2344
- * When `scopedModelId` is provided, ONLY the matched scoped window's
2345
- * `resetsAt` is considered — the 5h/7d resets are intentionally ignored
2346
- * so the retry hint reflects the weekly reset, not the sooner 5h reset
2347
- * (which would cause a retry-storm against a block that won't clear for
2348
- * days). With `scopedModelId` undefined, the 5h/7d behavior is unchanged.
2349
- */
2350
- export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now, scopedModelId) {
2351
- const resetTimes = [];
2352
- const allQuotas = [mainQuota, ...fallbackAccounts.map((a) => a.quota)];
2353
- for (const quota of allQuotas) {
2354
- if (scopedModelId) {
2355
- const scopedWindow = getScopedQuotaWindowForModel(quota, scopedModelId);
2356
- const resetStr = scopedWindow?.resetsAt;
2357
- if (!resetStr)
2358
- continue;
2359
- const resetTime = Date.parse(resetStr);
2360
- if (Number.isFinite(resetTime) && resetTime > now) {
2361
- resetTimes.push(resetTime);
2362
- }
2363
- }
2364
- else {
2365
- for (const key of ['five_hour', 'seven_day']) {
2366
- const resetStr = quota?.[key]?.resetsAt;
2367
- if (!resetStr)
2368
- continue;
2369
- const resetTime = Date.parse(resetStr);
2370
- if (Number.isFinite(resetTime) && resetTime > now) {
2371
- resetTimes.push(resetTime);
2372
- }
2373
- }
2374
- }
2375
- }
2376
- if (!resetTimes.length)
2377
- return 300;
2378
- return Math.max(1, Math.ceil((Math.min(...resetTimes) - now) / 1000)) + 60;
2379
- }
2380
- export function getKillswitchConfig(storage) {
2381
- return storage?.killswitch ?? { enabled: false };
2382
- }
2383
- export async function setKillswitchPersistent(config, path = getAccountStoragePath()) {
2384
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
2385
- storage.killswitch = config;
2386
- await saveAccounts(storage, path);
2387
- return storage;
2388
- }
2389
- export async function removeAccountPersistent(id, path = getAccountStoragePath()) {
2390
- const storage = await loadAccounts(path);
2391
- if (!storage)
2392
- return false;
2393
- const existed = removeAccount(storage, id);
2394
- if (existed) {
2395
- await saveAccounts(storage, path, { removedAccountIds: [id] });
2396
- }
2397
- return existed;
2398
- }
2399
- export async function reorderAccountsPersistent(orderedIds, path = getAccountStoragePath()) {
2400
- const storage = await loadAccounts(path);
2401
- if (!storage)
2402
- return;
2403
- reorderAccounts(storage, orderedIds);
2404
- await saveAccounts(storage, path, { preserveExistingAccountOrder: false });
2405
- }
2406
- export async function setAccountEnabledPersistent(id, enabled, path = getAccountStoragePath()) {
2407
- const storage = await loadAccounts(path);
2408
- if (!storage)
2409
- return false;
2410
- const found = setAccountEnabled(storage, id, enabled);
2411
- if (found)
2412
- await saveAccounts(storage, path);
2413
- return found;
2414
- }
2415
- export async function addAccountPersistent(account, path = getAccountStoragePath()) {
2416
- const storage = (await loadAccounts(path)) ?? createEmptyStorage();
2417
- upsertAccount(storage, account);
2418
- await saveAccounts(storage, path);
2419
- }
2420
- export function getQuotaNextRefreshAt(quota, storage, now) {
2421
- const intervalMs = getQuotaCheckIntervalMs(storage);
2422
- if (!quotaEnabled(storage))
2423
- return now + intervalMs;
2424
- const windowFreshnessDeadline = Math.min(...[
2425
- ...['five_hour', 'seven_day'].map((key) => quota?.[key]?.checkedAt),
2426
- ...(quota?.scoped ?? []).map((window) => window.checkedAt),
2427
- ]
2428
- .filter((checkedAt) => Number.isFinite(checkedAt))
2429
- .map((checkedAt) => checkedAt + intervalMs));
2430
- const capAtOldestWindow = (candidate) => Number.isFinite(windowFreshnessDeadline)
2431
- ? Math.min(candidate, windowFreshnessDeadline)
2432
- : candidate;
2433
- const thresholds = getQuotaMinimumRemainingThresholds(storage);
2434
- const blockedResetTimes = [];
2435
- for (const key of ['five_hour', 'seven_day']) {
2436
- const window = quota?.[key];
2437
- if (!window)
2438
- return capAtOldestWindow(now + intervalMs);
2439
- if (window.remainingPercent >= thresholds[key])
2440
- continue;
2441
- const resetTime = window.resetsAt ? Date.parse(window.resetsAt) : Number.NaN;
2442
- if (!Number.isFinite(resetTime) || resetTime <= now) {
2443
- return capAtOldestWindow(now + intervalMs);
2444
- }
2445
- blockedResetTimes.push(resetTime);
2446
- }
2447
- if (!blockedResetTimes.length)
2448
- return capAtOldestWindow(now + intervalMs);
2449
- return capAtOldestWindow(Math.min(...blockedResetTimes) + 60_000);
2450
- }
2451
- function tokenNeedsRefresh(account, storage, now) {
2452
- return (!account.access ||
2453
- !account.expires ||
2454
- account.expires - now <= refreshBeforeExpiryMs(storage));
2455
- }
2456
- function quotaSnapshotIsFresh(quota, storage, now) {
2457
- if (!quotaEnabled(storage))
2458
- return true;
2459
- const maxAge = getQuotaCheckIntervalMs(storage);
2460
- return ['five_hour', 'seven_day'].every((key) => {
2461
- const window = quota?.[key];
2462
- return Boolean(window && now - window.checkedAt < maxAge);
2463
- });
2464
- }
2465
- function quotaIsStale(account, storage, now, modelId) {
2466
- if (!quotaSnapshotIsFresh(account.quota, storage, now))
2467
- return true;
2468
- const scoped = getScopedQuotaWindowForModel(account.quota, modelId);
2469
- return Boolean(scoped && now - scoped.checkedAt >= getQuotaCheckIntervalMs(storage));
2470
- }
2471
- function cachedQuotaWindowStillRelevant(window, now) {
2472
- if (!window)
2473
- return false;
2474
- if (!window.resetsAt)
2475
- return true;
2476
- const resetTime = Date.parse(window.resetsAt);
2477
- return !Number.isFinite(resetTime) || resetTime > now;
2478
- }
2479
- function cachedQuotaSnapshotStillRelevant(quota, now) {
2480
- return ['five_hour', 'seven_day'].every((key) => cachedQuotaWindowStillRelevant(quota?.[key], now));
2481
- }
2482
- function isTransientQuotaError(error) {
2483
- const status = error.status;
2484
- if (typeof status === 'number' && Number.isFinite(status)) {
2485
- if (status === 429 || status >= 500)
2486
- return true;
2487
- }
2488
- const formattedMessage = formatErrorMessage(error);
2489
- if (/Claude quota check failed: (429|5\d\d)\b/.test(formattedMessage)) {
2490
- return true;
2491
- }
2492
- if (formattedMessage.includes('Quota refresh is already in progress')) {
2493
- return true;
2494
- }
2495
- return isTransientNetworkError(error);
2496
- }
2497
- function canUseCachedQuotaAfterRefreshError(account, storage, error, now) {
2498
- return (Boolean(account.access && account.expires && account.expires > now) &&
2499
- isTransientQuotaError(error) &&
2500
- quotaSnapshotPassesPolicy(account.quota, storage) &&
2501
- cachedQuotaSnapshotStillRelevant(account.quota, now));
2502
- }
2503
- function clampPercent(value) {
2504
- if (!Number.isFinite(value))
2505
- return 0;
2506
- if (value < 0)
2507
- return 0;
2508
- if (value > 100)
2509
- return 100;
2510
- return value;
2511
- }
2512
- function nonEmptyString(value) {
2513
- return typeof value === 'string' && value.trim() ? value.trim() : undefined;
2514
- }
2515
- function slugForQuotaIdentity(value) {
2516
- return value
2517
- .toLowerCase()
2518
- .replace(/[^a-z0-9]+/g, '-')
2519
- .replace(/^-+|-+$/g, '');
2520
- }
2521
- function mapScopedWeeklyLimits(limits, checkedAt) {
2522
- if (!Array.isArray(limits))
2523
- return [];
2524
- const seen = new Set();
2525
- const scoped = [];
2526
- for (const limit of limits) {
2527
- if (limit?.kind !== 'weekly_scoped' || limit.group !== 'weekly')
2528
- continue;
2529
- if (typeof limit.percent !== 'number' || !Number.isFinite(limit.percent)) {
2530
- continue;
2531
- }
2532
- const modelName = nonEmptyString(limit.scope?.model?.display_name);
2533
- if (!modelName)
2534
- continue;
2535
- const identity = nonEmptyString(limit.scope?.model?.id) ?? modelName;
2536
- const slug = slugForQuotaIdentity(identity);
2537
- if (!slug)
2538
- continue;
2539
- const id = `claude-weekly-scoped-${slug}`;
2540
- if (seen.has(id))
2541
- continue;
2542
- seen.add(id);
2543
- const usedPercent = clampPercent(limit.percent);
2544
- const modelId = nonEmptyString(limit.scope?.model?.id);
2545
- scoped.push({
2546
- id,
2547
- title: `${modelName} only`,
2548
- ...(modelId && { modelId }),
2549
- modelName,
2550
- usedPercent,
2551
- remainingPercent: clampPercent(100 - usedPercent),
2552
- resetsAt: limit.resets_at,
2553
- checkedAt,
2554
- });
2555
- }
2556
- return scoped;
2557
- }
2558
- function mapExtraUsage(usage) {
2559
- if (usage.extra_usage?.is_enabled !== true)
2560
- return undefined;
2561
- const usedAmount = usage.extra_usage.used_credits;
2562
- const limitAmount = usage.extra_usage.monthly_limit;
2563
- if (typeof usedAmount !== 'number' ||
2564
- !Number.isFinite(usedAmount) ||
2565
- typeof limitAmount !== 'number' ||
2566
- !Number.isFinite(limitAmount)) {
2567
- return undefined;
2568
- }
2569
- const rawCurrency = usage.spend?.limit?.currency;
2570
- const currency = rawCurrency == null ? 'USD' : nonEmptyString(rawCurrency);
2571
- const rawExponent = usage.spend?.limit?.exponent;
2572
- const moneyExponent = rawExponent == null ? 2 : rawExponent;
2573
- if (!currency ||
2574
- !/^[A-Za-z]{3}$/.test(currency) ||
2575
- !Number.isInteger(moneyExponent) ||
2576
- moneyExponent < 0 ||
2577
- moneyExponent > 20) {
2578
- return undefined;
2579
- }
2580
- return {
2581
- used: { amountMinor: usedAmount, currency, exponent: moneyExponent },
2582
- limit: { amountMinor: limitAmount, currency, exponent: moneyExponent },
2583
- ...(typeof usage.extra_usage.utilization === 'number' &&
2584
- Number.isFinite(usage.extra_usage.utilization) && {
2585
- utilizationPercent: usage.extra_usage.utilization,
2586
- }),
2587
- ...(nonEmptyString(usage.spend?.severity) && {
2588
- severity: nonEmptyString(usage.spend?.severity),
2589
- }),
2590
- exhausted: usedAmount >= limitAmount,
2591
- };
2592
- }
2593
- function mapBindingWindow(limits) {
2594
- if (!Array.isArray(limits))
2595
- return undefined;
2596
- const active = limits.find((limit) => limit?.is_active === true);
2597
- if (!active)
2598
- return undefined;
2599
- if (active.kind === 'session')
2600
- return 'five_hour';
2601
- if (active.kind === 'weekly_all')
2602
- return 'seven_day';
2603
- if (active.kind !== 'weekly_scoped' || active.group !== 'weekly') {
2604
- return undefined;
2605
- }
2606
- const modelName = nonEmptyString(active.scope?.model?.display_name);
2607
- if (!modelName)
2608
- return undefined;
2609
- const identity = nonEmptyString(active.scope?.model?.id) ?? modelName;
2610
- const slug = slugForQuotaIdentity(identity);
2611
- return slug ? `claude-weekly-scoped-${slug}` : undefined;
2612
- }
2613
- function mapUsageWindow(window, checkedAt) {
2614
- if (typeof window?.utilization !== 'number')
2615
- return undefined;
2616
- if (!Number.isFinite(window.utilization))
2617
- return undefined;
2618
- const usedPercent = clampPercent(window.utilization);
2619
- return {
2620
- usedPercent,
2621
- remainingPercent: clampPercent(100 - usedPercent),
2622
- resetsAt: window.resets_at,
2623
- checkedAt,
2624
- };
2625
- }
2626
- export async function fetchOAuthQuotaSnapshot(input) {
2627
- const fetchImpl = input.fetchImpl ?? fetch;
2628
- const response = await fetchImpl(QUOTA_URL, {
2629
- method: 'GET',
2630
- headers: {
2631
- Authorization: `Bearer ${input.accessToken}`,
2632
- Accept: 'application/json',
2633
- 'Content-Type': 'application/json',
2634
- 'anthropic-beta': 'oauth-2025-04-20',
2635
- 'User-Agent': `claude-code/${CLAUDE_CODE_VERSION}`,
2636
- },
2637
- });
2638
- if (!response.ok) {
2639
- const body = await response.text().catch(() => '');
2640
- const error = Object.assign(new Error(`Claude quota check failed: ${response.status} — ${body}`), {
2641
- status: response.status,
2642
- retryAfter: parseRetryAfterHeader(response.headers.get('Retry-After')),
2643
- });
2644
- throw error;
2645
- }
2646
- const checkedAt = input.now?.() ?? Date.now();
2647
- const usage = (await response.json());
2648
- const bindingWindow = mapBindingWindow(usage.limits);
2649
- const snapshot = {
2650
- five_hour: mapUsageWindow(usage.five_hour, checkedAt),
2651
- seven_day: mapUsageWindow(usage.seven_day, checkedAt),
2652
- scoped: mapScopedWeeklyLimits(usage.limits, checkedAt),
2653
- extraUsage: mapExtraUsage(usage),
2654
- ...(bindingWindow && {
2655
- bindingWindow,
2656
- bindingWindowSource: 'poll',
2657
- }),
2658
- source: 'poll',
2659
- checkedAt,
2660
- };
2661
- const fieldSources = {
2662
- ...(snapshot.five_hour && { five_hour: 'poll' }),
2663
- ...(snapshot.seven_day && { seven_day: 'poll' }),
2664
- ...(snapshot.scoped && { scoped: 'poll' }),
2665
- ...(snapshot.extraUsage && { extraUsage: 'poll' }),
2666
- ...(snapshot.bindingWindow && { bindingWindow: 'poll' }),
2667
- };
2668
- return {
2669
- ...snapshot,
2670
- ...(Object.keys(fieldSources).length > 0 && { fieldSources }),
2671
- };
2672
- }
2673
- function updateStoredAccount(storage, account) {
2674
- const index = storage.accounts.findIndex((candidate) => candidate.id === account.id);
2675
- if (index >= 0)
2676
- storage.accounts[index] = account;
2677
- }
2678
- export function upsertAccount(storage, account) {
2679
- const index = storage.accounts.findIndex((candidate) => candidate.id === account.id ||
2680
- (account.label && candidate.label === account.label));
2681
- if (index >= 0) {
2682
- storage.accounts[index] = {
2683
- ...storage.accounts[index],
2684
- ...account,
2685
- addedAt: storage.accounts[index]?.addedAt ?? account.addedAt,
2686
- ...(account.type === 'oauth' && {
2687
- quota: account.quota,
2688
- profile: account.profile,
2689
- lastRefreshedAt: account.lastRefreshedAt,
2690
- lastRefreshError: account.lastRefreshError,
2691
- lastQuotaRefreshError: account.lastQuotaRefreshError,
2692
- }),
2693
- };
2694
- return;
2695
- }
2696
- storage.accounts.push(account);
2697
- }
2698
- export function removeAccount(storage, id) {
2699
- const index = storage.accounts.findIndex((c) => c.id === id);
2700
- if (index < 0)
2701
- return false;
2702
- storage.accounts.splice(index, 1);
2703
- return true;
2704
- }
2705
- export function reorderAccounts(storage, orderedIds) {
2706
- const orderMap = new Map(orderedIds.map((id, i) => [id, i]));
2707
- const known = storage.accounts.filter((a) => orderMap.has(a.id));
2708
- const unknown = storage.accounts.filter((a) => !orderMap.has(a.id));
2709
- known.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
2710
- storage.accounts = [...known, ...unknown];
2711
- }
2712
- export function setAccountEnabled(storage, id, enabled) {
2713
- const account = storage.accounts.find((c) => c.id === id);
2714
- if (!account)
2715
- return false;
2716
- account.enabled = enabled;
2717
- return true;
2718
- }
2719
- function formatErrorMessage(error) {
2720
- return error instanceof Error ? error.message : String(error);
2721
- }
2722
- function recordRefreshError(account, error, now) {
2723
- if (error instanceof CustodyTombstoneRefreshError)
2724
- return;
2725
- const existing = existingRefreshBackoffError(error);
2726
- if (existing) {
2727
- account.lastRefreshError = existing;
2728
- return;
2729
- }
2730
- account.lastRefreshError = buildRefreshOperationError({
2731
- error,
2732
- now,
2733
- accountIdentity: account.id,
2734
- refreshTokenFingerprint: tokenFingerprint(account.refresh),
2735
- previous: account.lastRefreshError,
2736
- });
2737
- }
2738
- function recordQuotaRefreshError(account, error, now) {
2739
- if (error instanceof CustodyTombstoneRefreshError)
2740
- return;
2741
- if (isQuotaPolicyAuthError(error) || existingRefreshBackoffError(error))
2742
- return;
2743
- account.lastQuotaRefreshError = buildQuotaOperationError({
2744
- error,
2745
- now,
2746
- previous: account.lastQuotaRefreshError,
2747
- });
2748
- if (error.isRefreshError) {
2749
- recordRefreshError(account, error, now);
2750
- }
2751
- }
2752
- function fallbackRefreshLockName(accountId) {
2753
- return `fallback-oauth-refresh-${createHash('sha256')
2754
- .update(accountId)
2755
- .digest('hex')
2756
- .slice(0, 16)}`;
2757
- }
2758
- export class FallbackAccountManager {
2759
- now;
2760
- fetchImpl;
2761
- configPath;
2762
- refreshPromises = new Map();
2763
- refreshTimer = null;
2764
- quotaTimer = null;
2765
- quotaManager;
2766
- isFallbackAccountVaultServed;
2767
- isFallbackAccountVaultEnabled;
2768
- resolveFallbackAccessToken;
2769
- onBackgroundRefresh;
2770
- onFallbackStorageChanged;
2771
- setIntervalImpl;
2772
- clearIntervalImpl;
2773
- constructor(options = {}) {
2774
- this.now = options.now ?? Date.now;
2775
- this.fetchImpl = options.fetchImpl ?? fetch;
2776
- this.configPath = options.configPath ?? getAccountStoragePath();
2777
- this.quotaManager = options.quotaManager ?? null;
2778
- this.isFallbackAccountVaultServed =
2779
- options.isFallbackAccountVaultServed ?? (() => false);
2780
- this.isFallbackAccountVaultEnabled =
2781
- options.isFallbackAccountVaultEnabled ?? (() => false);
2782
- this.resolveFallbackAccessToken =
2783
- options.resolveFallbackAccessToken ??
2784
- ((account) => {
2785
- if (!account.access ||
2786
- account.expires === undefined ||
2787
- account.expires <= this.now()) {
2788
- return undefined;
2789
- }
2790
- return { token: account.access, source: 'sidecar' };
2791
- });
2792
- this.onBackgroundRefresh = options.onBackgroundRefresh;
2793
- this.onFallbackStorageChanged = options.onFallbackStorageChanged;
2794
- this.setIntervalImpl = options.setIntervalImpl ?? globalThis.setInterval;
2795
- this.clearIntervalImpl =
2796
- options.clearIntervalImpl ?? globalThis.clearInterval;
2797
- }
2798
- /**
2799
- * Seed QuotaManager from persisted account.quota if no cache entry exists
2800
- * yet. Prevents unnecessary API calls when the on-disk snapshot is fresh.
2801
- */
2802
- seedFallbackQuota(account, storage) {
2803
- if (!this.quotaManager)
2804
- return;
2805
- if (!account.quota)
2806
- return;
2807
- const checkedAt = quotaSnapshotCheckedAt(account.quota);
2808
- if (checkedAt <= 0)
2809
- return;
2810
- const existing = this.quotaManager.getFallback(account.id, account);
2811
- if (existing && existing.checkedAt >= checkedAt)
2812
- return;
2813
- const checkInterval = getQuotaCheckIntervalMs(storage);
2814
- this.quotaManager.setFallback(account.id, {
2815
- quota: account.quota,
2816
- refreshAfter: checkedAt + checkInterval,
2817
- checkedAt,
2818
- }, account);
2819
- }
2820
- async load() {
2821
- return loadAccounts(this.configPath);
2822
- }
2823
- async save(storage, accountIds) {
2824
- await saveAccountState(storage, this.configPath, {
2825
- accounts: accountIds ?? true,
2826
- });
2827
- }
2828
- startBackgroundRefresh() {
2829
- const run = async (initial = false) => {
2830
- await this.onBackgroundRefresh?.(initial);
2831
- await this.refreshDueAccounts();
2832
- await this.refreshQuotaForDueAccounts();
2833
- };
2834
- const initialRun = run(true).catch(() => { });
2835
- if (!this.refreshTimer) {
2836
- this.refreshTimer = this.setIntervalImpl(() => run().catch(() => { }), FALLBACK_BACKGROUND_TICK_MS + jitterMs(BACKGROUND_TICK_JITTER_MS));
2837
- if ('unref' in this.refreshTimer)
2838
- this.refreshTimer.unref();
2839
- }
2840
- return initialRun;
2841
- }
2842
- stopBackgroundRefresh() {
2843
- if (this.refreshTimer)
2844
- this.clearIntervalImpl(this.refreshTimer);
2845
- if (this.quotaTimer)
2846
- this.clearIntervalImpl(this.quotaTimer);
2847
- this.refreshTimer = null;
2848
- this.quotaTimer = null;
2849
- }
2850
- async getUsableFallbackAccounts(existingStorage, options = {}) {
2851
- const storage = existingStorage !== undefined ? existingStorage : await this.load();
2852
- if (!storage)
2853
- return [];
2854
- const usable = [];
2855
- let changed = false;
2856
- for (const account of storage.accounts) {
2857
- if (account.enabled === false || !isOAuthAccount(account))
2858
- continue;
2859
- let next = account;
2860
- try {
2861
- if (tokenNeedsRefresh(next, storage, this.now()) &&
2862
- (!this.isFallbackAccountVaultEnabled(next.id, storage) ||
2863
- next.expires === undefined ||
2864
- next.expires <= this.now()) &&
2865
- !this.isFallbackAccountVaultServed(next.id, storage)) {
2866
- if (this.isFallbackAccountVaultEnabled(next.id, storage)) {
2867
- logger.warn('refresh', 'custody override: local fallback refresh', {
2868
- accountId: next.id,
2869
- reason: 'vault credential unavailable',
2870
- });
2871
- }
2872
- const refreshError = next.lastRefreshError;
2873
- if (refreshError &&
2874
- refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
2875
- throw createRefreshBackoffActiveError(refreshError, this.now());
2876
- }
2877
- next = await this.refreshAccount(next, storage);
2878
- changed = true;
2879
- }
2880
- this.seedFallbackQuota(next, storage);
2881
- const stale = this.quotaManager
2882
- ? this.quotaManager.isFallbackStale(next.id, next.access, options.modelId, next)
2883
- : quotaIsStale(next, storage, this.now(), options.modelId);
2884
- // Skip the request-time refresh when this account's quota API is
2885
- // backed off (recent 429/5xx). Hitting it again would extend the
2886
- // backoff; evaluate policy on the cached/seeded quota instead. Mirrors
2887
- // the background refreshQuotaForDueAccounts() guard.
2888
- if (stale &&
2889
- !quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
2890
- const result = await this.refreshAccountQuota(next, storage);
2891
- next = result.account;
2892
- changed ||= result.changed;
2893
- }
2894
- // Single source of truth: evaluate quota policy from the unified
2895
- // QuotaManager cache (the same source as the staleness check above) so
2896
- // an active-route refresh that updated only the cache is not ignored.
2897
- if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
2898
- modelId: options.modelId,
2899
- }))
2900
- usable.push(next);
2901
- }
2902
- catch (error) {
2903
- if (canUseCachedQuotaAfterRefreshError(next, storage, error, this.now())) {
2904
- log('[refresh] fallback quota using cached quota after refresh error', {
2905
- accountId: next.id,
2906
- error: formatErrorMessage(error),
2907
- });
2908
- if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage, {
2909
- modelId: options.modelId,
2910
- })) {
2911
- usable.push(next);
2912
- }
2913
- }
2914
- else if (!failClosedOnUnknownQuota(storage) &&
2915
- !refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh)) &&
2916
- quotaSnapshotPassesModelScope(next.quota, options.modelId)) {
2917
- usable.push(next);
2918
- }
2919
- }
2920
- }
2921
- if (changed)
2922
- await this.save(storage);
2923
- return usable;
2924
- }
2925
- async markUsed(account) {
2926
- const storage = await this.load();
2927
- if (!storage)
2928
- return;
2929
- const stored = storage.accounts.find((candidate) => candidate.id === account.id);
2930
- if (!stored)
2931
- return;
2932
- stored.lastUsed = this.now();
2933
- await this.save(storage);
2934
- }
2935
- accountPassesQuotaPolicy(account, storage, options = {}) {
2936
- return (quotaSnapshotPassesPolicy(account.quota, storage) &&
2937
- quotaSnapshotPassesModelScope(account.quota, options.modelId));
2938
- }
2939
- /**
2940
- * Return the account with its quota overlaid from the unified QuotaManager
2941
- * cache (token-bound) when available, so quota-policy decisions use the same
2942
- * source of truth as the staleness check. Falls back to the stored
2943
- * account.quota when no manager is wired or the cache has no entry.
2944
- */
2945
- quotaPolicyAccount(account) {
2946
- if (!this.quotaManager)
2947
- return account;
2948
- const cached = this.quotaManager.getFallback(account.id, account)?.quota;
2949
- return cached ? { ...account, quota: cached } : account;
2950
- }
2951
- async refreshDueAccounts() {
2952
- const storage = await this.load();
2953
- if (!storage || !refreshEnabled(storage))
2954
- return;
2955
- let changed = false;
2956
- for (const account of storage.accounts) {
2957
- if (account.enabled === false || !isOAuthAccount(account))
2958
- continue;
2959
- if (!tokenNeedsRefresh(account, storage, this.now()) ||
2960
- this.isFallbackAccountVaultEnabled(account.id, storage) ||
2961
- this.isFallbackAccountVaultServed(account.id, storage))
2962
- continue;
2963
- if (refreshBackoffActive(account.lastRefreshError, account.id, this.now(), tokenFingerprint(account.refresh))) {
2964
- // Backoff skips are steady-state while a fallback account is waiting for
2965
- // its next retry. Logging every background tick from every OpenCode
2966
- // process creates noise without adding new diagnostic signal; the
2967
- // failure/backoff itself is recorded when the refresh attempt fails and
2968
- // shown by /claude-quota.
2969
- continue;
2970
- }
2971
- try {
2972
- log('[refresh] fallback oauth background due', {
2973
- accountId: account.id,
2974
- expiresInMs: account.expires
2975
- ? account.expires - this.now()
2976
- : undefined,
2977
- });
2978
- await this.refreshAccount(account, storage);
2979
- changed = true;
2980
- }
2981
- catch (error) {
2982
- logger.warn('refresh', 'fallback oauth background failed', {
2983
- accountId: account.id,
2984
- error: error instanceof Error ? error.message : String(error),
2985
- });
2986
- recordRefreshError(account, error, this.now());
2987
- updateStoredAccount(storage, account);
2988
- changed = true;
2989
- // Background refresh must not break the plugin request path.
2990
- }
2991
- }
2992
- if (changed)
2993
- await this.save(storage);
2994
- }
2995
- async refreshQuotaForDueAccounts() {
2996
- const storage = await this.load();
2997
- if (!storage || !quotaEnabled(storage))
2998
- return;
2999
- let changed = false;
3000
- for (const account of storage.accounts) {
3001
- if (account.enabled === false || !isOAuthAccount(account))
3002
- continue;
3003
- let next = account;
3004
- try {
3005
- if (tokenNeedsRefresh(next, storage, this.now()) &&
3006
- !this.isFallbackAccountVaultEnabled(next.id, storage) &&
3007
- !this.isFallbackAccountVaultServed(next.id, storage)) {
3008
- if (refreshBackoffActive(next.lastRefreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
3009
- continue;
3010
- }
3011
- next = await this.refreshAccount(next, storage);
3012
- changed = true;
3013
- }
3014
- if (quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
3015
- continue;
3016
- }
3017
- this.seedFallbackQuota(next, storage);
3018
- // Use QuotaManager staleness when available (shared cache);
3019
- // fall back to per-account on-disk staleness otherwise.
3020
- const stale = this.quotaManager
3021
- ? this.quotaManager.isFallbackStale(next.id, next.access)
3022
- : quotaIsStale(next, storage, this.now());
3023
- if (!stale)
3024
- continue;
3025
- const result = await this.refreshAccountQuota(next, storage);
3026
- changed ||= result.changed;
3027
- }
3028
- catch (error) {
3029
- recordQuotaRefreshError(account, error, this.now());
3030
- updateStoredAccount(storage, account);
3031
- changed = true;
3032
- // Quota probes are advisory; failed probes fail closed at selection time.
3033
- }
3034
- }
3035
- if (changed) {
3036
- await this.save(storage);
3037
- this.onFallbackStorageChanged?.();
3038
- }
3039
- }
3040
- async refreshQuotaForAllAccounts(options = {}) {
3041
- const storage = await this.load();
3042
- const errors = [];
3043
- if (!storage || !quotaEnabled(storage))
3044
- return { storage, errors };
3045
- const force = options.force ?? false;
3046
- let changed = false;
3047
- for (const account of storage.accounts) {
3048
- if (account.enabled === false || !isOAuthAccount(account))
3049
- continue;
3050
- let next = account;
3051
- try {
3052
- if (tokenNeedsRefresh(next, storage, this.now()) &&
3053
- !this.isFallbackAccountVaultServed(next.id, storage) &&
3054
- !this.isFallbackAccountVaultEnabled(next.id, storage)) {
3055
- const refreshError = next.lastRefreshError;
3056
- if (refreshError &&
3057
- refreshBackoffActive(refreshError, next.id, this.now(), tokenFingerprint(next.refresh))) {
3058
- throw createRefreshBackoffActiveError(refreshError, this.now());
3059
- }
3060
- next = await this.refreshAccount(next, storage);
3061
- changed = true;
3062
- }
3063
- // force (manual /claude-quota) bypasses the staleness skip to fetch
3064
- // fresh numbers on demand. refreshAccountQuota still respects 429
3065
- // backoff via QuotaManager.refreshFallback.
3066
- if (!force && !quotaIsStale(next, storage, this.now())) {
3067
- if (next.lastQuotaRefreshError) {
3068
- next.lastQuotaRefreshError = undefined;
3069
- updateStoredAccount(storage, next);
3070
- changed = true;
3071
- }
3072
- continue;
3073
- }
3074
- const result = await this.refreshAccountQuota(next, storage);
3075
- changed ||= result.changed;
3076
- }
3077
- catch (error) {
3078
- recordQuotaRefreshError(account, error, this.now());
3079
- updateStoredAccount(storage, account);
3080
- changed = true;
3081
- errors.push({
3082
- accountId: account.id,
3083
- message: formatErrorMessage(error),
3084
- });
3085
- }
3086
- }
3087
- if (changed)
3088
- await this.save(storage);
3089
- return { storage, errors };
3090
- }
3091
- async refreshAccount(account, storage, options = {}) {
3092
- if (this.isFallbackAccountVaultServed(account.id, storage))
3093
- return account;
3094
- const existing = this.refreshPromises.get(account.id);
3095
- if (existing) {
3096
- const refreshed = await existing;
3097
- updateStoredAccount(storage, refreshed);
3098
- return refreshed;
3099
- }
3100
- const promise = this.refreshAccountNow(account, storage, options).finally(() => {
3101
- this.refreshPromises.delete(account.id);
3102
- });
3103
- this.refreshPromises.set(account.id, promise);
3104
- try {
3105
- const refreshed = await promise;
3106
- updateStoredAccount(storage, refreshed);
3107
- return refreshed;
3108
- }
3109
- catch (error) {
3110
- if (options.persistError) {
3111
- recordRefreshError(account, error, this.now());
3112
- updateStoredAccount(storage, account);
3113
- await this.save(storage);
3114
- }
3115
- throw error;
3116
- }
3117
- }
3118
- async waitForConcurrentFallbackRefresh(account, storage, previous, options) {
3119
- const deadline = Date.now() + FALLBACK_REFRESH_JOIN_WAIT_MS;
3120
- while (Date.now() < deadline) {
3121
- await new Promise((resolve) => setTimeout(resolve, FALLBACK_REFRESH_JOIN_POLL_MS));
3122
- const latestStorage = await this.load();
3123
- const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
3124
- if (!latestAccount)
3125
- continue;
3126
- const changed = latestAccount.access !== previous.access ||
3127
- latestAccount.refresh !== previous.refresh ||
3128
- (latestAccount.expires ?? 0) > (previous.expires ?? 0) + 60_000;
3129
- if (changed &&
3130
- (options.force ||
3131
- !tokenNeedsRefresh(latestAccount, latestStorage, this.now()))) {
3132
- updateStoredAccount(storage, latestAccount);
3133
- log('[refresh] fallback oauth joined concurrent refresh', {
3134
- accountId: latestAccount.id,
3135
- expiresInMs: latestAccount.expires
3136
- ? latestAccount.expires - this.now()
3137
- : undefined,
3138
- });
3139
- return latestAccount;
3140
- }
3141
- const refreshError = latestAccount.lastRefreshError;
3142
- if (refreshError &&
3143
- refreshBackoffActive(refreshError, latestAccount.id, this.now(), tokenFingerprint(latestAccount.refresh))) {
3144
- updateStoredAccount(storage, latestAccount);
3145
- throw createRefreshBackoffActiveError(refreshError, this.now());
3146
- }
3147
- }
3148
- return null;
3149
- }
3150
- async refreshAccountNow(account, storage, options) {
3151
- let latestStorage = await this.load();
3152
- let latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
3153
- if (latestAccount &&
3154
- !options.force &&
3155
- !tokenNeedsRefresh(latestAccount, latestStorage, this.now())) {
3156
- updateStoredAccount(storage, latestAccount);
3157
- return latestAccount;
3158
- }
3159
- let sourceAccount = latestAccount ?? account;
3160
- const fileLock = await acquireRefreshFileLock({
3161
- name: fallbackRefreshLockName(sourceAccount.id),
3162
- ttlMs: FALLBACK_REFRESH_LOCK_TTL_MS,
3163
- path: this.configPath,
3164
- now: this.now,
3165
- renew: true,
3166
- });
3167
- if (!fileLock) {
3168
- log('[refresh] fallback oauth refresh skipped file lock', {
3169
- accountId: sourceAccount.id,
3170
- });
3171
- const concurrent = await this.waitForConcurrentFallbackRefresh(account, storage, sourceAccount, options);
3172
- if (concurrent)
3173
- return concurrent;
3174
- throw new Error('Fallback OAuth refresh is already in progress');
3175
- }
3176
- try {
3177
- latestStorage = await this.load();
3178
- latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === account.id && isOAuthAccount(candidate));
3179
- if (latestAccount &&
3180
- !options.force &&
3181
- !tokenNeedsRefresh(latestAccount, latestStorage, this.now())) {
3182
- updateStoredAccount(storage, latestAccount);
3183
- return latestAccount;
3184
- }
3185
- sourceAccount = latestAccount ?? sourceAccount;
3186
- const refreshToken = sourceAccount.refresh;
3187
- log('[refresh] fallback oauth refresh request start', {
3188
- accountId: sourceAccount.id,
3189
- force: options.force === true,
3190
- expiresInMs: sourceAccount.expires
3191
- ? sourceAccount.expires - this.now()
3192
- : undefined,
3193
- });
3194
- const refreshed = await refreshClaudeOAuthToken({
3195
- refreshToken,
3196
- authLineageId: sourceAccount.authLineageId,
3197
- fetchImpl: this.fetchImpl,
3198
- now: this.now,
3199
- });
3200
- sourceAccount.access = refreshed.access;
3201
- sourceAccount.refresh = refreshed.refresh;
3202
- sourceAccount.authLineageId =
3203
- refreshed.authLineageId ?? sourceAccount.authLineageId;
3204
- sourceAccount.expires = refreshed.expires;
3205
- sourceAccount.lastRefreshedAt =
3206
- refreshed.expires - refreshed.expiresIn * 1000;
3207
- sourceAccount.lastRefreshError = undefined;
3208
- updateStoredAccount(storage, sourceAccount);
3209
- await this.save(storage);
3210
- log('[refresh] fallback oauth refresh succeeded', {
3211
- accountId: sourceAccount.id,
3212
- expiresInMs: sourceAccount.expires
3213
- ? sourceAccount.expires - this.now()
3214
- : undefined,
3215
- });
3216
- return sourceAccount;
3217
- }
3218
- finally {
3219
- await fileLock.release();
3220
- }
3221
- }
3222
- async refreshAccountQuota(account, storage) {
3223
- const initialQuotaState = JSON.stringify([
3224
- account.quota,
3225
- account.lastQuotaRefreshError,
3226
- ]);
3227
- let changed = false;
3228
- let target = account;
3229
- const vaultEnabled = this.isFallbackAccountVaultEnabled(target.id, storage);
3230
- let access = this.resolveFallbackAccessToken(target, storage);
3231
- if (!access && !vaultEnabled) {
3232
- target = await this.refreshAccount(account, storage, { force: true });
3233
- changed = true;
3234
- access = this.resolveFallbackAccessToken(target, storage);
3235
- }
3236
- if (!access) {
3237
- log('[quota] fallback quota poll skipped: no usable credential', {
3238
- accountId: target.id,
3239
- });
3240
- return {
3241
- account: target,
3242
- fetched: false,
3243
- changed,
3244
- };
3245
- }
3246
- // Unify on the shared QuotaManager when present: it adds inflight
3247
- // deduplication and 429 backoff gating around the same quota API. Fall back
3248
- // to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
3249
- const fetchSnapshot = (accessToken) => this.quotaManager
3250
- ? this.quotaManager.refreshFallbackWithMetadata(target.id, accessToken, target)
3251
- : fetchOAuthQuotaSnapshot({
3252
- accessToken,
3253
- fetchImpl: this.fetchImpl,
3254
- now: this.now,
3255
- }).then((quota) => ({ quota, fetched: true }));
3256
- const fetchStartedAt = this.now();
3257
- let fetched = false;
3258
- try {
3259
- const result = await fetchSnapshot(access.token);
3260
- target.quota = result.quota;
3261
- fetched = result.fetched;
3262
- }
3263
- catch (error) {
3264
- const message = error instanceof Error ? error.message : String(error);
3265
- if (!message.includes('Claude quota check failed: 401') ||
3266
- vaultEnabled ||
3267
- access.source !== 'sidecar') {
3268
- throw error;
3269
- }
3270
- target = await this.refreshAccount(account, storage, {
3271
- force: true,
3272
- });
3273
- changed = true;
3274
- access = this.resolveFallbackAccessToken(target, storage);
3275
- if (!access) {
3276
- log('[quota] fallback quota poll skipped after refresh: no usable credential', {
3277
- accountId: target.id,
3278
- });
3279
- return {
3280
- account: target,
3281
- fetched: false,
3282
- changed,
3283
- };
3284
- }
3285
- // 401 does not arm QuotaManager backoff, so this retry proceeds.
3286
- const result = await fetchSnapshot(access.token);
3287
- target.quota = result.quota;
3288
- fetched = result.fetched;
3289
- }
3290
- const latestStorage = await this.load();
3291
- const latestAccount = latestStorage?.accounts.find((candidate) => candidate.id === target.id && isOAuthAccount(candidate));
3292
- if (latestStorage &&
3293
- latestAccount &&
3294
- latestAccount.access !== target.access) {
3295
- this.seedFallbackQuota(latestAccount, latestStorage);
3296
- updateStoredAccount(storage, latestAccount);
3297
- return { account: latestAccount, fetched: false, changed: false };
3298
- }
3299
- if (latestStorage &&
3300
- latestAccount &&
3301
- latestAccount.access === target.access &&
3302
- latestAccount.quota &&
3303
- quotaSnapshotCheckedAt(latestAccount.quota) >= fetchStartedAt &&
3304
- quotaSnapshotIsFresh(latestAccount.quota, latestStorage, this.now())) {
3305
- this.seedFallbackQuota(latestAccount, latestStorage);
3306
- updateStoredAccount(storage, latestAccount);
3307
- return { account: latestAccount, fetched, changed: false };
3308
- }
3309
- target.lastQuotaRefreshError = undefined;
3310
- updateStoredAccount(storage, target);
3311
- // Sync to shared QuotaManager so all consumers see the same cache. The
3312
- // refreshFallback path already cached the snapshot; re-set here so
3313
- // refreshAfter reflects this storage's check interval consistently.
3314
- if (this.quotaManager && target.quota) {
3315
- const now = this.now();
3316
- this.quotaManager.setFallback(target.id, {
3317
- quota: target.quota,
3318
- refreshAfter: now + getQuotaCheckIntervalMs(storage),
3319
- checkedAt: now,
3320
- }, target);
3321
- }
3322
- return {
3323
- account: target,
3324
- fetched,
3325
- changed: changed ||
3326
- JSON.stringify([target.quota, target.lastQuotaRefreshError]) !==
3327
- initialQuotaState,
3328
- };
3329
- }
3330
- }