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,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.20.0",
3
+ "version": "2.22.0",
4
4
  "description": "Install and operate codex-multi-auth for the official @openai/codex CLI with multi-account OAuth rotation, switching, health checks, and recovery tools.",
5
5
  "interface": {
6
6
  "composerIcon": "./assets/codex-multi-auth-icon.svg"
package/README.md CHANGED
@@ -16,8 +16,9 @@ Using OpenCode rather than the Codex CLI? The sibling project [`oc-codex-multi-a
16
16
  ## What it does
17
17
 
18
18
  - **Account pool** — OAuth login for multiple ChatGPT accounts, stored locally under `~/.codex/multi-auth` (files `0600`, directories `0700`), with per-project pools under `projects/<project-key>/`.
19
- - **Switching and selection** — pin an account, pick the forecast-best one, or let health- and quota-aware hybrid selection rotate for you.
20
- - **Runtime rotation** — a default-on, loopback-only proxy that forwards Responses API and model-discovery traffic through the best available account during wrapper-launched Codex sessions.
19
+ - **Switching and selection** — pin an account, pick the forecast-best one, follow saved account order within customized priority tiers with sequential scheduling, or let health- and quota-aware hybrid selection rotate for you.
20
+ - **Runtime rotation** — a default-on, loopback-only proxy that forwards Responses API, model-discovery, image, thread-goal, and Codex dictation/transcription traffic through managed accounts during wrapper-launched Codex sessions.
21
+ - **API capability evidence** — opted-in API/ZDR routes receive daily billable checks while the router runs; status distinguishes configured access programs from verified responses. See [check behavior and controls](docs/reference/commands.md#codex-multi-auth-check).
21
22
  - **Diagnostics and repair** — status, live health checks, quota forecasts, JSON reports, and `fix`/`doctor`/`verify` commands that recover stale or damaged local state.
22
23
  - **Luna Reserve fallback** — treats `gpt-reserve` as a separately metered Luna allowance, automatically falls back after a genuine Luna quota 429, and exposes the Reserve percentage through `limits --json --refresh`.
23
24
  - **Local governance** — file-backed usage ledger, budget guards, account tags/weights/pause/drain policies, routing profiles, and an optional loopback bridge with bearer tokens for local OpenAI-compatible clients.
@@ -94,7 +95,7 @@ codex-multi-auth rotation status # is live rotation enabled?
94
95
 
95
96
  ## Runtime rotation, in one paragraph
96
97
 
97
- With rotation on (the default), request-bearing wrapper commands — `exec`, `review`, `resume`, `fork`, `app`, and the bare TUI — route through a local proxy that picks the healthiest account per request, refreshes tokens as needed, and fails over across the pool on rate limits within a bounded retry budget. The proxy listens on loopback only, authenticates its own clients with a per-process token, forwards only Responses API, model-discovery, image, and thread-goal calls, and never writes account emails or tokens to client headers or logs. Plain `codex` is never touched: rotation exists only inside `codex-multi-auth-codex`/`mcodex` sessions, or inside the packaged desktop app after an opt-in, reversible bind. Turn it off with `codex-multi-auth rotation disable` or `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`.
98
+ With rotation on (the default), request-bearing wrapper commands — `exec`, `review`, `resume`, `fork`, `app`, and the bare TUI — route through a local proxy that picks the healthiest account per request, refreshes tokens as needed, and fails over across the pool on rate limits within a bounded retry budget. The proxy listens on loopback only, authenticates its own clients with a per-process token, forwards only Responses API, model-discovery, image, thread-goal, and `POST /transcribe` calls, and never writes account emails or tokens to client headers or logs. Transcription preserves the incoming multipart audio body and boundary, uses managed ChatGPT OAuth, and does not replay ambiguous audio uploads across accounts. Plain `codex` is never touched: rotation exists only inside `codex-multi-auth-codex`/`mcodex` sessions, or inside the packaged desktop app after an opt-in, reversible bind. Turn it off with `codex-multi-auth rotation disable` or `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`.
98
99
 
99
100
  ## Where state lives
100
101
 
@@ -138,7 +139,7 @@ Common symptoms — wrong active account, OAuth callback port `1455` already bou
138
139
 
139
140
  ## Release notes
140
141
 
141
- - Current stable: [v2.20.0](docs/releases/v2.20.0.md) — `npm i -g codex-multi-auth`
142
+ - Current stable: [v2.22.0](docs/releases/v2.22.0.md) — `npm i -g codex-multi-auth`
142
143
  - Earlier stable lines: [v2.6.0](docs/releases/v2.6.0.md), [v2.5.0](docs/releases/v2.5.0.md)
143
144
  - Full release archive: [docs/README.md#release-history](docs/README.md#release-history)
144
145
 
@@ -70,6 +70,7 @@ export declare class AccountManager {
70
70
  private readonly persistedWorkspaceSelections;
71
71
  private readonly persistedWorkspaces;
72
72
  private hadPersistedStorage;
73
+ private readonly loadedAccountStorageSnapshot;
73
74
  private cursorByFamily;
74
75
  private currentAccountIndexByFamily;
75
76
  private lastToastAccountIndex;
@@ -103,6 +104,10 @@ export declare class AccountManager {
103
104
  */
104
105
  private routingMutexMode;
105
106
  static loadFromDisk(authFallback?: OAuthAuthDetails): Promise<AccountManager>;
107
+ /** @internal True when construction received a parsed/recovered storage snapshot. */
108
+ hasLoadedAccountStorageSnapshot(): boolean;
109
+ /** @internal True only when this account identity was explicitly persisted, not legacy-derived in memory. */
110
+ hasPersistedRecordId(account: ManagedAccount): boolean;
106
111
  hasRefreshToken(refreshToken: string): boolean;
107
112
  private hydrateFromCodexCli;
108
113
  constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV3 | null);
@@ -142,6 +142,7 @@ export class AccountManager {
142
142
  persistedWorkspaceSelections = new WeakMap();
143
143
  persistedWorkspaces = new WeakMap();
144
144
  hadPersistedStorage = false;
145
+ loadedAccountStorageSnapshot;
145
146
  cursorByFamily = initFamilyState(0);
146
147
  currentAccountIndexByFamily = initFamilyState(-1);
147
148
  lastToastAccountIndex = -1;
@@ -202,6 +203,18 @@ export class AccountManager {
202
203
  await manager.hydrateFromCodexCli();
203
204
  return manager;
204
205
  }
206
+ /** @internal True when construction received a parsed/recovered storage snapshot. */
207
+ hasLoadedAccountStorageSnapshot() {
208
+ return this.loadedAccountStorageSnapshot;
209
+ }
210
+ /** @internal True only when this account identity was explicitly persisted, not legacy-derived in memory. */
211
+ hasPersistedRecordId(account) {
212
+ const recordId = account.recordId?.trim();
213
+ if (!recordId || !this.persistenceBaseline)
214
+ return false;
215
+ const baseline = this.persistenceBaseline.accounts.find((row) => resolveAccountRecordId(row) === recordId);
216
+ return typeof baseline?.recordId === "string" && baseline.recordId.trim().length > 0;
217
+ }
205
218
  hasRefreshToken(refreshToken) {
206
219
  return this.accounts.some((account) => account.refreshToken === refreshToken);
207
220
  }
@@ -266,6 +279,7 @@ export class AccountManager {
266
279
  }
267
280
  constructor(authFallback, stored) {
268
281
  this.storagePathState = { ...getStoragePathState() };
282
+ this.loadedAccountStorageSnapshot = stored !== undefined && stored !== null;
269
283
  this.hadPersistedStorage = existsSync(this.resolveSelectionStoragePath());
270
284
  const fallbackAccountId = extractAccountId(authFallback?.access)?.trim() || undefined;
271
285
  const fallbackAccountEmail = sanitizeEmail(extractAccountEmail(authFallback?.access));
@@ -522,6 +536,7 @@ export class AccountManager {
522
536
  _runtimeTrackerKey: trackerKey,
523
537
  circuitKeyId,
524
538
  rateLimitResetTimes: { ...account.rateLimitResetTimes },
539
+ workspaces: account.workspaces?.map((workspace) => ({ ...workspace })),
525
540
  };
526
541
  });
527
542
  }
@@ -1377,6 +1392,14 @@ export class AccountManager {
1377
1392
  if (!storedAccount) {
1378
1393
  return null;
1379
1394
  }
1395
+ const liveAccount = this.getAccountByIdentity(source, auth);
1396
+ // Credential rotation proves auth health only. It must not erase an
1397
+ // unrelated inference network/server/rate-limit backoff that happens
1398
+ // to be live on the same account.
1399
+ const preserveNonAuthCooldown = liveAccount?.cooldownReason !== undefined &&
1400
+ liveAccount.cooldownReason !== "auth-failure" &&
1401
+ typeof liveAccount.coolingDownUntil === "number" &&
1402
+ liveAccount.coolingDownUntil > nowMs();
1380
1403
  storedAccount.refreshToken = auth.refresh;
1381
1404
  storedAccount.accessToken = auth.access;
1382
1405
  storedAccount.expiresAt = auth.expires;
@@ -1391,9 +1414,14 @@ export class AccountManager {
1391
1414
  storedAccount.enabled = undefined;
1392
1415
  delete storedAccount.authInvalidatedAt;
1393
1416
  delete storedAccount.authInvalidationErrorCode;
1394
- delete storedAccount.coolingDownUntil;
1395
- delete storedAccount.cooldownReason;
1396
- const liveAccount = this.getAccountByIdentity(source, auth);
1417
+ if (preserveNonAuthCooldown && liveAccount) {
1418
+ storedAccount.coolingDownUntil = liveAccount.coolingDownUntil;
1419
+ storedAccount.cooldownReason = liveAccount.cooldownReason;
1420
+ }
1421
+ else {
1422
+ delete storedAccount.coolingDownUntil;
1423
+ delete storedAccount.cooldownReason;
1424
+ }
1397
1425
  if (liveAccount) {
1398
1426
  const previousLiveAccountState = {
1399
1427
  access: liveAccount.access,
@@ -1411,7 +1439,8 @@ export class AccountManager {
1411
1439
  };
1412
1440
  this.updateFromAuth(liveAccount, auth);
1413
1441
  liveAccount.enabled = true;
1414
- this.clearAccountCooldown(liveAccount);
1442
+ if (!preserveNonAuthCooldown)
1443
+ this.clearAccountCooldown(liveAccount);
1415
1444
  this.clearAuthFailures(liveAccount);
1416
1445
  try {
1417
1446
  await this.persistSnapshot(_current, nextStorage, persist, storedAccount);
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  declare const routeSchema: z.ZodObject<{
3
+ capabilityEpoch: z.ZodOptional<z.ZodString>;
4
+ legacyCapabilityCache: z.ZodOptional<z.ZodBoolean>;
3
5
  id: z.ZodString;
4
6
  label: z.ZodString;
5
7
  kind: z.ZodEnum<{
@@ -19,5 +21,9 @@ export type ApiRouteCredential = z.infer<typeof routeSchema>;
19
21
  export declare function getApiRoutesPath(): string;
20
22
  export declare function loadApiRoutes(path?: string): Promise<ApiRouteCredential[]>;
21
23
  export declare function saveApiRoutes(routes: ApiRouteCredential[], path?: string, expected?: ApiRouteCredential[]): Promise<void>;
24
+ /** Return our committed snapshot, never a later writer's configuration. */
25
+ export declare function saveApiRoutesSnapshot(routes: ApiRouteCredential[], path?: string, expected?: ApiRouteCredential[]): Promise<ApiRouteCredential[]>;
22
26
  export declare function updateApiModelDiscovery(route: ApiRouteCredential, models: string[], path?: string): Promise<ApiRouteCredential>;
27
+ /** Migrate legacy ownership before taking any capability lease or cache lock. */
28
+ export declare function ensureApiRouteCapabilityEpoch(route: ApiRouteCredential, signal?: AbortSignal): Promise<ApiRouteCredential | undefined>;
23
29
  export {};
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { promises as fs } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { z } from "zod";
@@ -15,6 +16,9 @@ const programId = z.string().min(1).max(80).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/
15
16
  const accessPrograms = z.record(programId, z.array(programId).max(32))
16
17
  .refine(value => Object.keys(value).length <= 16);
17
18
  const routeSchema = z.object({
19
+ /** Internal lifetime token. Store-owned; never trust an incoming token. */
20
+ capabilityEpoch: z.string().uuid().optional(),
21
+ legacyCapabilityCache: z.boolean().optional(),
18
22
  id: z
19
23
  .string()
20
24
  .min(1)
@@ -52,6 +56,9 @@ function validate(value) {
52
56
  throw new Error("Invalid API route configuration");
53
57
  return parsed.data.routes;
54
58
  }
59
+ class InvalidApiRouteContentsError extends Error {
60
+ constructor() { super("Invalid API route configuration"); }
61
+ }
55
62
  export async function loadApiRoutes(path = getApiRoutesPath()) {
56
63
  let raw;
57
64
  try {
@@ -78,39 +85,80 @@ export async function loadApiRoutes(path = getApiRoutesPath()) {
78
85
  return validate(JSON.parse(raw));
79
86
  }
80
87
  catch {
81
- throw new Error("Invalid API route configuration");
88
+ throw new InvalidApiRouteContentsError();
82
89
  }
83
90
  }
84
- export async function saveApiRoutes(routes, path = getApiRoutesPath(), expected) {
91
+ export function saveApiRoutes(routes, path = getApiRoutesPath(), expected) {
92
+ return saveApiRoutesInternal(routes, path, expected);
93
+ }
94
+ /** Return our committed snapshot, never a later writer's configuration. */
95
+ export async function saveApiRoutesSnapshot(routes, path = getApiRoutesPath(), expected) {
96
+ let saved = [];
97
+ await saveApiRoutesInternal(routes, path, expected, false, undefined, value => { saved = value; });
98
+ return saved;
99
+ }
100
+ async function saveApiRoutesInternal(routes, path = getApiRoutesPath(), expected, discoveryExpansion = false, signal, onSaved) {
101
+ await withStorageLock(() => withFileTransactionLock(path, () => saveApiRoutesLocked(routes, path, expected, discoveryExpansion, signal, onSaved), { signal }));
102
+ }
103
+ async function saveApiRoutesLocked(routes, path = getApiRoutesPath(), expected, discoveryExpansion = false, signal, onSaved) {
85
104
  const validated = validate({ version: 1, routes });
86
- const serialized = JSON.stringify({ version: 1, routes: validated }) + "\n";
105
+ let serialized = JSON.stringify({ version: 1, routes: validated }) + "\n";
87
106
  if (Buffer.byteLength(serialized) > 1024 * 1024)
88
107
  throw new Error("API route configuration is too large");
89
- await withStorageLock(() => withFileTransactionLock(path, async () => {
90
- await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
91
- const temp = tempPathFor(path);
92
- try {
93
- if (expected &&
94
- JSON.stringify(await loadApiRoutes(path)) !==
95
- JSON.stringify(validate({ version: 1, routes: expected }))) {
96
- throw new Error("API route configuration changed; reopen the menu before saving.");
97
- }
98
- await fs.writeFile(temp, serialized, { mode: 0o600, flag: "wx" });
99
- await withRetry(() => fs.rename(temp, path), {
100
- maxAttempts: 6,
101
- backoffMs: 25,
102
- });
103
- }
104
- finally {
105
- await withRetry(() => fs.unlink(temp), {
106
- maxAttempts: 6,
107
- backoffMs: 25,
108
- }).catch((error) => {
109
- if (error.code !== "ENOENT")
110
- throw error;
111
- });
108
+ signal?.throwIfAborted();
109
+ let current;
110
+ try {
111
+ current = await loadApiRoutes(path);
112
+ }
113
+ catch (error) {
114
+ // Only explicit replacement may repair parsed-content corruption. CAS,
115
+ // oversized reads and I/O failures must not be treated as an empty store.
116
+ if (expected !== undefined || !(error instanceof InvalidApiRouteContentsError))
117
+ throw error;
118
+ current = [];
119
+ }
120
+ await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
121
+ const temp = tempPathFor(path);
122
+ try {
123
+ if (expected &&
124
+ JSON.stringify(current) !==
125
+ JSON.stringify(validate({ version: 1, routes: expected }))) {
126
+ throw new Error("API route configuration changed; reopen the menu before saving.");
112
127
  }
113
- }));
128
+ const owned = validated.map(route => {
129
+ const old = current.find(value => value.id === route.id);
130
+ const policy = (value) => JSON.stringify([
131
+ value.kind, value.apiKey, value.enabled, value.probeCapabilities,
132
+ value.accessPrograms, value.modelAccessPrograms,
133
+ // Visibility is a selection set; menu ordering is not a policy edit.
134
+ ...(discoveryExpansion ? [] : [[...new Set(value.visibleModels)].sort()]),
135
+ ]);
136
+ const sameLifetime = old && policy(old) === policy(route);
137
+ const withoutLegacy = { ...route };
138
+ delete withoutLegacy.legacyCapabilityCache;
139
+ return { ...withoutLegacy, capabilityEpoch: sameLifetime ? old.capabilityEpoch ?? randomUUID() : randomUUID(),
140
+ ...(sameLifetime && (!old.capabilityEpoch || old.legacyCapabilityCache === true) ? { legacyCapabilityCache: true } : {}) };
141
+ });
142
+ serialized = JSON.stringify({ version: 1, routes: owned }) + "\n";
143
+ if (Buffer.byteLength(serialized) > 1024 * 1024)
144
+ throw Error("API route configuration is too large");
145
+ await fs.writeFile(temp, serialized, { mode: 0o600, flag: "wx" });
146
+ signal?.throwIfAborted();
147
+ await withRetry(() => fs.rename(temp, path), {
148
+ maxAttempts: 6,
149
+ backoffMs: 25,
150
+ });
151
+ onSaved?.(owned);
152
+ }
153
+ finally {
154
+ await withRetry(() => fs.unlink(temp), {
155
+ maxAttempts: 6,
156
+ backoffMs: 25,
157
+ }).catch((error) => {
158
+ if (error.code !== "ENOENT")
159
+ throw error;
160
+ });
161
+ }
114
162
  }
115
163
  /** Persist a discovery baseline and new GPT text IDs without undoing menu edits.
116
164
  * Callers supply IDs already filtered for the Responses endpoint family. */
@@ -122,7 +170,8 @@ export function updateApiModelDiscovery(route, models, path = getApiRoutesPath()
122
170
  }
123
171
  async function updateApiModelDiscoverySerial(route, models, path = getApiRoutesPath()) {
124
172
  const routes = await loadApiRoutes(path);
125
- const current = routes.find((r) => r.id === route.id && r.apiKey === route.apiKey && r.kind === route.kind);
173
+ const current = routes.find((r) => r.id === route.id && r.apiKey === route.apiKey && r.kind === route.kind &&
174
+ (!route.capabilityEpoch || route.capabilityEpoch === r.capabilityEpoch));
126
175
  if (!current)
127
176
  return { ...route, enabled: false, visibleModels: [] };
128
177
  if (!current.enabled)
@@ -139,7 +188,26 @@ async function updateApiModelDiscoverySerial(route, models, path = getApiRoutesP
139
188
  knownModels: [...known],
140
189
  visibleModels: [...visible],
141
190
  };
191
+ let persisted = updated;
142
192
  if (JSON.stringify(updated) !== JSON.stringify(current))
143
- await saveApiRoutes(routes.map((r) => (r.id === current.id ? updated : r)), path, routes);
144
- return updated;
193
+ await saveApiRoutesInternal(routes.map((r) => (r.id === current.id ? updated : r)), path, routes, true, undefined, values => { const saved = values.find(value => value.id === updated.id); if (saved)
194
+ persisted = saved; });
195
+ return persisted;
196
+ }
197
+ /** Migrate legacy ownership before taking any capability lease or cache lock. */
198
+ export async function ensureApiRouteCapabilityEpoch(route, signal) {
199
+ return withStorageLock(() => withFileTransactionLock(getApiRoutesPath(), async () => {
200
+ signal?.throwIfAborted();
201
+ const current = await loadApiRoutes();
202
+ const match = current.find(value => value.id === route.id && value.kind === route.kind && value.apiKey === route.apiKey);
203
+ if (!match || (route.capabilityEpoch && route.capabilityEpoch !== match.capabilityEpoch))
204
+ return undefined;
205
+ if (match.capabilityEpoch)
206
+ return match;
207
+ let migrated;
208
+ await saveApiRoutesLocked(current, getApiRoutesPath(), current, false, signal, values => {
209
+ migrated = values.find(value => value.id === route.id);
210
+ });
211
+ return migrated;
212
+ }, { signal }));
145
213
  }
@@ -2,7 +2,7 @@ import { type ApiRouteCredential } from "../api-route-store.js";
2
2
  import { type MenuItem } from "../ui/select.js";
3
3
  interface MenuDeps {
4
4
  load: () => Promise<ApiRouteCredential[]>;
5
- save: (routes: ApiRouteCredential[], expected: ApiRouteCredential[]) => Promise<void>;
5
+ save: (routes: ApiRouteCredential[], expected: ApiRouteCredential[]) => Promise<ApiRouteCredential[]>;
6
6
  select: (items: MenuItem<string>[], message: string) => Promise<string | null>;
7
7
  text: (message: string) => Promise<string>;
8
8
  secret: () => Promise<string>;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { Writable } from "node:stream";
4
- import { loadApiRoutes, saveApiRoutes, } from "../api-route-store.js";
4
+ import { loadApiRoutes, saveApiRoutesSnapshot, } from "../api-route-store.js";
5
5
  import { ApiModelRuntime } from "../runtime/api-model-runtime.js";
6
6
  import { select } from "../ui/select.js";
7
7
  async function ask(message, secret = false) {
@@ -35,7 +35,7 @@ async function ask(message, secret = false) {
35
35
  }
36
36
  const defaults = {
37
37
  load: loadApiRoutes,
38
- save: (routes, expected) => saveApiRoutes(routes, undefined, expected),
38
+ save: (routes, expected) => saveApiRoutesSnapshot(routes, undefined, expected),
39
39
  select: (items, message) => select(items, { message, allowEscape: true }),
40
40
  text: (message) => ask(message),
41
41
  secret: () => ask("API key (hidden): ", true),
@@ -189,8 +189,7 @@ export async function runApiLoginMenu(overrides = {}) {
189
189
  }
190
190
  if (action !== "models") {
191
191
  const next = routes.map((r) => (r.id === route?.id ? route : r));
192
- await d.save(next, routes);
193
- routes = next;
192
+ routes = await d.save(next, routes);
194
193
  continue;
195
194
  }
196
195
  }
@@ -203,8 +202,7 @@ export async function runApiLoginMenu(overrides = {}) {
203
202
  const next = routes.some((r) => r.id === edited.id)
204
203
  ? routes.map((r) => (r.id === edited.id ? edited : r))
205
204
  : [...routes, edited];
206
- await d.save(next, routes);
207
- routes = next;
205
+ routes = await d.save(next, routes);
208
206
  d.log("Saved. Choose an API or ZDR model explicitly in Codex. Desktop login is unchanged.");
209
207
  }
210
208
  catch (error) {
@@ -4,7 +4,7 @@ import { loadAccountPolicyStore } from "../../account-policy.js";
4
4
  import { type ModelInventory } from "../../runtime/model-discovery-status.js";
5
5
  import type { ModelFamily } from "../../prompts/codex.js";
6
6
  import type { QuotaCacheData } from "../../quota-cache.js";
7
- import type { AppBindRouterStatus } from "../../runtime/app-bind.js";
7
+ import { type AppBindRouterStatus } from "../../runtime/app-bind.js";
8
8
  import { type RuntimeAccountSignal } from "../../runtime/runtime-current-account.js";
9
9
  import type { RuntimeObservabilitySnapshot } from "../../runtime/runtime-observability.js";
10
10
  import type { AccountStorageV3, StorageHealthSummary } from "../../storage.js";
@@ -6,10 +6,12 @@ import { styleReportText as paint } from "../../ui/format.js";
6
6
  import { inferenceAccountKey } from "../../runtime/inference-activity.js";
7
7
  import { modelScopeId } from "../../runtime/workspace-model-scopes.js";
8
8
  import { loadAccountPolicyStore, getAccountPolicyKey } from "../../account-policy.js";
9
+ import { getSchedulingStrategy, loadPluginConfig } from "../../config.js";
9
10
  import { formatModelInventory } from "../../runtime/model-discovery-status.js";
10
11
  import { AUTH_INVALIDATION_MARKER, formatAccountLabel, formatCooldown, formatWaitTime, formatWorkspaceLines, } from "../../accounts.js";
11
12
  import { evaluateForecastAccounts, recommendForecastAccount, } from "../../forecast.js";
12
13
  import { findQuotaCacheEntryForAccount, isQuotaCacheEntryExhausted, } from "../../quota-readiness.js";
14
+ import { formatWebsocketDisconnect } from "../../runtime/app-bind.js";
13
15
  import { resolveAccountCurrentMarkers, resolveRuntimeCurrentAccount, } from "../../runtime/runtime-current-account.js";
14
16
  import { isRateLimitedMarker } from "../rate-limit-markers.js";
15
17
  function isRestoreReason(value) {
@@ -86,6 +88,10 @@ export async function runStatusCommand(deps) {
86
88
  if (apiConfigurationUnavailable && !deps.json)
87
89
  logInfo("API configuration unavailable; subscription status remains available.");
88
90
  const modelInventory = await deps.loadModelInventory?.();
91
+ const appBindStatus = await deps.loadAppBindStatus?.() ?? null;
92
+ const disconnectLine = formatWebsocketDisconnect(appBindStatus?.lastWebsocketDisconnect);
93
+ if (!deps.json && disconnectLine)
94
+ logInfo(paint(disconnectLine, "warning"));
89
95
  const resetCredits = await deps.loadResetCreditState?.().catch(() => null);
90
96
  const resetSnapshot = (account) => { const target = resetTargetForStoredAccount(account); return target ? resetCredits?.snapshots[target.key] : undefined; };
91
97
  let policiesUnavailable = false;
@@ -111,6 +117,7 @@ export async function runStatusCommand(deps) {
111
117
  totalAccountCount: 0,
112
118
  apiAccounts: [],
113
119
  modelInventory: modelInventory ?? null,
120
+ lastWebsocketDisconnect: appBindStatus?.lastWebsocketDisconnect ?? null,
114
121
  // Emit the same keys the populated branch does (as null) so a
115
122
  // --json consumer sees one stable shape regardless of account count.
116
123
  activeIndex: null,
@@ -172,17 +179,26 @@ export async function runStatusCommand(deps) {
172
179
  }
173
180
  }
174
181
  const appHelperStatus = deps.loadAppHelperStatus?.() ?? null;
175
- const [runtimeSnapshot, appBindStatus] = await Promise.all([
176
- deps.loadRuntimeObservabilitySnapshot?.() ?? Promise.resolve(null),
177
- deps.loadAppBindStatus?.() ?? Promise.resolve(null),
178
- ]);
182
+ const runtimeSnapshot = await deps.loadRuntimeObservabilitySnapshot?.() ?? null;
179
183
  const quotaPreferences = forecastQuotas.map(quota => subscriptionQuotaPreference(quota, now));
180
184
  const automaticOrder = new Map();
181
185
  if (appBindStatus?.nativeOpenai) {
182
- const candidates = storage.accounts.map((account, index) => ({ account, index, quota: quotaPreferences[index] ?? subscriptionQuotaPreference(null, now), tier: accountPolicies.accounts[getAccountPolicyKey(account, index)]?.priority ?? 1 }))
183
- .filter(a => forecastResults[a.index]?.availability === "ready" && !a.quota.exhausted);
184
- candidates.sort((a, b) => Number(usesSubscriptionReserve(a.quota)) - Number(usesSubscriptionReserve(b.quota)) ||
185
- a.tier - b.tier || compareSubscriptionQuota(a.quota, b.quota) || Number(b.index === activeIndex) - Number(a.index === activeIndex) || a.index - b.index);
186
+ const schedulingStrategy = appBindStatus.schedulingStrategy ?? getSchedulingStrategy(loadPluginConfig());
187
+ let candidates = storage.accounts.map((account, index) => ({ account, index, quota: quotaPreferences[index] ?? subscriptionQuotaPreference(null, now), tier: accountPolicies.accounts[getAccountPolicyKey(account, index)]?.priority ?? 1 }));
188
+ const useSavedOrder = candidates.some(account => account.tier !== 1);
189
+ candidates = candidates.filter(a => forecastResults[a.index]?.availability === "ready" && !a.quota.exhausted);
190
+ candidates.sort((a, b) => {
191
+ const tierOrder = Number(usesSubscriptionReserve(a.quota)) - Number(usesSubscriptionReserve(b.quota)) || a.tier - b.tier;
192
+ if (tierOrder)
193
+ return tierOrder;
194
+ if (schedulingStrategy === "sequential") {
195
+ if (useSavedOrder)
196
+ return a.index - b.index;
197
+ const count = storage.accounts.length;
198
+ return (a.index - activeIndex + count) % count - (b.index - activeIndex + count) % count;
199
+ }
200
+ return compareSubscriptionQuota(a.quota, b.quota) || Number(b.index === activeIndex) - Number(a.index === activeIndex) || a.index - b.index;
201
+ });
186
202
  // A switch pin is strict in native mode too: no other account is tried, so none gets an order.
187
203
  const pinned = typeof storage.pinnedAccountIndex === "number" ? candidates.filter(a => a.index === storage.pinnedAccountIndex) : candidates;
188
204
  pinned.forEach((a, index) => { automaticOrder.set(a.index, index + 1); });
@@ -260,6 +276,7 @@ export async function runStatusCommand(deps) {
260
276
  recommendationReason: recommendation.reason,
261
277
  runtimeInUseIndex: runtimeCurrent ? runtimeCurrent.index : null,
262
278
  modelInventory: modelInventory ?? null,
279
+ lastWebsocketDisconnect: appBindStatus?.lastWebsocketDisconnect ?? null,
263
280
  accounts,
264
281
  lastRequestedWorkspace: runtimeSnapshot?.lastRequestedWorkspaceId ? {
265
282
  id: runtimeSnapshot.lastRequestedWorkspaceId,
@@ -345,7 +362,7 @@ export async function runStatusCommand(deps) {
345
362
  if (appBindStatus?.nativeOpenai) {
346
363
  const preference = quotaPreferences[i];
347
364
  const order = automaticOrder.get(i);
348
- const reset = preference?.resetAtMs ? `; limiting window resets in ${formatWaitTime(Math.max(0, preference.resetAtMs - now))}` : "; reset ordering unknown; run check";
365
+ const reset = preference?.resetAtMs ? `; limiting window resets in ${formatWaitTime(Math.max(0, preference.resetAtMs - now))}` : "; quota reset unknown; run check";
349
366
  const reserve = usesSubscriptionReserve(preference) ? `; ${SUBSCRIPTION_RESERVE_PERCENT}% reserve (last resort)` : "";
350
367
  const pinnedElsewhere = typeof storage.pinnedAccountIndex === "number" && storage.pinnedAccountIndex !== i;
351
368
  logInfo(paint(` automatic order: ${order ? `#${order}` : pinnedElsewhere ? `none (account ${(storage.pinnedAccountIndex ?? 0) + 1} is pinned)` : "unavailable"}${reset}${reserve}`, reserve ? "warning" : "accent"));
@@ -280,7 +280,13 @@ const runListOrStatusCommand = (rest) => runStatusCommand({
280
280
  formatRateLimitEntry,
281
281
  loadRuntimeObservabilitySnapshot: loadPersistedRuntimeObservabilitySnapshot,
282
282
  loadAppBindStatus: async () => getAppBindStatus()
283
- .then((status) => (status.running && status.router ? { ...status.router, nativeOpenai: status.state?.nativeOpenai === true } : null))
283
+ .then((status) => status.router ? {
284
+ ...status.router,
285
+ // Keep historical diagnostics, but never revive a crashed router's
286
+ // persisted "running" marker or its account-selection signal.
287
+ state: status.running ? status.router.state : "stopped",
288
+ nativeOpenai: status.running && status.state?.nativeOpenai === true,
289
+ } : null)
284
290
  .catch(() => null),
285
291
  loadAppHelperStatus: readAppRuntimeHelperAccountSignal,
286
292
  loadQuotaCache,
@@ -48,6 +48,7 @@ export declare const URL_PATHS: {
48
48
  readonly MODELS: "/models";
49
49
  readonly RESPONSES: "/responses";
50
50
  readonly CODEX_RESPONSES: "/codex/responses";
51
+ readonly TRANSCRIBE: "/transcribe";
51
52
  };
52
53
  /** JWT claim path for ChatGPT account ID */
53
54
  export declare const JWT_CLAIM_PATH: "https://api.openai.com/auth";
@@ -48,6 +48,7 @@ export const URL_PATHS = {
48
48
  MODELS: "/models",
49
49
  RESPONSES: "/responses",
50
50
  CODEX_RESPONSES: "/codex/responses",
51
+ TRANSCRIBE: "/transcribe",
51
52
  };
52
53
  /** JWT claim path for ChatGPT account ID */
53
54
  export const JWT_CLAIM_PATH = "https://api.openai.com/auth";
@@ -38,6 +38,7 @@ export declare function buildVisibleModelUnion(catalogs: RouteCatalog[]): RouteM
38
38
  /** Whitelist capability metadata; never persist model instructions or arbitrary fields. */
39
39
  export declare function modelEntitlements(model: RouteModel): {
40
40
  model: string;
41
+ programCheckedAt?: number | undefined;
41
42
  checkedAt?: number | undefined;
42
43
  probes?: {
43
44
  [k: string]: "downgraded" | "unsupported" | "unverified" | "verified";
@@ -129,12 +129,13 @@ export function modelEntitlements(model) {
129
129
  const safe = (v) => typeof v === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(v);
130
130
  return {
131
131
  model: model.slug,
132
+ ...(typeof model.program_checked_at === "number" && Number.isFinite(model.program_checked_at) && model.program_checked_at >= 0 && model.program_checked_at <= 8.64e15 ? { programCheckedAt: model.program_checked_at } : {}),
132
133
  ...(typeof model.capability_checked_at === "number"
133
134
  ? { checkedAt: model.capability_checked_at }
134
135
  : {}),
135
136
  ...(isRecord(model.capability_probe_status)
136
137
  ? {
137
- probes: Object.fromEntries(Object.entries(model.capability_probe_status).filter((entry) => /^(responses|fast|ultrafast|effort:(none|minimal|low|medium|high|xhigh|max|ultra))$/.test(entry[0]) &&
138
+ probes: Object.fromEntries(Object.entries(model.capability_probe_status).filter((entry) => /^(responses|fast|ultrafast|effort:(none|minimal|low|medium|high|xhigh|max|ultra)|program:cyber:(standard|daybreak_blue|daybreak_red))$/.test(entry[0]) &&
138
139
  [
139
140
  "verified",
140
141
  "unsupported",
@@ -47,6 +47,7 @@ export declare class AccountModelCatalog {
47
47
  models: string[];
48
48
  entitlements: {
49
49
  model: string;
50
+ programCheckedAt?: number | undefined;
50
51
  checkedAt?: number | undefined;
51
52
  probes?: {
52
53
  [k: string]: "downgraded" | "unsupported" | "unverified" | "verified";
@@ -3,6 +3,9 @@ import { type RouteModel } from "../model-route-policy.js";
3
3
  /** Read the explicit model-specific statement, never surrounding examples or another model's settings. */
4
4
  export declare function parseApiReasoningDocumentation(id: string, text: string): string[];
5
5
  export type ProbeResult = {
6
+ ownerEpoch?: string;
7
+ programCheckedAt?: number;
8
+ programRetry?: boolean;
6
9
  at: number;
7
10
  levels: string[];
8
11
  tiers: string[];
@@ -11,19 +14,25 @@ export type ProbeResult = {
11
14
  export declare class ApiModelCapabilities {
12
15
  private readonly fetchImpl;
13
16
  private readonly now;
17
+ private readonly probeSlot?;
18
+ private readonly documentationSource?;
19
+ private readonly programRefreshBefore?;
14
20
  private activeProbes;
15
21
  private readonly probeWaiters;
16
22
  private withProbeSlot;
17
23
  private readonly inFlight;
18
24
  private readonly probes;
19
25
  private readonly cache;
20
- constructor(fetchImpl?: typeof fetch, now?: () => number);
26
+ constructor(fetchImpl?: typeof fetch, now?: () => number, probeSlot?: (<T>(operation: () => Promise<T>) => Promise<T>) | undefined, documentationSource?: ApiModelCapabilities | undefined, programRefreshBefore?: number | undefined);
21
27
  /** Probe results keyed by an opaque hash, for a fresh CLI process to honour the 15-minute cache. */
22
28
  exportProbes(): Array<[string, ProbeResult]>;
23
29
  importProbes(entries: Array<[string, ProbeResult]>): void;
24
30
  reasoning(id: string, refresh?: boolean): Promise<string[]>;
25
31
  private documentation;
32
+ private programsCoverCheck;
33
+ private freshProbe;
34
+ private reconcileReasoning;
26
35
  private probe;
27
36
  private runProbe;
28
- enrich(models: RouteModel[], refresh?: boolean, route?: ApiRouteCredential, forceProbes?: boolean): Promise<RouteModel[]>;
37
+ enrich(models: RouteModel[], refresh?: boolean, route?: ApiRouteCredential, forceProbes?: boolean, cachedProbesOnly?: Array<[string, ProbeResult]>): Promise<RouteModel[]>;
29
38
  }