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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +5 -4
- package/dist/lib/accounts.d.ts +5 -0
- package/dist/lib/accounts.js +33 -4
- package/dist/lib/api-route-store.d.ts +6 -0
- package/dist/lib/api-route-store.js +98 -30
- package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
- package/dist/lib/codex-manager/api-login-menu.js +4 -6
- package/dist/lib/codex-manager/commands/status.d.ts +1 -1
- package/dist/lib/codex-manager/commands/status.js +26 -9
- package/dist/lib/codex-manager.js +7 -1
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +1 -0
- package/dist/lib/model-route-policy.d.ts +1 -0
- package/dist/lib/model-route-policy.js +2 -1
- package/dist/lib/runtime/account-model-catalog.d.ts +1 -0
- package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
- package/dist/lib/runtime/api-model-capabilities.js +155 -24
- package/dist/lib/runtime/app-bind.d.ts +4 -0
- package/dist/lib/runtime/app-bind.js +32 -1
- package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
- package/dist/lib/runtime/automatic-api-checks.js +188 -0
- package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
- package/dist/lib/runtime/model-discovery-status.js +64 -49
- package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
- package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
- package/dist/lib/runtime/responses-websocket.d.ts +18 -5
- package/dist/lib/runtime/responses-websocket.js +33 -11
- package/dist/lib/runtime/rotation-account-selection.d.ts +20 -1
- package/dist/lib/runtime/rotation-account-selection.js +94 -4
- package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
- package/dist/lib/runtime/rotation-proxy-state.js +376 -4
- package/dist/lib/runtime/rotation-server-types.d.ts +3 -0
- package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
- package/dist/lib/runtime/rotation-token-refresh.js +218 -27
- package/dist/lib/runtime-rotation-proxy.js +571 -96
- package/dist/lib/storage/credential-sidecars.js +34 -2
- package/dist/lib/storage/file-lock.d.ts +2 -0
- package/dist/lib/storage/file-lock.js +44 -30
- package/dist/lib/usage/ledger.js +1 -0
- package/dist/lib/usage/redaction.js +1 -0
- package/dist/lib/usage/types.d.ts +1 -1
- package/package.json +1 -1
- package/scripts/codex-app-router.js +42 -14
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codex-multi-auth",
|
|
3
|
-
"version": "2.
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
package/dist/lib/accounts.d.ts
CHANGED
|
@@ -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);
|
package/dist/lib/accounts.js
CHANGED
|
@@ -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
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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
|
-
|
|
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
|
|
88
|
+
throw new InvalidApiRouteContentsError();
|
|
82
89
|
}
|
|
83
90
|
}
|
|
84
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
144
|
-
|
|
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<
|
|
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,
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
183
|
-
|
|
184
|
-
candidates.
|
|
185
|
-
|
|
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
|
|
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) =>
|
|
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,
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -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";
|
package/dist/lib/constants.js
CHANGED
|
@@ -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
|
}
|