codex-multi-auth 2.21.0 → 2.23.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 (70) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +8 -5
  3. package/config/schema/config.schema.json +8 -0
  4. package/dist/lib/accounts.d.ts +5 -0
  5. package/dist/lib/accounts.js +33 -4
  6. package/dist/lib/api-route-store.d.ts +7 -0
  7. package/dist/lib/api-route-store.js +100 -31
  8. package/dist/lib/auth/account-access.js +2 -1
  9. package/dist/lib/auth/auth.js +3 -2
  10. package/dist/lib/auth/device-auth.js +2 -1
  11. package/dist/lib/auth/http.d.ts +1 -0
  12. package/dist/lib/auth/http.js +1 -0
  13. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  14. package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
  15. package/dist/lib/codex-manager/api-login-menu.js +4 -6
  16. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  17. package/dist/lib/codex-manager/commands/locale.js +25 -0
  18. package/dist/lib/codex-manager/commands/status.d.ts +5 -1
  19. package/dist/lib/codex-manager/commands/status.js +501 -13
  20. package/dist/lib/codex-manager/help.js +3 -1
  21. package/dist/lib/codex-manager.js +98 -23
  22. package/dist/lib/config.js +3 -0
  23. package/dist/lib/constants.d.ts +1 -0
  24. package/dist/lib/constants.js +1 -0
  25. package/dist/lib/locale.d.ts +7 -0
  26. package/dist/lib/locale.js +35 -0
  27. package/dist/lib/model-route-policy.d.ts +1 -0
  28. package/dist/lib/model-route-policy.js +2 -1
  29. package/dist/lib/quota-probe.js +2 -1
  30. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  31. package/dist/lib/request/proxy-fetch.js +37 -0
  32. package/dist/lib/request/url-rewriting.js +17 -0
  33. package/dist/lib/runtime/account-model-catalog.d.ts +3 -0
  34. package/dist/lib/runtime/account-model-catalog.js +7 -2
  35. package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
  36. package/dist/lib/runtime/api-model-capabilities.js +155 -24
  37. package/dist/lib/runtime/app-bind.d.ts +7 -0
  38. package/dist/lib/runtime/app-bind.js +92 -1
  39. package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
  40. package/dist/lib/runtime/automatic-api-checks.js +188 -0
  41. package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
  42. package/dist/lib/runtime/model-discovery-status.js +64 -49
  43. package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
  44. package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
  45. package/dist/lib/runtime/responses-websocket.d.ts +48 -5
  46. package/dist/lib/runtime/responses-websocket.js +275 -39
  47. package/dist/lib/runtime/rotation-account-selection.d.ts +25 -5
  48. package/dist/lib/runtime/rotation-account-selection.js +94 -4
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
  50. package/dist/lib/runtime/rotation-proxy-state.js +376 -4
  51. package/dist/lib/runtime/rotation-server-types.d.ts +6 -0
  52. package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
  53. package/dist/lib/runtime/rotation-token-refresh.js +218 -27
  54. package/dist/lib/runtime-rotation-proxy.js +601 -105
  55. package/dist/lib/schemas.d.ts +5 -0
  56. package/dist/lib/schemas.js +1 -0
  57. package/dist/lib/storage/credential-sidecars.js +34 -2
  58. package/dist/lib/storage/file-lock.d.ts +2 -0
  59. package/dist/lib/storage/file-lock.js +44 -30
  60. package/dist/lib/usage/ledger.js +1 -0
  61. package/dist/lib/usage/redaction.js +1 -0
  62. package/dist/lib/usage/types.d.ts +1 -1
  63. package/dist/lib/web-dashboard-html.d.ts +1 -0
  64. package/dist/lib/web-dashboard-html.js +66 -0
  65. package/dist/lib/web-dashboard.d.ts +36 -0
  66. package/dist/lib/web-dashboard.js +322 -0
  67. package/package.json +3 -1
  68. package/scripts/codex-app-router.js +86 -14
  69. package/scripts/codex-routing.js +1 -0
  70. package/scripts/codex.js +150 -13
@@ -14,11 +14,14 @@ const efforts = new Set([
14
14
  ]);
15
15
  /** Read the explicit model-specific statement, never surrounding examples or another model's settings. */
16
16
  export function parseApiReasoningDocumentation(id, text) {
17
+ return parseReasoningStatement(id, text) ?? [];
18
+ }
19
+ function parseReasoningStatement(id, text) {
17
20
  if (text.match(/^Model ID:\s*`([^`]+)`/m)?.[1] !== id)
18
- return [];
21
+ return undefined;
19
22
  const statement = text.match(/`?reasoning\.effort`?\s+supports\s*:?\s*([^\n.]+)\./i)?.[1];
20
- if (!statement)
21
- return [];
23
+ if (statement === undefined)
24
+ return undefined;
22
25
  const values = statement
23
26
  .replace(/`|\(default\)/g, " ")
24
27
  .split(/,|\band\b/)
@@ -29,9 +32,25 @@ export function parseApiReasoningDocumentation(id, text) {
29
32
  values.some((v) => !/^[a-z][a-z0-9_-]{0,31}$/.test(v) ||
30
33
  v === "ultra" ||
31
34
  v === "persistent"))
32
- return [];
35
+ return undefined;
33
36
  return [...new Set(values)];
34
37
  }
