codex-multi-auth 2.20.0 → 2.22.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 (44) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +5 -4
  3. package/dist/lib/accounts.d.ts +5 -0
  4. package/dist/lib/accounts.js +33 -4
  5. package/dist/lib/api-route-store.d.ts +6 -0
  6. package/dist/lib/api-route-store.js +98 -30
  7. package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
  8. package/dist/lib/codex-manager/api-login-menu.js +4 -6
  9. package/dist/lib/codex-manager/commands/status.d.ts +1 -1
  10. package/dist/lib/codex-manager/commands/status.js +26 -9
  11. package/dist/lib/codex-manager.js +7 -1
  12. package/dist/lib/constants.d.ts +1 -0
  13. package/dist/lib/constants.js +1 -0
  14. package/dist/lib/model-route-policy.d.ts +1 -0
  15. package/dist/lib/model-route-policy.js +2 -1
  16. package/dist/lib/runtime/account-model-catalog.d.ts +1 -0
  17. package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
  18. package/dist/lib/runtime/api-model-capabilities.js +155 -24
  19. package/dist/lib/runtime/app-bind.d.ts +4 -0
  20. package/dist/lib/runtime/app-bind.js +32 -1
  21. package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
  22. package/dist/lib/runtime/automatic-api-checks.js +188 -0
  23. package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
  24. package/dist/lib/runtime/model-discovery-status.js +64 -49
  25. package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
  26. package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
  27. package/dist/lib/runtime/responses-websocket.d.ts +18 -5
  28. package/dist/lib/runtime/responses-websocket.js +33 -11
  29. package/dist/lib/runtime/rotation-account-selection.d.ts +20 -1
  30. package/dist/lib/runtime/rotation-account-selection.js +94 -4
  31. package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
  32. package/dist/lib/runtime/rotation-proxy-state.js +376 -4
  33. package/dist/lib/runtime/rotation-server-types.d.ts +3 -0
  34. package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
  35. package/dist/lib/runtime/rotation-token-refresh.js +218 -27
  36. package/dist/lib/runtime-rotation-proxy.js +571 -96
  37. package/dist/lib/storage/credential-sidecars.js +34 -2
  38. package/dist/lib/storage/file-lock.d.ts +2 -0
  39. package/dist/lib/storage/file-lock.js +44 -30
  40. package/dist/lib/usage/ledger.js +1 -0
  41. package/dist/lib/usage/redaction.js +1 -0
  42. package/dist/lib/usage/types.d.ts +1 -1
  43. package/package.json +1 -1
  44. package/scripts/codex-app-router.js +42 -14
@@ -1,6 +1,61 @@
1
- import { AccountManager } from "../accounts.js";
1
+ import { AccountManager, sanitizeEmail } from "../accounts.js";
2
2
  import { recordRuntimeReload, recordRuntimeReset, } from "./runtime-observability.js";
3
+ import { redirectRuntimeRefreshCommits, waitForRuntimeRefreshCommits } from "./rotation-token-refresh.js";
3
4
  const STALE_RUNTIME_RELOAD_DEDUPE_MS = 1_000;
