codex-multi-auth 2.21.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
@@ -66,6 +66,7 @@ class SocketSession {
66
66
  stats;
67
67
  httpFetch;
68
68
  channels = new Map();
69
+ openedAt = new WeakMap();
69
70
  owners = new Map();
70
71
  chains = new Map();
71
72
  /** Credentials whose WebSocket handshake was refused; they use HTTP for the session. */
@@ -224,34 +225,47 @@ class SocketSession {
224
225
  this.httpOnly.add(key);
225
226
  return this.httpFetch(input, init);
226
227
  }
228
+ this.openedAt.set(socket, Date.now());
227
229
  this.channels.set(key, socket);
228
230
  }
229
231
  const active = socket;
230
232
  return new Promise((resolve, reject) => {
231
233
  let accepted = false, finished = false;
234
+ let bodyCancelled = false;
235
+ let eventCount = 0;
232
236
  let streamController;
233
237
  const stream = new ReadableStream({
234
238
  start(controller) {
235
239
  streamController = controller;
236
240
  },
237
241
  cancel() {
242
+ bodyCancelled = true;
238
243
  active.terminate();
239
244
  },
240
245
  }, { highWaterMark: this.maxBytes, size: (chunk) => chunk.byteLength });
241
246
  const cleanup = () => {
242
247
  active.off("message", onMessage);
243
- active.off("close", onClose);
244
- active.off("error", onClose);
248
+ active.off("close", onSocketClose);
249
+ active.off("error", onError);
245
250
  init.signal?.removeEventListener("abort", abort);
246
251
  context.signal.removeEventListener("abort", abort);
247
252
  };
248
- const onClose = () => {
253
+ const onClose = (closeCode, cause = "close") => {
249
254
  if (finished)
250
255
  return;
251
256
  finished = true;
252
257
  cleanup();
253
258
  this.channels.delete(key);
254
- const cancelled = context.signal.aborted || this.closed;
259
+ const cancelled = bodyCancelled || init.signal?.aborted || context.signal.aborted || this.closed;
260
+ if (!cancelled) {
261
+ const at = Date.now();
262
+ this.stats.lastDisconnect = {
263
+ at, cause, routeKind: context.routeKind,
264
+ connectionAgeMs: Math.max(0, at - (this.openedAt.get(active) ?? at)), eventCount,
265
+ stage: accepted ? "streaming" : "awaiting_first_event",
266
+ ...(typeof closeCode === "number" ? { closeCode } : {}),
267
+ };
268
+ }
255
269
  // Once create has been sent, a disconnect cannot prove generation
256
270
  // did not start. Never replay it, even before the first event.
257
271
  const error = cancelled
@@ -269,8 +283,11 @@ class SocketSession {
269
283
  }));
270
284
  }
271
285
  };
286
+ const onSocketClose = (code) => onClose(code);
287
+ const onError = () => onClose(undefined, "transport_error");
272
288
  const abort = () => active.terminate();
273
289
  const onMessage = (raw, isBinary) => {
290
+ let failureCause = "invalid_event";
274
291
  try {
275
292
  if (isBinary)
276
293
  throw Error("Unexpected binary response");
@@ -278,6 +295,7 @@ class SocketSession {
278
295
  const event = JSON.parse(text);
279
296
  if (!isRecord(event) || typeof event.type !== "string")
280
297
  throw Error("Invalid response event");
298
+ eventCount++;
281
299
  if (event.type === "error" && !accepted) {
282
300
  finished = true;
283
301
  cleanup();
@@ -297,8 +315,10 @@ class SocketSession {
297
315
  headers: { ...handshakeHeaders, "content-type": "text/event-stream" },
298
316
  }));
299
317
  }
300
- if ((streamController.desiredSize ?? 0) < -this.maxBytes)
318
+ if ((streamController.desiredSize ?? 0) < -this.maxBytes) {
319
+ failureCause = "buffer_limit";
301
320
  throw Error("Upstream buffer exceeded");
321
+ }
302
322
  streamController.enqueue(Buffer.from(`data: ${text}\n\n`));
303
323
  if (isRecord(event.response) &&
304
324
  typeof event.response.id === "string") {
@@ -316,13 +336,13 @@ class SocketSession {
316
336
  }
317
337
  }
318
338
  catch {
339
+ onClose(undefined, failureCause);
319
340
  active.terminate();
320
- onClose();
321
341
  }
322
342
  };