38
+ /** Candidates describe checks, never request defaults or granted entitlements. */
39
+ function programCandidates(route, id) {
40
+ const configured = (route.modelAccessPrograms?.[id] ?? route.accessPrograms)?.cyber;
41
+ const candidates = [...(configured ?? []), ...(/(?:^|-)cyber(?:-|$)|daybreak-red/.test(id)
42
+ ? ["daybreak_red"] : /^gpt-[56](?:[.-]|$)/.test(id) ? ["standard", "daybreak_blue"] : [])];
43
+ return [...new Set(candidates)].filter(value => ["standard", "daybreak_blue", "daybreak_red"].includes(value));
44
+ }
45
+ function programsFresh(result, programs, now) {
46
+ return result.programRetry !== true && result.programCheckedAt !== undefined &&
47
+ Number.isFinite(result.programCheckedAt) && result.programCheckedAt >= 0 && result.programCheckedAt <= 8.64e15 &&
48
+ result.programCheckedAt <= now && result.programCheckedAt + 86400_000 > now &&
49
+ programs.every(program => ["verified", "unsupported"].includes(result.status[`program:cyber:${program}`] ?? ""));
50
+ }
51
+ function probeKey(route, id) {
52
+ return createHash("sha256").update(route.id).update("\0").update(route.apiKey).update("\0").update(id).digest("hex");
53
+ }
35
54
  /** Only the exact model's input declaration can enable native image attachments. */