5
+ /**
6
+ * Copy credentials that rotated on `previousManager` since `authBeforeLoad`
7
+ * into the matching rows of `reloaded`. A replacement snapshot read before a
8
+ * refresh commit persisted still holds the spent single-use refresh token;
9
+ * without this carry the swap reintroduces it and the next refresh fails with
10
+ * token reuse. Matching is deliberately conservative — persisted record id,
11
+ * then the refresh token on either side of the rotation, then accountId+email —
12
+ * so a credential never lands on a different account.
13
+ */
14
+ function carryRotatedCredentials(previousManager, authBeforeLoad, reloaded) {
15
+ const candidates = reloaded.getAccountsSnapshot();
16
+ for (const previous of previousManager.getAccountsSnapshot()) {
17
+ const baseline = authBeforeLoad.find((account) => (account.recordId && account.recordId === previous.recordId) ||
18
+ account.index === previous.index);
19
+ if (!baseline ||
20
+ (baseline.access === previous.access &&
21
+ baseline.refreshToken === previous.refreshToken &&
22
+ baseline.expires === previous.expires)) {
23
+ continue;
24
+ }
25
+ const rotatedTokens = new Set([previous.refreshToken, baseline.refreshToken]);
26
+ const sourceEmail = sanitizeEmail(previous.email);
27
+ const matches = candidates.filter((candidate) => (previousManager.hasPersistedRecordId(previous) &&
28
+ candidate.recordId === previous.recordId) ||
29
+ rotatedTokens.has(candidate.refreshToken) ||
30
+ (previous.accountId !== undefined &&
31
+ candidate.accountId === previous.accountId &&
32
+ sanitizeEmail(candidate.email) === sourceEmail));
33
+ const [matched] = matches;
34
+ if (matches.length !== 1 || !matched)
35
+ continue;
36
+ const live = reloaded.getAccountByIndex(matched.index);
37
+ if (!live)
38
+ continue;
39
+ live.access = previous.access;
40
+ live.refreshToken = previous.refreshToken;
41
+ live.expires = previous.expires;
42
+ // Token-bound identity (accountId/accountIdSource/email) rides along with
43
+ // the rotated credential — `commitRefreshedAuth` can refresh it during the
44
+ // same window, and without it the row keeps a workspace binding issued for
45
+ // the spent token. Explicit manual/org bindings stay authoritative.
46
+ const tokenBoundIdentityChanged = baseline.accountId !== previous.accountId ||
47
+ baseline.accountIdSource !== previous.accountIdSource;
48
+ const liveHasExplicitBinding = live.accountIdSource === "manual" || live.accountIdSource === "org";
49
+ const previousHasTokenBinding = previous.accountIdSource === "token" || previous.accountIdSource === "id_token";
50
+ if (tokenBoundIdentityChanged && previousHasTokenBinding && !liveHasExplicitBinding) {
51
+ live.accountId = previous.accountId;
52
+ live.accountIdSource = previous.accountIdSource;
53
+ }
54
+ if (baseline.email !== previous.email) {
55
+ live.email = previous.email;
56
+ }
57
+ }
58
+ }
4
59
  /** @internal */