323
343
  active.on("message", onMessage);
324
- active.once("close", onClose);
325
- active.once("error", onClose);
344
+ active.once("close", onSocketClose);
345
+ active.once("error", onError);
326
346
  init.signal?.addEventListener("abort", abort, { once: true });
327
347
  context.signal.addEventListener("abort", abort, { once: true });
328
348
  if (init.signal?.aborted || context.signal.aborted) {
@@ -331,8 +351,10 @@ class SocketSession {
331
351
  }
332
352
  this.stats.upstreamRequests++;
333
353
  active.send(JSON.stringify(body), (error) => {
334
- if (error)
335
- onClose();
354
+ if (error) {
355
+ onClose(undefined, "send_error");
356
+ active.terminate();
357
+ }
336
358
  });
337
359
  });
338
360
  }
@@ -548,7 +570,7 @@ export class ResponsesWebSocketGateway {
548
570
  const controller = new AbortController();
549
571
  session.controller = controller;
550
572
  const clientBeta = req.headers["openai-beta"];
551
- this.contexts.set(id, { session, previousId, delta: event.input, signal: controller.signal, clientBeta: typeof clientBeta === "string" ? clientBeta : undefined });
573
+ this.contexts.set(id, { routeKind: pool, session, previousId, delta: event.input, signal: controller.signal, clientBeta: typeof clientBeta === "string" ? clientBeta : undefined });
552
574
  const headers = new Headers();
553
575
  for (const [key, value] of Object.entries(req.headers)) {
554
576
  if (typeof value === "string" &&
@@ -1,8 +1,27 @@
1
1
  import { type SubscriptionQuotaPreference } from "./subscription-quota-order.js";
2
- import type { AccountManager, ManagedAccount } from "../accounts.js";
2
+ import { type AccountManager, type ManagedAccount } from "../accounts.js";
3
3
  import type { ModelFamily } from "../prompts/codex.js";
4
4
  import type { RuntimePolicyDecision } from "../policy/runtime-policy.js";
5
5
  import type { SessionAffinityStore } from "../session-affinity.js";
6
+ /**
7
+ * Select a managed credential for transcription without consulting or mutating
8
+ * Codex inference health, quota, cooldown, circuit-breaker, affinity, or cursor
9
+ * state. Transcription shares account authentication and workspace policy with
10
+ * inference, but it is a separately admitted operation and must not inherit
11
+ * model-runtime backpressure.
12
+ */
13
+ export declare function chooseTranscriptionAccount(params: {
14
+ accountManager: AccountManager;
15
+ attemptedIndexes: ReadonlySet<number>;
16
+ policy: RuntimePolicyDecision | null;
17
+ pinnedIndex: number | null;
18
+ now: number;
19
+ transcriptionRateLimitUntilByRecordId?: ReadonlyMap<string, number>;
20
+ startIndex?: number | null;
21
+ preferredIndex?: number | null;
22
+ fallbackPinnedIndex?: number | null;
23
+ skipReasons?: Map<number, string>;
24
+ }): ManagedAccount | null;
6
25
  /**
7
26
  * `chooseAccount` is a SYNC selector that internally advances the rotation
8
27
  * cursor (the session-affinity-preferred branch and the round-robin fallback
@@ -1,4 +1,5 @@
1
1
  import { compareSubscriptionQuota, usesSubscriptionReserve } from "./subscription-quota-order.js";
2
+ import { AUTH_INVALIDATION_MARKER } from "../accounts.js";
2
3
  /**
3
4
  * Whether a runtime skip reason is a cooldown, the one blocker a pinned
4
5
  * retry may waive. `getManagedAccountRuntimeSkipReason` emits either the
@@ -7,6 +8,84 @@ import { compareSubscriptionQuota, usesSubscriptionReserve } from "./subscriptio
7
8
  function isCooldownReason(reason) {
8
9
  return reason === "cooling-down" || reason.startsWith("cooling-down:");
9
10
  }
11
+ /**
12
+ * Select a managed credential for transcription without consulting or mutating
13
+ * Codex inference health, quota, cooldown, circuit-breaker, affinity, or cursor
14
+ * state. Transcription shares account authentication and workspace policy with
15
+ * inference, but it is a separately admitted operation and must not inherit
16
+ * model-runtime backpressure.
17
+ */
18
+ export function chooseTranscriptionAccount(params) {
19
+ const { accountManager, attemptedIndexes, policy, pinnedIndex, now, transcriptionRateLimitUntilByRecordId, startIndex, preferredIndex, fallbackPinnedIndex, skipReasons, } = params;
20
+ /** Return only credential-level blockers; inference runtime health is intentionally ignored. */
21
+ const credentialReason = (index) => {
22
+ const account = accountManager.getAccountByIndex(index);
23
+ if (!account)
24
+ return "missing";
25
+ if (account.enabled === false)
26
+ return "disabled";
27
+ if (!accountManager.hasEnabledWorkspaces(account))
28
+ return "workspace-disabled";
29
+ if (accountManager.isAccountAuthInvalidated(account))
30
+ return AUTH_INVALIDATION_MARKER;
31
+ if (account.cooldownReason === "auth-failure" &&
32
+ typeof account.coolingDownUntil === "number" &&
33
+ account.coolingDownUntil > now)
34
+ return "cooling-down:auth-failure";
35
+ const transcriptionRateLimitUntil = account.recordId
36
+ ? transcriptionRateLimitUntilByRecordId?.get(account.recordId)
37
+ : undefined;
38
+ if (typeof transcriptionRateLimitUntil === "number" && transcriptionRateLimitUntil > now) {
39
+ return "transcription-rate-limited";
40
+ }
41
+ if (policy?.blockedAccountIndexes.has(index))
42
+ return "policy-blocked";
43
+ if (attemptedIndexes.has(index))
44
+ return "already-attempted";
45
+ return null;
46
+ };
47
+ /** Resolve one candidate while preserving a diagnostic skip reason for rejected credentials. */
48
+ const eligible = (index) => {
49
+ const reason = credentialReason(index);
50
+ if (reason) {
51
+ skipReasons?.set(index, reason);
52
+ return null;
53
+ }
54
+ return accountManager.getAccountByIndex(index);
55
+ };
56
+ if (typeof pinnedIndex === "number")
57
+ return eligible(pinnedIndex);
58
+ if (typeof fallbackPinnedIndex === "number") {
59
+ const fallback = eligible(fallbackPinnedIndex);
60
+ if (fallback)
61
+ return fallback;
62
+ }
63
+ let candidates = accountManager.getAccountsSnapshot().filter((account) => credentialReason(account.index) === null);
64
+ if (!candidates.length) {
65
+ for (const account of accountManager.getAccountsSnapshot()) {
66
+ const reason = credentialReason(account.index);
67
+ if (reason)
68
+ skipReasons?.set(account.index, reason);
69
+ }
70
+ return null;
71
+ }
72
+ if (policy?.priorityByAccount) {
73
+ const tier = Math.min(...candidates.map((account) => policy.priorityByAccount?.[account.index] ?? 1));
74
+ candidates = candidates.filter((account) => (policy.priorityByAccount?.[account.index] ?? 1) === tier);
75
+ }
76
+ if (typeof preferredIndex === "number") {
77
+ const preferred = candidates.find((account) => account.index === preferredIndex);
78
+ if (preferred)
79
+ return accountManager.getAccountByIndex(preferred.index);
80
+ }
81
+ if (typeof startIndex === "number" && candidates.length > 1) {
82
+ const after = candidates.filter((account) => account.index >= startIndex);
83
+ const before = candidates.filter((account) => account.index < startIndex);
84
+ candidates = [...after, ...before];
85
+ }
86
+ const first = candidates[0];
87
+ return first ? accountManager.getAccountByIndex(first.index) : null;
88
+ }
10
89
  /**
11
90
  * `chooseAccount` is a SYNC selector that internally advances the rotation
12
91
  * cursor (the session-affinity-preferred branch and the round-robin fallback
@@ -58,8 +137,13 @@ export function chooseAccount(params) {
58
137
  }
59
138
  const tier = Math.min(...candidates.map(a => priorities[a.index] ?? 1));
60
139
  const tierIndexes = new Set(candidates.filter(a => (priorities[a.index] ?? 1) === tier).map(a => a.index));
61
- let preferred = params.preferredIndex;
62
- if (quotaByAccount) {
140
+ // Runtime policy fills default priority 1 even without a configured order.
141
+ const useSavedOrder = schedulingStrategy === "sequential" &&
142
+ Object.values(priorities).some(priority => priority !== 1);
143
+ let preferred = useSavedOrder
144
+ ? candidates.find(account => tierIndexes.has(account.index))?.index
145
+ : params.preferredIndex;
146
+ if (quotaByAccount && schedulingStrategy !== "sequential") {
63
147
  // Rank only measured accounts: "no data" compares equal to everything, which
64
148
  // is not transitive and would make the winner depend on storage order.
65
149
  const ranked = candidates.flatMap(a => {
@@ -75,19 +159,25 @@ export function chooseAccount(params) {
75
159
  }
76
160
  }
77
161
  // Re-evaluate tiers on every attempt. Never persist a temporary tier block.
78
- return chooseAccount({
162
+ const selected = chooseAccount({
79
163
  ...params,
80
164
  policy: {
81
165
  ...policy,
82
166
  priorityByAccount: undefined,
83
167
  blockedAccountIndexes: new Set([
84
168
  ...policy.blockedAccountIndexes,
85
- ...accountManager.getAccountsSnapshot().filter(a => !tierIndexes.has(a.index)).map(a => a.index),
169
+ // A retry alone must not make a healthy sequential primary unusable.
170
+ ...accountManager.getAccountsSnapshot().filter(a => !tierIndexes.has(a.index) && (schedulingStrategy !== "sequential" || !attemptedIndexes.has(a.index) ||
171
+ quotaByAccount?.[a.index]?.exhausted ||
172
+ (hasNonReserve && usesSubscriptionReserve(quotaByAccount?.[a.index])))).map(a => a.index),
86
173
  ]),
87
174
  },
88
175
  fallbackPinnedIndex: null,
89
176
  pinnedIndex: typeof preferred === "number" && tierIndexes.has(preferred) ? preferred : null,
90
177
  });
178
+ if (selected && schedulingStrategy === "sequential")
179
+ selected.lastUsed = now;
180
+ return selected;
91
181
  }
92
182
  // Manual pin (from `codex-multi-auth switch <n>`) overrides every other
93
183
  // selection signal. We do NOT call markSwitched here — the proxy must not
@@ -32,6 +32,13 @@ export interface RotationProxyStateInit {
32
32
  routingMutexMode: "enabled" | "legacy";
33
33
  schedulingStrategy: "hybrid" | "sequential";
34
34
  fetchImpl: typeof fetch;
35
+ /**
36
+ * The embedder's configured fetcher, captured before `state.fetchImpl` is
37
+ * rebound to the WebSocket gateway. Capability/doc probes must use this —
38
+ * the gateway fetch exists for Responses transport upgrades, and bare
39
+ * `fetch` would bypass a custom fetcher's instrumentation entirely.
40
+ */
41
+ upstreamFetchImpl: typeof fetch;
35
42
  upstreamBaseUrl: string;
36
43
  clientApiKey: string;
37
44
  now: () => number;
@@ -96,8 +103,27 @@ export interface RotationProxyState extends RotationProxyStateInit {
96
103
  lastGlobalSwitchAt: number;
97
104
  staleRuntimeReloadPromise: Promise<AccountManager | null> | null;
98
105
  lastStaleRuntimeReloadAt: number;
106
+ credentialReloadPromise: Promise<AccountManager | null> | null;
107
+ lastCredentialReloadAt: number;
108
+ transcriptionNextAccountIndex: number;
109
+ readonly transcriptionRateLimitUntilByRecordId: Map<string, number>;
99
110
  }
100
111
  /** @internal */
101
112
  export declare function createRotationProxyState(init: RotationProxyStateInit): RotationProxyState;
113
+ /**
114
+ * Reload account credentials without resetting inference runtime state.
115
+ *
116
+ * This is intentionally narrower than {@link recoverStaleRuntimeState}: it is
117
+ * used when a non-inference operation discovers that its in-memory credential
118
+ * inventory is stale after an external login/enable action. Fresh credential,
119
+ * workspace, enabled and auth-invalidation fields come from disk, while live
120
+ * inference rate-limit windows and non-auth cooldowns are carried forward by
121
+ * stable account identity. An auth-failure cooldown is carried only while the
122
+ * access token is unchanged, so a genuine re-login can repair the credential.
123
+ * Process-global health/token trackers and circuit breakers are never reset.
124
+ *
125
+ * @internal
126
+ */
127
+ export declare function reloadRuntimeCredentials(state: RotationProxyState): Promise<AccountManager | null>;
102
128
  /** @internal */
103
129
  export declare function recoverStaleRuntimeState(state: RotationProxyState): Promise<AccountManager | null>;