36
55
  function parseInputModalities(id, text) {
37
56
  if (text.match(/^Model ID:\s*`([^`]+)`/m)?.[1] !== id)
@@ -81,9 +100,14 @@ function classifyProbe403(data, setting) {
81
100
  export class ApiModelCapabilities {
82
101
  fetchImpl;
83
102
  now;
103
+ probeSlot;
104
+ documentationSource;
105
+ programRefreshBefore;
84
106
  activeProbes = 0;
85
107
  probeWaiters = [];
86
108
  async withProbeSlot(operation) {
109
+ if (this.probeSlot)
110
+ return this.probeSlot(operation);
87
111
  if (this.activeProbes >= 4)
88
112
  await new Promise((resolve) => this.probeWaiters.push(resolve));
89
113
  else
@@ -102,9 +126,16 @@ export class ApiModelCapabilities {
102
126
  inFlight = new Map();
103
127
  probes = new Map();
104
128
  cache = new Map();
105
- constructor(fetchImpl = fetch, now = Date.now) {
129
+ constructor(fetchImpl = fetch, now = Date.now, probeSlot,
130
+ // Keep public documentation across short-lived credential/model leases.
131
+ documentationSource,
132
+ // Scheduled checks need program evidence to last until their next run.
133
+ programRefreshBefore) {
106
134
  this.fetchImpl = fetchImpl;
107
135
  this.now = now;
136
+ this.probeSlot = probeSlot;
137
+ this.documentationSource = documentationSource;
138
+ this.programRefreshBefore = programRefreshBefore;
108
139
  }
109
140
  /** Probe results keyed by an opaque hash, for a fresh CLI process to honour the 15-minute cache. */
110
141
  exportProbes() {
@@ -121,6 +152,8 @@ export class ApiModelCapabilities {
121
152
  return (await this.documentation(id, refresh)).levels;
122
153
  }
123
154
  async documentation(id, refresh = false) {
155
+ if (this.documentationSource)
156
+ return this.documentationSource.documentation(id, refresh);
124
157
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(id))
125
158
  return { levels: [] };
126
159
  const cached = this.cache.get(id);
@@ -153,41 +186,60 @@ export class ApiModelCapabilities {
153
186
  await reader.cancel();
154
187
  }
155
188
  const text = Buffer.concat(chunks).toString("utf8");
189
+ const reasoning = parseReasoningStatement(id, text);
156
190
  metadata = {
157
- levels: parseApiReasoningDocumentation(id, text),
191
+ levels: reasoning ?? [],
192
+ reasoningAvailable: reasoning !== undefined,
158
193
  inputModalities: parseInputModalities(id, text),
159
194
  };
160
195
  }
161
196
  catch {
162
197
  // An outage is not a capability revocation. Keep last successful evidence.
163
- metadata = cached ?? metadata;
198
+ metadata = { ...(cached ?? metadata), reasoningAvailable: false };
164
199
  }
165
200
  if (this.cache.size >= 1000)
166
201
  this.cache.delete(this.cache.keys().next().value ?? "");
167
202
  this.cache.set(id, { ...metadata, at: this.now() });
168
203
  return metadata;
169
204
  }
170
- async probe(route, id, documented, force = false) {
171
- const key = createHash("sha256")
172
- .update(route.id)
173
- .update("\0")
174
- .update(route.apiKey)
175
- .update("\0")
176
- .update(id)
177
- .digest("hex");
178
- const old = this.probes.get(key);
205
+ programsCoverCheck(result, programs) {
206
+ return programsFresh(result, programs, this.now()) &&
207
+ (this.programRefreshBefore === undefined || (result.programCheckedAt ?? -Infinity) + 86400_000 >= this.programRefreshBefore);
208
+ }
209
+ freshProbe(route, id, old) {
179
210
  // A probe cut short by 401/403/429 is retried after a minute, not trusted for 15.
180
211
  const freshFor = old?.status.retry === "transient" ? 60_000 : 900_000;
181
- if (!force && old && old.at + freshFor > this.now())
212
+ const programs = programCandidates(route, id);
213
+ const oldPrograms = old ? Object.keys(old.status).filter(name => name.startsWith("program:cyber:")) : [];
214
+ const samePrograms = oldPrograms.length === programs.length && programs.every(program => oldPrograms.includes(`program:cyber:${program}`));
215
+ const programCacheFresh = old && samePrograms && (programs.length
216
+ ? this.programsCoverCheck(old, programs) || ((old.programRetry === true || old.status.retry === "transient") && old.at + 60_000 > this.now())
217
+ : old.programCheckedAt === undefined);
218
+ if (old && old.at <= this.now() && old.at + freshFor > this.now() && programCacheFresh)
182
219
  return old;
220
+ return undefined;
221
+ }
222
+ reconcileReasoning(probe, documented, available) {
223
+ if (!available)
224
+ return probe;
225
+ return { ...probe, levels: probe.levels.filter(effort => documented.includes(effort) || probe.status[`effort:${effort}`] === "verified") };
226
+ }
227
+ async probe(route, id, documented, force = false, reasoningAvailable = false) {
228
+ const key = probeKey(route, id);
229
+ const fresh = this.freshProbe(route, id, this.probes.get(key));
230
+ if (!force && fresh) {
231
+ const reconciled = this.reconcileReasoning(fresh, documented, reasoningAvailable);
232
+ this.probes.set(key, reconciled);
233
+ return reconciled;
234
+ }
183
235
  const running = this.inFlight.get(key);
184
236
  if (running)
185
237
  return running;
186
- const pending = this.runProbe(route, id, documented, key).finally(() => this.inFlight.delete(key));
238
+ const pending = this.runProbe(route, id, documented, key, force, reasoningAvailable).finally(() => this.inFlight.delete(key));
187
239
  this.inFlight.set(key, pending);
188
240
  return pending;
189
241
  }
190
- async runProbe(route, id, documented, key) {
242
+ async runProbe(route, id, documented, key, force, reasoningAvailable) {
191
243
  const result = {
192
244
  at: this.now(),
193
245
  levels: [...documented],
@@ -200,6 +252,7 @@ export class ApiModelCapabilities {
200
252
  // inconclusive and keeps what an earlier probe verified. Only
201
253
  // "unsupported" or "downgraded" removes a setting otherwise.
202
254
  let lostAccess = false;
255
+ let programAuthFailure = false;
203
256
  // A model-level denial hides the model on this credential: no efforts, no tiers.
204
257
  let modelDenied = false;
205
258
  const previous = this.probes.get(key);
@@ -222,6 +275,7 @@ export class ApiModelCapabilities {
222
275
  if (kind === "setting")
223
276
  return "unsupported";
224
277
  credentialUnavailable = true;
278
+ programAuthFailure = Boolean(setting.program);
225
279
  return "unverified";
226
280
  };
227
281
  return this.withProbeSlot(async () => {
@@ -266,6 +320,7 @@ export class ApiModelCapabilities {
266
320
  ? { reasoning: { effort: setting.effort } }
267
321
  : {}),
268
322
  ...(setting.tier ? { service_tier: setting.tier } : {}),
323
+ ...(setting.program ? { access_programs: { cyber: setting.program } } : {}),
269
324
  }),
270
325
  });
271
326
  if ([401, 429].includes(response.status))
@@ -299,6 +354,21 @@ export class ApiModelCapabilities {
299
354
  catch {
300
355
  return response.status === 403 ? denied403(null) : "unverified";
301
356
  }
357
+ if (setting.program) {
358
+ const error = isRecord(data) && isRecord(data.error) ? data.error : null;
359
+ if ([400, 403].includes(response.status) && error &&
360
+ (["invalid_access_program", "unsupported_access_program", "access_program_not_enabled"].includes(String(error.code)) ||
361
+ ["access_programs", "access_programs.cyber"].includes(String(error.param))))
362
+ return "unsupported";
363
+ if (response.status === 403) {
364
+ programAuthFailure = true;
365
+ return denied403(data);
366
+ }
367
+ if (!response.ok)
368
+ return "unverified";
369
+ return isRecord(data) && ["completed", "incomplete"].includes(String(data.status)) && isRecord(data.access_programs) && data.access_programs.cyber === setting.program
370
+ ? "verified" : "unverified";
371
+ }
302
372
  if (response.status === 403)
303
373
  return denied403(data);
304
374
  if (!response.ok) {
@@ -357,7 +427,10 @@ export class ApiModelCapabilities {
357
427
  for (const { effort, status } of checked) {
358
428
  const effective = keep(`effort:${effort}`, status);
359
429
  result.status[`effort:${effort}`] = effective;
360
- if (effective === "verified")
430
+ // Persisted levels can originate in public documentation, without a
431
+ // verified effort probe. Keep that scoped evidence through an
432
+ // inconclusive retry only while current documentation is unavailable.
433
+ if (effective === "verified" || (status === "unverified" && !reasoningAvailable && !lostAccess && !modelDenied && previous?.levels.includes(effort)))
361
434
  result.levels.push(effort);
362
435
  }
363
436
  }
@@ -373,12 +446,54 @@ export class ApiModelCapabilities {
373
446
  if (effective === "verified")
374
447
  result.tiers.push(tier);
375
448
  }
449
+ const programs = programCandidates(route, id);
450
+ const reusable = !force && previous && this.programsCoverCheck(previous, programs);
451
+ if (programs.length && reusable && !lostAccess && !modelDenied && !programAuthFailure) {
452
+ for (const program of programs)
453
+ result.status[`program:cyber:${program}`] = previous.status[`program:cyber:${program}`] ?? "unverified";
454
+ result.programCheckedAt = previous.programCheckedAt;
455
+ }
456
+ else if (programs.length) {
457
+ let inconclusive = false;
458
+ for (const program of programs) {
459
+ const name = `program:cyber:${program}`;
460
+ const status = await attempt({ program, effort });
461
+ if (status === "unverified")
462
+ inconclusive = true;
463
+ result.status[name] = !force && !programAuthFailure ? keep(name, status) : status;
464
+ }
465
+ // Never renew old evidence after throttling, transport failure or a missing echo.
466
+ result.programCheckedAt = inconclusive ? previous?.programCheckedAt : this.now();
467
+ if (inconclusive) {
468
+ result.status.retry = "transient";
469
+ result.programRetry = true;
470
+ }
471
+ if (programAuthFailure) {
472
+ for (const program of programs)
473
+ result.status[`program:cyber:${program}`] = "unverified";
474
+ }
475
+ }
376
476
  if (modelDenied) {
377
477
  result.levels = [];
378
478
  result.tiers = [];
379
479
  for (const name of Object.keys(result.status))
380
480
  result.status[name] = "unsupported";
381
481
  result.status.responses = "unsupported";
482
+ if (!lostAccess) {
483
+ // A structured model denial conclusively denies every candidate,
484
+ // including checks skipped after the denial. Do not retry it as a
485
+ // transport failure merely because those POSTs were unnecessary.
486
+ delete result.status.retry;
487
+ delete result.programRetry;
488
+ if (programs.length)
489
+ result.programCheckedAt = this.now();
490
+ }
491
+ }
492
+ if (lostAccess) {
493
+ result.levels = [];
494
+ result.tiers = [];
495
+ for (const name of Object.keys(result.status))
496
+ result.status[name] = "unverified";
382
497
  }
383
498
  if (credentialUnavailable && !modelDenied)
384
499
  result.status.retry = "transient";
@@ -388,15 +503,21 @@ export class ApiModelCapabilities {
388
503
  this.probes.set(key, result);
389
504
  return result;
390
505
  }
391
- async enrich(models, refresh = false, route, forceProbes = false) {
506
+ async enrich(models, refresh = false, route, forceProbes = false,
507
+ // Supplying evidence limits this call to cache reads, even when forced.
508
+ cachedProbesOnly) {
509
+ const cached = cachedProbesOnly === undefined ? undefined : new Map(cachedProbesOnly);
392
510
  return mapWithConcurrency(models, 3, async (model) => {
393
511
  const documented = await this.documentation(model.slug, refresh);
394
512
  let levels = documented.levels;
395
513
  let extra = {};
396
514
  if (route?.enabled && route.probeCapabilities) {
397
- const probed = await this.probe(route, model.slug, levels, forceProbes);
398
- levels = probed.levels;
399
- extra = {
515
+ let probed = cached ? this.freshProbe(route, model.slug, cached.get(probeKey(route, model.slug))) : await this.probe(route, model.slug, levels, forceProbes, documented.reasoningAvailable === true);
516
+ if (probed && cached)
517
+ probed = this.reconcileReasoning(probed, levels, documented.reasoningAvailable === true);
518
+ if (probed)
519
+ levels = probed.levels;
520
+ extra = probed ? {
400
521
  service_tiers: probed.tiers.map((id) => ({
401
522
  id: id === "fast" ? "priority" : id,
402
523
  name: id === "fast" ? "Fast" : "Ultrafast",
@@ -406,6 +527,16 @@ export class ApiModelCapabilities {
406
527
  additional_speed_tiers: probed.tiers,
407
528
  capability_probe_status: probed.status,
408
529
  capability_checked_at: probed.at,
530
+ program_checked_at: probed.programCheckedAt,
531
+ } : {
532
+ // A previously enriched model must not smuggle expired or
533
+ // differently scoped evidence through a cache-only miss.
534
+ service_tiers: [],
535
+ default_service_tier: "default",
536
+ additional_speed_tiers: [],
537
+ capability_probe_status: undefined,
538
+ capability_checked_at: undefined,
539
+ program_checked_at: undefined,
409
540
  };
410
541
  }
411
542
  // Ultra is a native orchestration mode. Codex resolves it to this
@@ -1,3 +1,4 @@
1
+ import type { WebSocketDisconnectDiagnostic, WebSocketUpgradeRejectionDiagnostic } from "./responses-websocket.js";
1
2
  export interface AppBindPaths {
2
3
  codexHome: string;
3
4
  configPath: string;
@@ -37,6 +38,11 @@ export interface AppBindState {
37
38
  updatedAt: number;
38
39
  }
39
40
  export interface AppBindRouterStatus {
41
+ lastWebsocketDisconnect?: WebSocketDisconnectDiagnostic | null;
42
+ websocketActiveConnections?: number | null;
43
+ websocketCapacityReclaims?: number | null;
44
+ lastWebsocketUpgradeRejection?: WebSocketUpgradeRejectionDiagnostic | null;
45
+ schedulingStrategy?: "hybrid" | "sequential";
40
46
  state: string | null;
41
47
  pid: number | null;
42
48
  startedAt?: number | null;
@@ -142,4 +148,5 @@ export declare function stopRuntimeRotationAppHelperProcess(helper: RuntimeRotat
142
148
  export declare function getAppBindStatus(options?: AppBindOptions): Promise<AppBindStatus>;
143
149
  export declare function bindCodexAppRuntimeRotation(options?: AppBindOptions): Promise<AppBindResult>;
144
150
  export declare function unbindCodexAppRuntimeRotation(options?: AppBindOptions): Promise<AppBindResult>;
151
+ export declare function formatWebsocketDisconnect(value: unknown): string | null;
145
152
  export declare function formatAppBindStatus(status: AppBindStatus): string;
@@ -228,11 +228,77 @@ async function readAppBindBackup(path) {
228
228
  createdAt,
229
229
  };
230
230
  }
231
+ function readWebsocketDisconnect(value) {
232
+ if (!isRecord(value) || typeof value.at !== "number" || !Number.isFinite(value.at) || value.at < 0)
233
+ return null;
234
+ const { cause, stage, closeCode, routeKind, connectionAgeMs, eventCount } = value;
235
+ const transport = {};
236
+ for (const key of ["requestBytes", "inputItemCount"]) {
237
+ const field = value[key];
238
+ if (field !== undefined) {
239
+ if (typeof field !== "number" || !Number.isSafeInteger(field) || field < 0)
240
+ return null;
241
+ transport[key] = field;
242
+ }
243
+ }
244
+ for (const key of ["isPrewarm", "hasPreviousResponse", "reusedConnection", "sendCompleted", "compressionNegotiated", "tcpEndReceived", "tcpCloseHadError"]) {
245
+ const field = value[key];
246
+ if (field !== undefined) {
247
+ if (typeof field !== "boolean")
248
+ return null;
249
+ transport[key] = field;
250
+ }
251
+ }
252
+ const errorCode = value.transportErrorCode;
253
+ if (errorCode !== undefined) {
254
+ if (errorCode !== "ECONNRESET" && errorCode !== "EPIPE" && errorCode !== "ETIMEDOUT" && errorCode !== "OTHER")
255
+ return null;
256
+ transport.transportErrorCode = errorCode;
257
+ }
258
+ if (routeKind !== undefined && routeKind !== "oauth" && routeKind !== "api" && routeKind !== "zdr")
259
+ return null;
260
+ for (const count of [connectionAgeMs, eventCount])
261
+ if (count !== undefined && (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0))
262
+ return null;
263
+ if (cause !== "close" && cause !== "transport_error" && cause !== "invalid_event" && cause !== "buffer_limit" && cause !== "send_error")
264
+ return null;
265
+ if (stage !== "awaiting_first_event" && stage !== "streaming")
266
+ return null;
267
+ if (closeCode !== undefined && (typeof closeCode !== "number" || !Number.isInteger(closeCode) || closeCode < 1000 || closeCode > 4999))
268
+ return null;
269
+ return { at: value.at, cause, stage, ...(typeof closeCode === "number" ? { closeCode } : {}),
270
+ ...(routeKind === "oauth" || routeKind === "api" || routeKind === "zdr" ? { routeKind } : {}),
271
+ ...(typeof connectionAgeMs === "number" ? { connectionAgeMs } : {}),
272
+ ...(typeof eventCount === "number" ? { eventCount } : {}), ...transport };
273
+ }
274
+ function readWebsocketCounter(value, maximum = Number.MAX_SAFE_INTEGER) {
275
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum
276
+ ? value : null;
277
+ }
278
+ function readWebsocketUpgradeRejection(value) {
279
+ if (!isRecord(value))
280
+ return null;
281
+ const at = readWebsocketCounter(value.at, 8.64e15);
282
+ const activeConnections = readWebsocketCounter(value.activeConnections, 64);
283
+ const { reason, status } = value;
284
+ if (at === null || activeConnections === null)
285
+ return null;
286
+ if (!((reason === "origin" && status === 403) ||
287
+ ((reason === "capacity" || reason === "repeated_disconnect") && status === 426)))
288
+ return null;
289
+ return { at, reason, status, activeConnections };
290
+ }
231
291
  async function readRouterStatus(path) {
232
292
  const record = await readJsonFile(path);
233
293
  if (!record)
234
294
  return null;
235
295
  return {
296
+ lastWebsocketDisconnect: readWebsocketDisconnect(record.lastWebsocketDisconnect),
297
+ websocketActiveConnections: readWebsocketCounter(record.websocketActiveConnections, 64),
298
+ websocketCapacityReclaims: readWebsocketCounter(record.websocketCapacityReclaims),
299
+ lastWebsocketUpgradeRejection: readWebsocketUpgradeRejection(record.lastWebsocketUpgradeRejection),
300
+ schedulingStrategy: record.schedulingStrategy === "hybrid" || record.schedulingStrategy === "sequential"
301
+ ? record.schedulingStrategy : undefined,
236
302
  state: readString(record, "state"),
237
303
  pid: readNumber(record, "pid"),
238
304
  startedAt: readNumber(record, "startedAt"),
@@ -1380,6 +1446,27 @@ async function unbindCodexAppRuntimeRotationLocked(options, paths) {
1380
1446
  message,
1381
1447
  };
1382
1448
  }
1449
+ export function formatWebsocketDisconnect(value) {
1450
+ const diagnostic = readWebsocketDisconnect(value);
1451
+ if (!diagnostic || diagnostic.at > 8.64e15)
1452
+ return null;
1453
+ const transport = ["requestBytes", "inputItemCount", "isPrewarm", "hasPreviousResponse", "reusedConnection", "sendCompleted", "compressionNegotiated", "transportErrorCode", "tcpEndReceived", "tcpCloseHadError"]
1454
+ .filter(key => diagnostic[key] !== undefined).map(key => `; ${key}=${diagnostic[key]}`).join("");
1455
+ return `Last WebSocket disconnect: ${new Date(diagnostic.at).toISOString()}; cause=${diagnostic.cause}; stage=${diagnostic.stage}${diagnostic.closeCode !== undefined ? `; closeCode=${diagnostic.closeCode}` : ""}${diagnostic.routeKind !== undefined ? `; route=${diagnostic.routeKind}` : ""}${diagnostic.connectionAgeMs !== undefined ? `; connectionAgeMs=${diagnostic.connectionAgeMs}` : ""}${diagnostic.eventCount !== undefined ? `; events=${diagnostic.eventCount}` : ""}${transport}`;
1456
+ }
1457
+ function formatWebsocketAdmission(status) {
1458
+ const active = readWebsocketCounter(status?.websocketActiveConnections, 64);
1459
+ const reclaimed = readWebsocketCounter(status?.websocketCapacityReclaims);
1460
+ const counters = [
1461
+ ...(active !== null ? [`active=${active}`] : []),
1462
+ ...(reclaimed !== null ? [`idleReclaimed=${reclaimed}`] : []),
1463
+ ];
1464
+ const rejection = readWebsocketUpgradeRejection(status?.lastWebsocketUpgradeRejection);
1465
+ return [
1466
+ ...(counters.length ? [`WebSocket connections: ${counters.join("; ")}`] : []),
1467
+ ...(rejection ? [`Last WebSocket upgrade rejection: ${new Date(rejection.at).toISOString()}; reason=${rejection.reason}; status=${rejection.status}; active=${rejection.activeConnections}`] : []),
1468
+ ];
1469
+ }
1383
1470
  export function formatAppBindStatus(status) {
1384
1471
  if (status.unmanagedBind && !status.state) {
1385
1472
  return [
@@ -1404,10 +1491,14 @@ export function formatAppBindStatus(status) {
1404
1491
  else if (status.router?.lastAccountIndex !== null && status.router?.lastAccountIndex !== undefined) {
1405
1492
  parts.push(`lastAccount=Account ${status.router.lastAccountIndex + 1}`);
1406
1493
  }
1494
+ const diagnostic = formatWebsocketDisconnect(status.router?.lastWebsocketDisconnect);
1495
+ const admission = formatWebsocketAdmission(status.router);
1407
1496
  if (status.state.nativeOpenai)
1408
- return `Codex app bind: ${parts.join(", ")}, native OpenAI, live account catalogs`;
1497
+ return [`Codex app bind: ${parts.join(", ")}, native OpenAI, live account catalogs`, ...(diagnostic ? [diagnostic] : []), ...admission].join("\n");
1409
1498
  return [
1410
1499
  `Codex app bind: ${parts.join(", ")}`,
1500
+ ...(diagnostic ? [diagnostic] : []),
1501
+ ...admission,
1411
1502
  [
1412
1503
  "Note: Codex Desktop may hide history while the app bind selects the",
1413
1504
  "codex-multi-auth-runtime-proxy provider; use `codex-multi-auth rotation",
@@ -0,0 +1,15 @@
1
+ import { type ApiRouteCredential } from "../api-route-store.js";
2
+ export declare const AUTOMATIC_API_CHECK_INTERVAL_MS: number;
3
+ export declare function runAutomaticApiChecks(options: {
4
+ path: string;
5
+ loadRoutes: () => Promise<ApiRouteCredential[]>;
6
+ prepareRoute?: (route: ApiRouteCredential, signal: AbortSignal) => Promise<ApiRouteCredential | undefined>;
7
+ check: (route: ApiRouteCredential, signal: AbortSignal, programRefreshBefore: number) => Promise<void>;
8
+ now?: () => number;
9
+ signal?: AbortSignal;
10
+ }): Promise<number | undefined>;
11
+ export declare function createAutomaticApiCheck(upstreamFetch?: typeof fetch): (signal: AbortSignal) => Promise<number | undefined>;
12
+ /** Daily checks follow persisted claim deadlines, never overlap, and drain on shutdown. */
13
+ export declare function startAutomaticApiChecks(run: (signal: AbortSignal) => Promise<number | void>): {
14
+ stop(): Promise<void>;
15
+ };
@@ -0,0 +1,188 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { z } from "zod";
5
+ import { loadApiRoutes, updateApiModelDiscovery, ensureApiRouteCapabilityEpoch } from "../api-route-store.js";
6
+ import { getCodexMultiAuthDir } from "../runtime-paths.js";
7
+ import { withFileTransactionLock } from "../storage/file-lock.js";
8
+ import { tempPathFor } from "../temp-path.js";
9
+ import { withRetry } from "../fs-retry.js";
10
+ import { combineSignals } from "../utils.js";
11
+ import { logWarn } from "../logger.js";
12
+ import { discoverModelInventory, saveModelInventory } from "./model-discovery-status.js";
13
+ export const AUTOMATIC_API_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
14
+ const PREPARATION_RETRY_MS = 60_000;
15
+ const schema = z.record(z.string().regex(/^[a-f0-9]{64}$/), z.number().finite().nonnegative());
16
+ const retry = { maxAttempts: 6, backoffMs: 25 };
17
+ function key(route) { return createHash("sha256").update(JSON.stringify([route.id, route.kind, route.apiKey, route.visibleModels])).digest("hex"); }
18
+ const eligible = (route) => route.enabled && route.probeCapabilities === true;
19
+ // Visibility can expand during discovery; daily spending remains credential-scoped.
20
+ function claimKey(route) { return createHash("sha256").update(JSON.stringify([route.id, route.kind, route.apiKey])).digest("hex"); }
21
+ // Compare visibility as a set without changing stored order or legacy claim hashes.
22
+ function semanticRoute(route) { return { ...route, visibleModels: [...new Set(route.visibleModels)].sort() }; }
23
+ function sameRoute(a, b) { return JSON.stringify(Object.entries(semanticRoute(a)).sort(([x], [y]) => x.localeCompare(y))) === JSON.stringify(Object.entries(semanticRoute(b)).sort(([x], [y]) => x.localeCompare(y))); }
24
+ export async function runAutomaticApiChecks(options) {
25
+ const signal = options.signal ?? new AbortController().signal;
26
+ // Claims name the scheduled pass, so I/O and later routes cannot move its horizon.
27
+ const startedAt = options.now?.() ?? Date.now();
28
+ let nextRunAt;
29
+ const includeDeadline = (at) => { nextRunAt = Math.min(nextRunAt ?? Infinity, at + AUTOMATIC_API_CHECK_INTERVAL_MS); };
30
+ if (signal.aborted)
31
+ return;
32
+ const routes = (await options.loadRoutes()).filter(eligible);
33
+ if (!routes.length)
34
+ return;
35
+ for (const route of routes) {
36
+ if (signal.aborted)
37
+ return;
38
+ const observed = (await options.loadRoutes()).find(r => eligible(r) && key(semanticRoute(r)) === key(semanticRoute(route)));
39
+ if (!observed)
40
+ continue;
41
+ // Complete store-owned migrations before capturing the exact claim snapshot.
42
+ let current;
43
+ try {
44
+ current = options.prepareRoute ? await options.prepareRoute(observed, signal) : observed;
45
+ }
46
+ catch {
47
+ if (signal.aborted)
48
+ return;
49
+ nextRunAt = Math.min(nextRunAt ?? Infinity, (options.now?.() ?? Date.now()) + PREPARATION_RETRY_MS);
50
+ logWarn("Automatic API route preparation failed; retrying shortly.");
51
+ continue;
52
+ }
53
+ if (!current || !eligible(current) || signal.aborted)
54
+ continue;
55
+ // Commit the claim before network I/O. Concurrent routers and restarts share it.
56
+ let programRefreshBefore = startedAt + AUTOMATIC_API_CHECK_INTERVAL_MS;
57
+ const claimed = await withFileTransactionLock(options.path, async () => {
58
+ // A credential wipe can finish while this checker is queued for the lock.
59
+ if (signal.aborted || !(await options.loadRoutes()).some(r => eligible(r) && sameRoute(r, current)))
60
+ return false;
61
+ let attempts = {};
62
+ let raw, repairHistory = false;
63
+ try {
64
+ raw = await withRetry(() => fs.readFile(options.path, "utf8"), retry);
65
+ }
66
+ catch (error) {
67
+ if (error.code !== "ENOENT")
68
+ throw error;
69
+ }
70
+ if (raw !== undefined)
71
+ try {
72
+ if (Buffer.byteLength(raw) > 1024 * 1024)
73
+ throw Error("API-check history too large");
74
+ attempts = schema.parse(JSON.parse(raw));
75
+ }
76
+ catch {
77
+ repairHistory = true;
78
+ }
79
+ const now = options.now?.() ?? Date.now(), id = claimKey(current);
80
+ // Unknown prior attempts must not trigger another billable pass on repair.
81
+ // Seed every eligible route so a restart can resume normally the next day.
82
+ if (repairHistory)
83
+ attempts = Object.fromEntries((await options.loadRoutes()).filter(eligible).map(route => [claimKey(route), now]));
84
+ const legacyClaims = [attempts[key(current)], attempts[key(observed)], attempts[key(route)]].filter((at) => at !== undefined && Number.isFinite(at) && at <= now + 300_000);
85
+ const last = attempts[id] ?? (legacyClaims.length ? Math.max(...legacyClaims) : undefined);
86
+ if (signal.aborted)
87
+ return false;
88
+ if (!repairHistory && last !== undefined && last <= now + 300_000 && now - last < AUTOMATIC_API_CHECK_INTERVAL_MS) {
89
+ includeDeadline(last);
90
+ return false;
91
+ }
92
+ // Keep other processes' claims; prune only the oldest history at a fixed bound.
93
+ for (const [old] of Object.entries(attempts).sort((a, b) => b[1] - a[1]).slice(999))
94
+ delete attempts[old];
95
+ const claimedAt = repairHistory ? now : Math.min(startedAt, now);
96
+ attempts[id] = claimedAt;
97
+ programRefreshBefore = claimedAt + AUTOMATIC_API_CHECK_INTERVAL_MS;
98
+ includeDeadline(claimedAt);
99
+ const temp = tempPathFor(options.path);
100
+ await fs.mkdir(dirname(options.path), { recursive: true, mode: 0o700 });
101
+ try {
102
+ await fs.writeFile(temp, JSON.stringify(attempts) + "\n", { mode: 0o600, flag: "wx" });
103
+ await withRetry(() => fs.rename(temp, options.path), retry);
104
+ }
105
+ finally {
106
+ await withRetry(() => fs.rm(temp, { force: true }), retry);
107
+ }
108
+ if (repairHistory)
109
+ logWarn("Automatic API check history repaired; billable checks deferred for one daily interval.");
110
+ return !repairHistory;
111
+ }, { signal });
112
+ if (!claimed || signal.aborted)
113
+ continue;
114
+ try {
115
+ await options.check(current, signal, programRefreshBefore);
116
+ }
117
+ catch {
118
+ if (!signal.aborted)
119
+ logWarn("Automatic API capability check failed; retry deferred until the next daily interval.");
120
+ }
121
+ }
122
+ return nextRunAt;
123
+ }
124
+ export function createAutomaticApiCheck(upstreamFetch = fetch) {
125
+ return async (signal) => {
126
+ return runAutomaticApiChecks({
127
+ path: join(getCodexMultiAuthDir(), "automatic-api-checks.json"), loadRoutes: loadApiRoutes, signal, prepareRoute: ensureApiRouteCapabilityEpoch,
128
+ check: async (route, stop, programRefreshBefore) => {
129
+ let acceptedRoute = route, discoveryAccepted = true;
130
+ const updateDiscovery = async (original, models) => {
131
+ discoveryAccepted = false;
132
+ const known = new Set(original.knownModels ?? models), visible = new Set(original.visibleModels);
133
+ for (const model of models) {
134
+ if (!known.has(model) && /^gpt-/i.test(model))
135
+ visible.add(model);
136
+ known.add(model);
137
+ }
138
+ const expected = { ...original, knownModels: [...known], visibleModels: [...visible] };
139
+ const updated = await updateApiModelDiscovery(original, models);
140
+ // Accept this discovery's expansion only, never a concurrent policy edit.
141
+ if (!sameRoute(updated, expected))
142
+ throw Error("API check route changed");
143
+ acceptedRoute = updated;
144
+ discoveryAccepted = true;
145
+ return updated;
146
+ };
147
+ // Link shutdown to each existing bounded request without weakening its timeout.
148
+ const fetcher = async (input, init) => {
149
+ const signal = combineSignals(stop, init?.signal);
150
+ signal.throwIfAborted();
151
+ if (init?.method?.toUpperCase() === "POST" && (!discoveryAccepted || !(await loadApiRoutes()).some(r => eligible(r) && sameRoute(r, acceptedRoute))))
152
+ throw Error("API check route changed");
153
+ signal.throwIfAborted();
154
+ return upstreamFetch(input, { ...init, signal });
155
+ };
156
+ const inventory = await discoverModelInventory(null, [route], { fetchImpl: fetcher, signal: stop, persistProbes: true, programRefreshBefore, updateApiDiscovery: updateDiscovery });
157
+ if (stop.aborted || !discoveryAccepted)
158
+ return;
159
+ const current = (await loadApiRoutes()).find(r => eligible(r) && sameRoute(r, acceptedRoute));
160
+ if (current)
161
+ await saveModelInventory(inventory, { mergeEntries: true, signal: stop });
162
+ },
163
+ });
164
+ };
165
+ }
166
+ /** Daily checks follow persisted claim deadlines, never overlap, and drain on shutdown. */
167
+ export function startAutomaticApiChecks(run) {
168
+ const controller = new AbortController();
169
+ let pending;
170
+ let timer;
171
+ const schedule = (delay) => { if (!controller.signal.aborted) {
172
+ timer = setTimeout(tick, delay);
173
+ timer.unref();
174
+ } };
175
+ const tick = () => {
176
+ timer = undefined;
177
+ if (controller.signal.aborted)
178
+ return;
179
+ let nextRunAt = Date.now() + AUTOMATIC_API_CHECK_INTERVAL_MS;
180
+ pending = Promise.resolve().then(() => run(controller.signal)).then(due => { if (typeof due === "number" && Number.isFinite(due))
181
+ nextRunAt = due; })
182
+ .catch(() => { if (!controller.signal.aborted)
183
+ logWarn("Automatic API capability checks unavailable; retry deferred."); })
184
+ .finally(() => { pending = undefined; schedule(Math.min(AUTOMATIC_API_CHECK_INTERVAL_MS, Math.max(0, nextRunAt - Date.now()))); });
185
+ };
186
+ schedule(5000);
187
+ return { async stop() { clearTimeout(timer); controller.abort(); await pending; } };
188
+ }