5
60
  export function createRotationProxyState(init) {
6
61
  return {
@@ -24,21 +79,325 @@ export function createRotationProxyState(init) {
24
79
  lastGlobalSwitchAt: 0,
25
80
  staleRuntimeReloadPromise: null,
26
81
  lastStaleRuntimeReloadAt: 0,
82
+ credentialReloadPromise: null,
83
+ lastCredentialReloadAt: 0,
84
+ transcriptionNextAccountIndex: 0,
85
+ transcriptionRateLimitUntilByRecordId: new Map(),
27
86
  };
28
87
  }
88
+ /**
89
+ * Reload account credentials without resetting inference runtime state.
90
+ *
91
+ * This is intentionally narrower than {@link recoverStaleRuntimeState}: it is
92
+ * used when a non-inference operation discovers that its in-memory credential
93
+ * inventory is stale after an external login/enable action. Fresh credential,
94
+ * workspace, enabled and auth-invalidation fields come from disk, while live
95
+ * inference rate-limit windows and non-auth cooldowns are carried forward by
96
+ * stable account identity. An auth-failure cooldown is carried only while the
97
+ * access token is unchanged, so a genuine re-login can repair the credential.
98
+ * Process-global health/token trackers and circuit breakers are never reset.
99
+ *
100
+ * @internal
101
+ */
102
+ export async function reloadRuntimeCredentials(state) {
103
+ // Inference stale recovery is destructive by design and takes precedence over
104
+ // the credential-only refresh. Sharing the in-flight result prevents two
105
+ // asynchronous loaders from racing to replace activeAccountManager.
106
+ if (state.staleRuntimeReloadPromise)
107
+ return state.staleRuntimeReloadPromise;
108
+ if (Date.now() - state.lastCredentialReloadAt <= STALE_RUNTIME_RELOAD_DEDUPE_MS) {
109
+ return state.activeAccountManager;
110
+ }
111
+ if (state.credentialReloadPromise)
112
+ return state.credentialReloadPromise;
113
+ state.credentialReloadPromise = (async () => {
114
+ const previousManager = state.activeAccountManager;
115
+ /** Stable matching key for carrying only runtime observations between credential snapshots. */
116
+ const identityOf = (account) => {
117
+ const accountId = account.accountId?.trim();
118
+ const email = sanitizeEmail(account.email);
119
+ return accountId || email
120
+ ? JSON.stringify([accountId ?? null, email ?? null])
121
+ : account.refreshToken?.trim()
122
+ ? `refresh:${account.refreshToken.trim()}`
123
+ : null;
124
+ };
125
+ // Capture auth before I/O so a token rotation that lands while loadFromDisk
126
+ // is suspended can be distinguished from credentials intentionally changed
127
+ // on disk by another login. Only the former is carried into the replacement.
128
+ const authBeforeLoad = previousManager.getAccountsSnapshot();
129
+ const reloaded = await AccountManager.loadFromDisk();
130
+ if (state.activeAccountManager !== previousManager) {
131
+ // Another path (native storage sync, stale recovery) already swapped
132
+ // the manager while disk I/O was pending; never clobber its redirect.
133
+ state.lastCredentialReloadAt = Date.now();
134
+ return state.activeAccountManager;
135
+ }
136
+ reloaded.setRoutingMutexMode(state.routingMutexMode);
137
+ const freshAccounts = reloaded.getAccountsSnapshot();
138
+ // loadFromDisk intentionally converts an unreadable/corrupt source with no
139
+ // usable WAL/backup into an empty manager. Do not let a transient read
140
+ // failure erase a healthy resident pool; a parsed empty/reset snapshot is
141
+ // still authoritative and may intentionally remove every account.
142
+ if (authBeforeLoad.length > 0 &&
143
+ freshAccounts.length === 0 &&
144
+ !reloaded.hasLoadedAccountStorageSnapshot()) {
145
+ state.lastCredentialReloadAt = Date.now();
146
+ return previousManager;
147
+ }
148
+ // A refresh can finish upstream while disk I/O is pending and then queue
149
+ // its auth commit against the resident manager. Let every commit already
150
+ // targeting that manager settle before taking the final live snapshot.
151
+ await waitForRuntimeRefreshCommits(previousManager);
152
+ if (state.activeAccountManager !== previousManager) {
153
+ state.lastCredentialReloadAt = Date.now();
154
+ return state.activeAccountManager;
155
+ }
156
+ const previousAccounts = previousManager.getAccountsSnapshot();
157
+ const credentialInventoryFingerprint = (accounts) => JSON.stringify(accounts.map((account) => ({
158
+ recordId: account.recordId ?? null,
159
+ accountId: account.accountId?.trim() ?? null,
160
+ accountIdSource: account.accountIdSource ?? null,
161
+ email: sanitizeEmail(account.email) ?? null,
162
+ refreshToken: account.refreshToken,
163
+ access: account.access ?? null,
164
+ expires: account.expires ?? null,
165
+ enabled: account.enabled !== false,
166
+ authInvalidatedAt: account.authInvalidatedAt ?? null,
167
+ authInvalidationErrorCode: account.authInvalidationErrorCode ?? null,
168
+ workspaces: (account.workspaces ?? []).map((workspace) => ({
169
+ id: workspace.id.trim(),
170
+ name: workspace.name ?? null,
171
+ enabled: workspace.enabled !== false,
172
+ disabledAt: workspace.disabledAt ?? null,
173
+ isDefault: workspace.isDefault ?? null,
174
+ })),
175
+ currentWorkspaceIndex: account.currentWorkspaceIndex ?? null,
176
+ })));
177
+ // A credential probe that finds the exact same inventory must not swap the
178
+ // live manager. Returning the resident object preserves inference cursors,
179
+ // lastUsed state, and in-flight runtime observations while still bounding
180
+ // disk checks through lastCredentialReloadAt.
181
+ if (credentialInventoryFingerprint(authBeforeLoad) === credentialInventoryFingerprint(freshAccounts)) {
182
+ // Cooldown fields are deliberately excluded from the inventory fingerprint
183
+ // so a disk probe cannot reset inference selector state. Still absorb a
184
+ // newer persisted auth-failure cooldown from another proxy, but only when
185
+ // the credential itself is unchanged; a re-login/rotation repairs that wait.
186
+ for (const fresh of freshAccounts) {
187
+ const resident = previousManager.getAccountByIndex(fresh.index);
188
+ if (resident &&
189
+ fresh.cooldownReason === "auth-failure" &&
190
+ typeof fresh.coolingDownUntil === "number" &&
191
+ fresh.coolingDownUntil > state.now() &&
192
+ resident.access === fresh.access &&
193
+ resident.refreshToken === fresh.refreshToken &&
194
+ fresh.coolingDownUntil > (resident.coolingDownUntil ?? 0)) {
195
+ resident.coolingDownUntil = fresh.coolingDownUntil;
196
+ resident.cooldownReason = "auth-failure";
197
+ }
198
+ }
199
+ state.lastCredentialReloadAt = Date.now();
200
+ return previousManager;
201
+ }
202
+ const baselineFor = (previous) => authBeforeLoad.find((account) => (account.recordId && account.recordId === previous.recordId) ||
203
+ account.index === previous.index);
204
+ const workspaceIds = (account) => new Set([
205
+ account.accountId?.trim(),
206
+ ...(account.workspaces ?? []).map((workspace) => workspace.id.trim()),
207
+ ].filter((value) => Boolean(value)));
208
+ const hasWorkspaceConflict = (previous, baseline, candidate) => {
209
+ const sourceIds = workspaceIds(previous);
210
+ if (baseline) {
211
+ for (const id of workspaceIds(baseline))
212
+ sourceIds.add(id);
213
+ }
214
+ const candidateIds = workspaceIds(candidate);
215
+ return sourceIds.size > 0 && candidateIds.size > 0 &&
216
+ ![...sourceIds].some((id) => candidateIds.has(id));
217
+ };
218
+ const claimed = new Set();
219
+ const matchedFreshIndexByPrevious = new Map();
220
+ const reserve = (previous, candidate) => {
221
+ if (!candidate || claimed.has(candidate.index))
222
+ return false;
223
+ claimed.add(candidate.index);
224
+ matchedFreshIndexByPrevious.set(previous.index, candidate.index);
225
+ return true;
226
+ };
227
+ // Reserve every strong match for every old row before considering fuzzy
228
+ // fallbacks. This prevents an earlier deleted row from stealing the only
229
+ // surviving same-email workspace before that survivor can claim itself.
230
+ for (const previous of previousAccounts) {
231
+ if (!previous.recordId)
232
+ continue;
233
+ const matches = freshAccounts.filter((account) => !claimed.has(account.index) && account.recordId === previous.recordId);
234
+ if (matches.length === 1)
235
+ reserve(previous, matches[0]);
236
+ }
237
+ for (const previous of previousAccounts) {
238
+ if (matchedFreshIndexByPrevious.has(previous.index))
239
+ continue;
240
+ const baseline = baselineFor(previous);
241
+ const identities = new Set([identityOf(previous), baseline ? identityOf(baseline) : null].filter((identity) => identity !== null));
242
+ const matches = freshAccounts.filter((account) => {
243
+ if (claimed.has(account.index))
244
+ return false;
245
+ const identity = identityOf(account);
246
+ return identity !== null && identities.has(identity);
247
+ });
248
+ if (matches.length === 1)
249
+ reserve(previous, matches[0]);
250
+ }
251
+ for (const previous of previousAccounts) {
252
+ if (matchedFreshIndexByPrevious.has(previous.index))
253
+ continue;
254
+ const baseline = baselineFor(previous);
255
+ const refreshTokens = new Set([previous.refreshToken, baseline?.refreshToken].filter((value) => Boolean(value)));
256
+ const matches = freshAccounts.filter((account) => !claimed.has(account.index) && refreshTokens.has(account.refreshToken));
257
+ if (matches.length === 1)
258
+ reserve(previous, matches[0]);
259
+ }
260
+ for (const previous of previousAccounts) {
261
+ if (matchedFreshIndexByPrevious.has(previous.index))
262
+ continue;
263
+ const baseline = baselineFor(previous);
264
+ const stableEmail = sanitizeEmail(previous.email) ?? sanitizeEmail(baseline?.email);
265
+ if (!stableEmail)
266
+ continue;
267
+ const matches = freshAccounts.filter((account) => !claimed.has(account.index) &&
268
+ sanitizeEmail(account.email) === stableEmail &&
269
+ !hasWorkspaceConflict(previous, baseline, account));
270
+ if (matches.length === 1)
271
+ reserve(previous, matches[0]);
272
+ }
273
+ for (const previous of previousAccounts) {
274
+ const matchedIndex = matchedFreshIndexByPrevious.get(previous.index);
275
+ if (matchedIndex === undefined)
276
+ continue;
277
+ const baseline = baselineFor(previous);
278
+ const live = reloaded.getAccountByIndex(matchedIndex);
279
+ if (!live)
280
+ continue;
281
+ const authRotatedDuringLoad = baseline !== undefined && (baseline.access !== previous.access ||
282
+ baseline.refreshToken !== previous.refreshToken ||
283
+ baseline.expires !== previous.expires);
284
+ if (authRotatedDuringLoad) {
285
+ live.access = previous.access;
286
+ live.refreshToken = previous.refreshToken;
287
+ live.expires = previous.expires;
288
+ const tokenBoundIdentityChanged = baseline !== undefined && (baseline.accountId !== previous.accountId ||
289
+ baseline.accountIdSource !== previous.accountIdSource);
290
+ const liveHasExplicitBinding = live.accountIdSource === "manual" || live.accountIdSource === "org";
291
+ const previousHasTokenBinding = previous.accountIdSource === "token" || previous.accountIdSource === "id_token";
292
+ if (tokenBoundIdentityChanged && previousHasTokenBinding && !liveHasExplicitBinding) {
293
+ live.accountId = previous.accountId;
294
+ live.accountIdSource = previous.accountIdSource;
295
+ }
296
+ if (baseline !== undefined && baseline.email !== previous.email) {
297
+ live.email = previous.email;
298
+ }
299
+ }
300
+ for (const [key, until] of Object.entries(previous.rateLimitResetTimes)) {
301
+ if (typeof until === "number" && until > state.now()) {
302
+ live.rateLimitResetTimes[key] = Math.max(live.rateLimitResetTimes[key] ?? 0, until);
303
+ }
304
+ }
305
+ if ((previous.coolingDownUntil ?? 0) > (live.coolingDownUntil ?? 0)) {
306
+ const repairedAuth = previous.cooldownReason === "auth-failure" && previous.access !== live.access;
307
+ if (!repairedAuth) {
308
+ live.coolingDownUntil = previous.coolingDownUntil;
309
+ live.cooldownReason = previous.cooldownReason;
310
+ }
311
+ }
312
+ const sameCredential = live.access === previous.access && live.refreshToken === previous.refreshToken;
313
+ // Auth-invalidation and workspace-disable markers are written in memory
314
+ // first and only reach disk through the debounced save. Carry only a
315
+ // marker that landed during the load window (absent from the baseline):
316
+ // a pre-existing marker may have been legitimately cleared on disk by
317
+ // another process, which is then authoritative. A re-login (rotated
318
+ // credential) legitimately clears them either way.
319
+ const baselineInvalidated = baseline !== undefined && previousManager.isAccountAuthInvalidated(baseline);
320
+ if (sameCredential &&
321
+ !baselineInvalidated &&
322
+ previousManager.isAccountAuthInvalidated(previous) &&
323
+ !reloaded.isAccountAuthInvalidated(live)) {
324
+ live.authInvalidatedAt = previous.authInvalidatedAt;
325
+ live.authInvalidationErrorCode = previous.authInvalidationErrorCode;
326
+ }
327
+ if (sameCredential) {
328
+ for (const workspace of previous.workspaces ?? []) {
329
+ const baselineWorkspace = baseline?.workspaces?.find((item) => item.id === workspace.id);
330
+ // A disable that predates the load is already in the baseline —
331
+ // disk may have re-enabled the workspace, and that wins.
332
+ if (baselineWorkspace?.enabled === false)
333
+ continue;
334
+ const target = live.workspaces?.find((item) => item.id === workspace.id);
335
+ if (target &&
336
+ workspace.enabled === false &&
337
+ workspace.disabledAt !== undefined &&
338
+ target.enabled !== false) {
339
+ target.enabled = false;
340
+ target.disabledAt = workspace.disabledAt;
341
+ }
342
+ }
343
+ }
344
+ }
345
+ // Requests that captured the resident manager before this swap may finish
346
+ // refreshing afterward. Redirect those late auth commits to the replacement
347
+ // so rotated single-use refresh tokens cannot be stranded on the old object.
348
+ redirectRuntimeRefreshCommits(previousManager, reloaded);
349
+ state.activeAccountManager = reloaded;
350
+ state.knownAccountManagers.add(reloaded);
351
+ state.lastCredentialReloadAt = Date.now();
352
+ recordRuntimeReload("credential-inventory-refresh");
353
+ return reloaded;
354
+ })()
355
+ .catch((error) => {
356
+ state.status.lastError = error instanceof Error ? error.message : String(error);
357
+ return null;
358
+ })
359
+ .finally(() => {
360
+ state.credentialReloadPromise = null;
361
+ });
362
+ return state.credentialReloadPromise;
363
+ }
29
364
  /** @internal */
30
365
  export async function recoverStaleRuntimeState(state) {
366
+ if (state.staleRuntimeReloadPromise)
367
+ return state.staleRuntimeReloadPromise;
368
+ // If a non-destructive credential refresh started first, let it publish its
369
+ // inventory before performing the authoritative stale-state reset. Re-check
370
+ // the stale promise afterward so concurrent recovery callers still dedupe.
371
+ if (state.credentialReloadPromise)
372
+ await state.credentialReloadPromise;
373
+ if (state.staleRuntimeReloadPromise)
374
+ return state.staleRuntimeReloadPromise;
31
375
  if (Date.now() - state.lastStaleRuntimeReloadAt <= STALE_RUNTIME_RELOAD_DEDUPE_MS) {
32
376
  return state.activeAccountManager;
33
377
  }
34
- if (state.staleRuntimeReloadPromise) {
35
- return state.staleRuntimeReloadPromise;
36
- }
37
378
  state.staleRuntimeReloadPromise = (async () => {
379
+ const previousManager = state.activeAccountManager;
380
+ const authBeforeLoad = previousManager.getAccountsSnapshot();
38
381
  AccountManager.resetVolatileRuntimeState();
39
382
  recordRuntimeReset("pool-exhausted-no-account");
40
383
  const reloaded = await AccountManager.loadFromDisk();
384
+ if (state.activeAccountManager !== previousManager) {
385
+ // A concurrent swap (native storage sync, credential reload) already
386
+ // published a newer manager; discarding this result keeps its redirect
387
+ // chain intact instead of clobbering previous → live with stale.
388
+ state.lastStaleRuntimeReloadAt = Date.now();
389
+ return state.activeAccountManager;
390
+ }
41
391
  reloaded.setRoutingMutexMode(state.routingMutexMode);
392
+ // Drain refresh commits that queued against the resident manager while
393
+ // disk I/O was pending, then carry the rotated credentials: the loaded
394
+ // snapshot predates their persist and still holds the spent token.
395
+ await waitForRuntimeRefreshCommits(previousManager);
396
+ if (state.activeAccountManager !== previousManager) {
397
+ state.lastStaleRuntimeReloadAt = Date.now();
398
+ return state.activeAccountManager;
399
+ }
400
+ carryRotatedCredentials(previousManager, authBeforeLoad, reloaded);
42
401
  // Wipe per-account cooldowns and rate-limit windows on the freshly
43
402
  // reloaded pool. `resetVolatileRuntimeState` above only cleared global
44
403
  // singletons (trackers, circuit breakers); the per-account transient
@@ -63,6 +422,19 @@ export async function recoverStaleRuntimeState(state) {
63
422
  // durable across a restart, not just best-effort. Recovery is rare
64
423
  // (full-pool exhaustion), so the extra synchronous write is cheap.
65
424
  await reloaded.flushPendingSave();
425
+ // The flush opened another window in which a late commit could queue on
426
+ // the superseded manager. Drain it, then carry + redirect + swap in one
427
+ // synchronous block so no commit can slip between the drain and the swap.
428
+ await waitForRuntimeRefreshCommits(previousManager);
429
+ if (state.activeAccountManager !== previousManager) {
430
+ state.lastStaleRuntimeReloadAt = Date.now();
431
+ return state.activeAccountManager;
432
+ }
433
+ carryRotatedCredentials(previousManager, authBeforeLoad, reloaded);
434
+ // Keep refresh ownership chains current across destructive recovery too.
435
+ // Requests that captured the previous manager before this swap must never
436
+ // persist a late refresh/cooldown into an object that is no longer active.
437
+ redirectRuntimeRefreshCommits(previousManager, reloaded);
66
438
  state.activeAccountManager = reloaded;
67
439
  state.knownAccountManagers.add(reloaded);
68
440
  state.lastStaleRuntimeReloadAt = Date.now();
@@ -1,3 +1,4 @@
1
+ import type { WebSocketDisconnectDiagnostic } from "./responses-websocket.js";
1
2
  import type { QuotaCacheData } from "../quota-cache.js";
2
3
  import type { ApiRouteCredential } from "../api-route-store.js";
3
4
  import type { AccountStorageV3 } from "../storage.js";
@@ -18,10 +19,12 @@ export interface RuntimeRotationProxyServer {
18
19
  getOpenConnectionCount?: () => number;
19
20
  }
20
21
  export interface RuntimeRotationProxyStatus {
22
+ schedulingStrategy?: "hybrid" | "sequential";
21
23
  streamQuotaUpdates?: number;
22
24
  lastStreamQuotaUpdateAt?: number;
23
25
  websocketConnections?: number;
24
26
  websocketUpstreamRequests?: number;
27
+ lastWebsocketDisconnect?: WebSocketDisconnectDiagnostic;
25
28
  startedAt: number;
26
29
  totalRequests: number;
27
30
  upstreamRequests: number;
@@ -1,9 +1,21 @@
1
- import type { AccountManager, ManagedAccount } from "../accounts.js";
1
+ import { type AccountManager, type ManagedAccount } from "../accounts.js";
2
2
  import type { ModelFamily } from "../prompts/codex.js";
3
3
  /** @internal */
4
4
  export declare const DEFAULT_AUTH_FAILURE_COOLDOWN_MS = 30000;
5
5
  /** @internal */
6
6
  export declare function applyMonotonicAuthCooldown(accountManager: AccountManager, account: ManagedAccount, cooldownMs: number): void;
7
+ type RuntimeRefreshTarget = {
8
+ accountManager: AccountManager;
9
+ account: ManagedAccount;
10
+ };
11
+ /** @internal Resolve an account onto the latest runtime manager replacement. */
12
+ export declare function resolveRuntimeRefreshTarget(accountManager: AccountManager, account: ManagedAccount): RuntimeRefreshTarget | null;
13
+ /** @internal Test-only reset for process-global refresh commit/redirect state. */
14
+ export declare function resetRuntimeRefreshStateForTests(): void;
15
+ /** @internal Wait until every refresh-auth commit already targeting this manager settles. */
16
+ export declare function waitForRuntimeRefreshCommits(accountManager: AccountManager): Promise<void>;
17
+ /** @internal Redirect late refresh state from requests that captured a superseded manager. */
18
+ export declare function redirectRuntimeRefreshCommits(previousManager: AccountManager, replacementManager: AccountManager): void;
7
19
  /** @internal */
8
20
  export declare function ensureFreshAccessToken(params: {
9
21
  accountManager: AccountManager;
@@ -17,8 +29,18 @@ export declare function ensureFreshAccessToken(params: {
17
29
  ok: true;
18
30
  accessToken: string;
19
31
  account: ManagedAccount;
32
+ accountManager: AccountManager;
20
33
  } | {
21
34
  ok: false;
22
35
  retryable: boolean;
23
36
  invalidated?: boolean;
37
+ selectionRestart?: false;
38
+ account: ManagedAccount;
39
+ accountManager: AccountManager;
40
+ } | {
41
+ ok: false;
42
+ retryable: true;
43
+ selectionRestart: true;
44
+ accountManager: AccountManager;
24
45
  }>;
46
+ export {};