@bitkyc08/opencodex 2.7.30 → 2.7.33-preview.20260722

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 (59) hide show
  1. package/README.ja.md +438 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +480 -0
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +18 -1
  7. package/gui/dist/assets/index-B79f-04T.js +52 -0
  8. package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/anthropic.ts +17 -2
  12. package/src/adapters/cursor/discovery.ts +2 -2
  13. package/src/adapters/google-tool-schema.ts +4 -0
  14. package/src/adapters/google.ts +17 -2
  15. package/src/adapters/openai-chat.ts +36 -1
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +12 -4
  18. package/src/cli/account-api.ts +4 -2
  19. package/src/cli/account-extended.ts +34 -0
  20. package/src/cli/account.ts +3 -1
  21. package/src/cli/claude.ts +6 -1
  22. package/src/cli/help.ts +25 -4
  23. package/src/cli/init.ts +38 -3
  24. package/src/cli/models.ts +206 -7
  25. package/src/codex/auth-api.ts +45 -3
  26. package/src/codex/catalog.ts +105 -12
  27. package/src/codex/routing.ts +85 -4
  28. package/src/combos/index.ts +1 -1
  29. package/src/combos/request.ts +26 -4
  30. package/src/combos/types.ts +4 -4
  31. package/src/config.ts +60 -3
  32. package/src/lib/upstream-retry.ts +21 -0
  33. package/src/lib/winsw.ts +7 -1
  34. package/src/oauth/anthropic.ts +23 -1
  35. package/src/oauth/github-copilot.ts +1 -0
  36. package/src/oauth/index.ts +88 -8
  37. package/src/oauth/kiro.ts +12 -1
  38. package/src/oauth/local-token-detect.ts +3 -0
  39. package/src/oauth/store.ts +43 -0
  40. package/src/oauth/types.ts +3 -1
  41. package/src/providers/antigravity-models.ts +123 -14
  42. package/src/providers/api-keys.ts +12 -0
  43. package/src/providers/openrouter-routing.ts +102 -0
  44. package/src/providers/registry.ts +41 -17
  45. package/src/router.ts +2 -1
  46. package/src/server/auth-cors.ts +5 -0
  47. package/src/server/index.ts +3 -3
  48. package/src/server/management-api.ts +143 -6
  49. package/src/server/relay.ts +31 -5
  50. package/src/server/request-log.ts +12 -0
  51. package/src/server/responses.ts +127 -31
  52. package/src/service.ts +12 -3
  53. package/src/types.ts +44 -3
  54. package/src/update/index.ts +19 -2
  55. package/src/update/job.ts +13 -3
  56. package/src/usage/expected-prices.ts +22 -9
  57. package/src/usage/summary.ts +43 -0
  58. package/gui/dist/assets/index-B-UauL1p.css +0 -1
  59. package/gui/dist/assets/index-avcinRsG.js +0 -40
package/src/cli/help.ts CHANGED
@@ -94,9 +94,18 @@ const helpEntries: Record<string, HelpEntry> = {
94
94
  ],
95
95
  },
96
96
  models: {
97
- usage: "ocx models [--provider <name>] [--json]",
98
- summary: "List available models from configured providers.",
99
- details: ["Shows statically configured models. Providers with liveModels may have additional models at runtime."],
97
+ usage: "ocx models [list] [--provider <name>] [--json] | add <provider> <modelId> [opts] | remove <id|provider/modelId> [--yes] | list-custom [--json]",
98
+ summary: "List models and manage custom (manually registered) models.",
99
+ details: [
100
+ "List available models from static config with no subcommand (liveModels may add more at runtime).",
101
+ "add: register a model the provider catalog does not advertise yet.",
102
+ " --display-name <name> Human label (no slashes).",
103
+ " --context-window <tokens> e.g. 200000.",
104
+ " --modalities text,image Comma-separated (text|image|audio).",
105
+ "remove: delete a custom model by UUID or <provider>/<modelId>.",
106
+ "list-custom: show all custom models.",
107
+ "Changes apply immediately to a running proxy (catalog sync).",
108
+ ],
100
109
  },
101
110
  claude: {
102
111
  usage: "ocx claude [claude args...]",
@@ -113,6 +122,17 @@ const helpEntries: Record<string, HelpEntry> = {
113
122
  usage: "ocx restart",
114
123
  summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.",
115
124
  },
125
+ v2: {
126
+ usage: "ocx v2 <status|on|off|mode <v1|default|v2>|threads <n>>",
127
+ summary: "Toggle the Codex multi_agent_v2 feature (multi-agent surface).",
128
+ details: [
129
+ "status Show flag, multi-agent mode, and thread limit.",
130
+ "on | off Enable/disable multi_agent_v2 (catalog resyncs).",
131
+ "mode <v1|default|v2> Force all models to one surface, or respect upstream pins.",
132
+ "threads <n> Set max_concurrent_threads_per_session (integer >= 1).",
133
+ "Flips preserve the active thread limit while moving between v1/v2 modes.",
134
+ ],
135
+ },
116
136
  health: {
117
137
  usage: "ocx health [--json]",
118
138
  summary: "Check proxy health. Exits 0 if healthy, 1 otherwise.",
@@ -156,10 +176,11 @@ Usage:
156
176
  ocx gui Open the opencodex dashboard
157
177
  ocx update [--tag <tag>] Update opencodex (keeps preview installs on @preview)
158
178
  ocx restart Stop and restart the proxy
179
+ ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|threads)
159
180
  ocx health [--json] Check proxy health (exit 0=healthy, 1=not)
160
181
  ocx provider <sub> Manage providers (list|add|remove|show|set-default)
161
182
  ocx account <sub> Accounts/keys (list|current|use|refresh|auto-switch|remove|add-key)
162
- ocx models [--json] List available models from configured providers
183
+ ocx models <sub> List models; manage custom models (add|remove|list-custom)
163
184
  ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on)
164
185
  ocx help [command] Show help
165
186
  ocx --version | -v Print version
package/src/cli/init.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as readline from "node:readline";
2
+ import { constants as fsConstants, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
2
3
  import { injectCodexConfig } from "../codex/inject";
3
- import { getDefaultConfig, isValidProviderName, saveConfig } from "../config";
4
+ import { classifyOpenAiTierBackup, getConfigPath, getDefaultConfig, isValidProviderName, saveConfig } from "../config";
4
5
  import { enrichProviderFromCatalog } from "../oauth/key-providers";
5
6
  import { deriveInitProviders } from "../providers/derive";
6
7
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -42,7 +43,7 @@ const KIND_HEADING: Record<InitKind, string> = {
42
43
  };
43
44
 
44
45
  function printMenu(providers: InitProvider[]): void {
45
- console.log("Available providers:");
46
+ console.log("Choose your default provider (you can add more later):");
46
47
  let lastKind: InitKind | null = null;
47
48
  providers.forEach((p, i) => {
48
49
  if (p.kind !== lastKind) { console.log(`\n ${KIND_HEADING[p.kind]}:`); lastKind = p.kind; }
@@ -53,6 +54,33 @@ function printMenu(providers: InitProvider[]): void {
53
54
 
54
55
  const envKeyFor = (id: string) => `${id.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_API_KEY`;
55
56
 
57
+ /** Post-init cleanup of `.pre-openai-tiers-v2.bak` with rollback preservation (issue #257). */
58
+ export function cleanupOpenAiTierBackupAfterInit(configPath = getConfigPath()): void {
59
+ const backup = `${configPath}.pre-openai-tiers-v2.bak`;
60
+ try {
61
+ if (!existsSync(backup)) return;
62
+ if (classifyOpenAiTierBackup(readFileSync(backup)) === "stale") {
63
+ unlinkSync(backup);
64
+ return;
65
+ }
66
+ // Publish the preserved snapshot with a no-replace copy (COPYFILE_EXCL) so a
67
+ // destination collision (frozen/rolled-back clock, pre-created file) can never
68
+ // silently overwrite another rollback snapshot; retry with a sequence suffix.
69
+ for (let attempt = 0; attempt < 16; attempt++) {
70
+ const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`;
71
+ try {
72
+ copyFileSync(backup, preserved, fsConstants.COPYFILE_EXCL);
73
+ } catch (error) {
74
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
75
+ throw error;
76
+ }
77
+ unlinkSync(backup);
78
+ console.warn(`⚠️ Kept your pre-migration config rollback snapshot at ${preserved}`);
79
+ return;
80
+ }
81
+ } catch { /* cleanup is best-effort; never block init on backup housekeeping */ }
82
+ }
83
+
56
84
  export async function runInit(): Promise<void> {
57
85
  const prompt = createPrompt();
58
86
  console.log("\n🔧 opencodex (ocx) setup\n");
@@ -60,7 +88,7 @@ export async function runInit(): Promise<void> {
60
88
  const providers = buildInitProviders();
61
89
  printMenu(providers);
62
90
 
63
- const choice = await prompt.ask("\nSelect provider (number): ");
91
+ const choice = await prompt.ask("\nSelect default provider (number): ");
64
92
  const idx = parseInt(choice, 10) - 1;
65
93
 
66
94
  let providerName: string;
@@ -136,6 +164,13 @@ export async function runInit(): Promise<void> {
136
164
  };
137
165
 
138
166
  saveConfig(config);
167
+ // Init writes a fresh config, so a stale pre-migration backup from a previous
168
+ // installation would make the next `ocx start` crash on a stale-backup
169
+ // collision (issue #257). But only a STALE backup (unparseable, or already a
170
+ // post-migration v2 snapshot) may be deleted; a backup that still parses as a
171
+ // valid pre-migration (v1) config is a user-intentional rollback point and is
172
+ // preserved by renaming it out of the collision path (sol review 260722).
173
+ cleanupOpenAiTierBackupAfterInit();
139
174
  console.log(`\n✅ Config saved to ~/.opencodex/config.json`);
140
175
  if (oauthHint) console.log(`🔐 Authenticate this provider with: ocx login ${providerName}`);
141
176
 
package/src/cli/models.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  /**
2
- * `ocx models` subcommand — list available models from configured providers.
3
- *
4
- * Usage:
5
- * ocx models [--provider <name>] [--json]
2
+ * `ocx models` subcommand — list configured models and manage custom models.
6
3
  */
7
- import { hasOwnProvider, loadConfig } from "../config";
8
- import type { OcxConfig } from "../types";
4
+ import { randomUUID } from "node:crypto";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { syncModelsToCodex } from "../codex/sync";
7
+ import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
8
+ import { routedSlug } from "../providers/slug-codec";
9
+ import { findLiveProxy } from "../server/proxy-liveness";
10
+ import type { OcxConfig, OcxCustomModel } from "../types";
11
+
12
+ const ADD_USAGE = "Usage: ocx models add <provider> <modelId> [--display-name <name>] [--context-window <tokens>] [--modalities text,image,audio]";
13
+ const REMOVE_USAGE = "Usage: ocx models remove <customId|provider/modelId> [--yes]";
14
+ const LIST_CUSTOM_USAGE = "Usage: ocx models list-custom [--json]";
15
+ const ALLOWED_MODALITIES = new Set(["text", "image", "audio"]);
9
16
 
10
17
  interface ModelEntry {
11
18
  provider: string;
@@ -75,7 +82,175 @@ function consumeFlagValue(args: string[], flag: string): string | undefined {
75
82
  return value;
76
83
  }
77
84
 
78
- export function handleModels(args: string[]): void {
85
+ function fail(message: string, usage?: string): never {
86
+ console.error(`Error: ${message}`);
87
+ if (usage) console.error(usage);
88
+ process.exit(1);
89
+ }
90
+
91
+ function rejectUnexpectedArgs(args: string[], usage: string): void {
92
+ if (args.length === 0) return;
93
+ const unknown = args.filter(arg => arg.startsWith("-"));
94
+ fail(
95
+ unknown.length > 0
96
+ ? `Unknown flag(s): ${unknown.join(", ")}`
97
+ : `Unexpected argument(s): ${args.join(", ")}`,
98
+ usage,
99
+ );
100
+ }
101
+
102
+ async function syncCustomModelsIfLive(): Promise<void> {
103
+ const live = await findLiveProxy();
104
+ if (!live) return;
105
+ await syncModelsToCodex(live.port).catch(error => {
106
+ console.error(`Warning: custom model saved, but catalog sync failed: ${error instanceof Error ? error.message : String(error)}`);
107
+ });
108
+ }
109
+
110
+ async function handleCustomAdd(args: string[]): Promise<void> {
111
+ const rest = [...args];
112
+ const provider = rest.shift()?.trim() ?? "";
113
+ const modelId = rest.shift()?.trim() ?? "";
114
+ const displayNameValue = consumeFlagValue(rest, "--display-name");
115
+ const contextWindowValue = consumeFlagValue(rest, "--context-window");
116
+ const modalitiesValue = consumeFlagValue(rest, "--modalities");
117
+ rejectUnexpectedArgs(rest, ADD_USAGE);
118
+
119
+ if (!provider || !modelId) fail("provider and modelId are required", ADD_USAGE);
120
+ if (!isValidProviderName(provider)) fail(`invalid provider name "${provider}"`);
121
+ if (modelId.includes("/")) fail("modelId must not contain /");
122
+
123
+ const config = loadConfig();
124
+ if (!hasOwnProvider(config.providers, provider)) {
125
+ fail(`provider "${provider}" is not configured. See: ocx provider list`);
126
+ }
127
+
128
+ const displayName = displayNameValue?.trim() || undefined;
129
+ if (displayName?.includes("/")) fail("displayName must not contain /");
130
+
131
+ let contextWindow: number | undefined;
132
+ if (contextWindowValue !== undefined) {
133
+ contextWindow = Number(contextWindowValue);
134
+ if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
135
+ fail("context window must be a positive integer");
136
+ }
137
+ }
138
+
139
+ let inputModalities: string[] | undefined;
140
+ if (modalitiesValue !== undefined) {
141
+ inputModalities = modalitiesValue.split(",").map(value => value.trim());
142
+ const invalid = inputModalities.filter(value => !ALLOWED_MODALITIES.has(value));
143
+ if (inputModalities.length === 0 || invalid.length > 0) {
144
+ fail("modalities must be comma-separated values from text|image|audio");
145
+ }
146
+ inputModalities = [...new Set(inputModalities)];
147
+ }
148
+
149
+ const existing = config.customModels ?? [];
150
+ const slug = routedSlug(provider, modelId);
151
+ if (existing.some(model => routedSlug(model.provider, model.modelId) === slug)) {
152
+ fail(`custom model "${slug}" already exists`);
153
+ }
154
+
155
+ const entry: OcxCustomModel = {
156
+ id: randomUUID(),
157
+ provider,
158
+ modelId,
159
+ ...(displayName ? { displayName } : {}),
160
+ ...(contextWindow ? { contextWindow } : {}),
161
+ ...(inputModalities ? { inputModalities } : {}),
162
+ addedAt: new Date().toISOString(),
163
+ };
164
+ config.customModels = [...existing, entry];
165
+ saveConfig(config);
166
+ await syncCustomModelsIfLive();
167
+ console.log(`Added custom model ${slug} (${entry.id}).`);
168
+ }
169
+
170
+ async function confirmCustomRemoval(model: OcxCustomModel): Promise<boolean> {
171
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
172
+ fail("remove requires --yes in non-interactive mode");
173
+ }
174
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
175
+ try {
176
+ const answer = (await rl.question(`Remove custom model ${routedSlug(model.provider, model.modelId)}? [y/N] `)).trim().toLowerCase();
177
+ return answer === "y" || answer === "yes";
178
+ } finally {
179
+ rl.close();
180
+ }
181
+ }
182
+
183
+ async function handleCustomRemove(args: string[]): Promise<void> {
184
+ const rest = [...args];
185
+ const confirmed = consumeFlag(rest, "--yes");
186
+ const target = rest.shift()?.trim() ?? "";
187
+ rejectUnexpectedArgs(rest, REMOVE_USAGE);
188
+ if (!target) fail("custom model id or provider/modelId is required", REMOVE_USAGE);
189
+
190
+ const config = loadConfig();
191
+ const existing = config.customModels ?? [];
192
+ const index = target.includes("/")
193
+ ? existing.findIndex(model => routedSlug(model.provider, model.modelId) === target)
194
+ : existing.findIndex(model => model.id === target);
195
+ if (index === -1) fail(`custom model "${target}" not found`);
196
+
197
+ const model = existing[index];
198
+ if (!confirmed && !(await confirmCustomRemoval(model))) {
199
+ console.log("Cancelled.");
200
+ return;
201
+ }
202
+
203
+ const next = existing.filter((_, modelIndex) => modelIndex !== index);
204
+ config.customModels = next.length > 0 ? next : undefined;
205
+ saveConfig(config);
206
+ await syncCustomModelsIfLive();
207
+ console.log(`Removed custom model ${routedSlug(model.provider, model.modelId)}.`);
208
+ }
209
+
210
+ function customModelCells(model: OcxCustomModel): string[] {
211
+ return [
212
+ model.id.slice(0, 8),
213
+ model.modelId,
214
+ model.displayName ?? "-",
215
+ model.contextWindow ? `${Math.round(model.contextWindow / 1000)}k` : "-",
216
+ model.inputModalities?.join(",") ?? "-",
217
+ ];
218
+ }
219
+
220
+ function printCustomModelGroup(provider: string, models: OcxCustomModel[]): void {
221
+ const rows = models.map(customModelCells);
222
+ const headers = ["ID", "MODEL", "DISPLAY NAME", "CONTEXT", "MODALITIES"];
223
+ const widths = headers.map((header, column) => Math.max(header.length, ...rows.map(row => row[column].length)));
224
+ const line = (cells: string[]) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ");
225
+ console.log(`${provider}:`);
226
+ console.log(` ${line(headers)}`);
227
+ for (const row of rows) console.log(` ${line(row)}`);
228
+ console.log();
229
+ }
230
+
231
+ function handleCustomList(args: string[]): void {
232
+ const rest = [...args];
233
+ const wantsJson = consumeFlag(rest, "--json");
234
+ rejectUnexpectedArgs(rest, LIST_CUSTOM_USAGE);
235
+ const models = loadConfig().customModels ?? [];
236
+ if (wantsJson) {
237
+ console.log(JSON.stringify(models, null, 2));
238
+ return;
239
+ }
240
+ if (models.length === 0) {
241
+ console.log("No custom models registered.");
242
+ return;
243
+ }
244
+ const byProvider = new Map<string, OcxCustomModel[]>();
245
+ for (const model of models) {
246
+ const group = byProvider.get(model.provider) ?? [];
247
+ group.push(model);
248
+ byProvider.set(model.provider, group);
249
+ }
250
+ for (const [provider, providerModels] of byProvider) printCustomModelGroup(provider, providerModels);
251
+ }
252
+
253
+ function handleConfiguredModels(args: string[]): void {
79
254
  const restArgs = [...args];
80
255
  const wantsJson = consumeFlag(restArgs, "--json");
81
256
  const providerFilter = consumeFlagValue(restArgs, "--provider");
@@ -136,3 +311,27 @@ export function handleModels(args: string[]): void {
136
311
  console.log("* = default model for provider");
137
312
  console.log("Note: providers with liveModels may have additional models at runtime.");
138
313
  }
314
+
315
+ function runCustomCommand(command: Promise<void>): void {
316
+ command.catch(error => {
317
+ console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
318
+ process.exitCode = 1;
319
+ });
320
+ }
321
+
322
+ export function handleModels(args: string[]): void {
323
+ const [subcommand, ...rest] = args;
324
+ if (subcommand === "add") {
325
+ runCustomCommand(handleCustomAdd(rest));
326
+ return;
327
+ }
328
+ if (subcommand === "remove") {
329
+ runCustomCommand(handleCustomRemove(rest));
330
+ return;
331
+ }
332
+ if (subcommand === "list-custom") {
333
+ handleCustomList(rest);
334
+ return;
335
+ }
336
+ handleConfiguredModels(subcommand === "list" ? rest : args);
337
+ }
@@ -77,6 +77,7 @@ function poolAccountDto(
77
77
  return {
78
78
  id: account.id,
79
79
  email: maskEmail(account.email) ?? account.email,
80
+ ...(account.alias !== undefined ? { alias: account.alias } : {}),
80
81
  ...(account.plan !== undefined ? { plan: account.plan } : {}),
81
82
  ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
82
83
  isMain: false,
@@ -168,9 +169,13 @@ async function verifyCodexAccountWarmup(
168
169
  }
169
170
 
170
171
  function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
171
- if (!flowId) return;
172
- codexAuthLoginState.set(flowId, { status: "error", error, doneAt: Date.now() });
173
- setTimeout(() => codexAuthLoginState.delete(flowId), 30_000);
172
+ const ids = flowId
173
+ ? [flowId]
174
+ : [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
175
+ for (const id of ids) {
176
+ codexAuthLoginState.set(id, { status: "error", error, doneAt: Date.now() });
177
+ setTimeout(() => codexAuthLoginState.delete(id), 30_000);
178
+ }
174
179
  }
175
180
 
176
181
  let mainAccountCache: { email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null; ts: number } | null = null;
@@ -258,6 +263,7 @@ interface PoolQuotaResult {
258
263
 
259
264
  export interface CodexAuthAccountDto {
260
265
  id: string;
266
+ alias?: string;
261
267
  email: string;
262
268
  plan?: string | null;
263
269
  logLabel?: string;
@@ -442,6 +448,24 @@ export async function handleCodexAuthAPI(
442
448
  return jsonResponse({ ok: true });
443
449
  }
444
450
 
451
+ if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
452
+ const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown };
453
+ const id = typeof body.id === "string" ? body.id.trim() : "";
454
+ const alias = typeof body.alias === "string" ? body.alias.trim() : "";
455
+ if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
456
+ if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400);
457
+ if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
458
+ return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400);
459
+ }
460
+ const runtimeConfig = getRuntimeConfig(config);
461
+ const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain);
462
+ if (!account) return jsonResponse({ error: "Account not found" }, 404);
463
+ if (alias) account.alias = alias;
464
+ else delete account.alias;
465
+ saveRuntimeConfig(config, runtimeConfig);
466
+ return jsonResponse({ ok: true, id, alias: alias || null });
467
+ }
468
+
445
469
  if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
446
470
  let body: { accountId: string | null };
447
471
  try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
@@ -763,6 +787,24 @@ export async function handleCodexAuthAPI(
763
787
  }
764
788
  }
765
789
 
790
+ if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") {
791
+ const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown };
792
+ const flowId = typeof body.flowId === "string" ? body.flowId.trim() : "";
793
+ const input = typeof body.input === "string" ? body.input : "";
794
+ if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
795
+ if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
796
+
797
+ // Import may yield; validate afterwards so cancel/replace cannot race a stale flow through.
798
+ const { submitManualLoginCode } = await import("../oauth");
799
+ const flow = codexAuthLoginState.get(flowId);
800
+ if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400);
801
+ if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400);
802
+
803
+ const result = submitManualLoginCode("chatgpt", input);
804
+ if (!result.ok) return jsonResponse({ error: result.error }, 400);
805
+ return jsonResponse({ ok: true }, 202);
806
+ }
807
+
766
808
  if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
767
809
  const body = (await req.json().catch(() => ({}))) as { flowId?: string };
768
810
  const { cancelLoginFlow } = await import("../oauth");
@@ -690,7 +690,7 @@ export function materializeBundledCodexCatalog(path: string, deps: BundledCatalo
690
690
 
691
691
  function loadCatalogForSync(path: string): RawCatalog | null {
692
692
  const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
693
- if (bundled) return bundled;
693
+ if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
694
694
  const catalog = readCatalog(path);
695
695
  if (catalog && findNativeTemplate(catalog)) return catalog;
696
696
  return readCatalog(catalogBackupPathFor(path))
@@ -829,6 +829,66 @@ function ensureUltraReasoningLevel(entry: RawEntry): void {
829
829
  entry.supported_reasoning_levels = levels;
830
830
  }
831
831
 
832
+ /** Reasoning-effort labels accepted by the installed Codex binary's bundled catalog. */
833
+ export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): Set<string> | null {
834
+ const bundled = loadBundledCodexCatalog(deps);
835
+ if (!bundled) return null;
836
+ const efforts = new Set<string>();
837
+ for (const model of bundled.models ?? []) {
838
+ if (typeof model.slug !== "string" || model.slug.includes("/")) continue;
839
+ const levels = Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [];
840
+ for (const level of levels) {
841
+ const effort = (level as { effort?: unknown })?.effort;
842
+ if (typeof effort === "string") efforts.add(effort);
843
+ }
844
+ if (typeof model.default_reasoning_level === "string") efforts.add(model.default_reasoning_level);
845
+ }
846
+ return efforts.size > 0 ? efforts : null;
847
+ }
848
+
849
+ /** Highest surviving rung at or below the original default, with a conservative empty fallback. */
850
+ export function clampedDefaultEffort(original: string, surviving: readonly string[]): string {
851
+ if (surviving.length === 0) return "medium";
852
+ const ranked = [...surviving]
853
+ .map(effort => ({ effort, rank: codexEffortRank(effort) }))
854
+ .sort((a, b) => a.rank - b.rank);
855
+ const originalRank = codexEffortRank(original);
856
+ const atOrBelow = ranked.filter(item => item.rank >= 0 && item.rank <= originalRank);
857
+ return (atOrBelow.at(-1) ?? ranked[0]!).effort;
858
+ }
859
+
860
+ /** Remove reasoning efforts the installed Codex binary cannot deserialize from one entry. */
861
+ export function clampEntryToCodexSupportedEfforts(entry: RawEntry, supported: Set<string> | null): void {
862
+ if (!supported) return;
863
+ const levels = Array.isArray(entry.supported_reasoning_levels)
864
+ ? entry.supported_reasoning_levels as Array<{ effort?: string }>
865
+ : null;
866
+ if (levels && levels.length > 0) {
867
+ const kept = levels.filter(level => typeof level?.effort === "string" && supported.has(level.effort));
868
+ entry.supported_reasoning_levels = kept.length > 0
869
+ ? kept
870
+ : CODEX_REASONING_LEVELS
871
+ .filter(level => level.effort === "low" || level.effort === "medium" || level.effort === "high")
872
+ .map(level => ({ ...level }));
873
+ }
874
+ const currentDefault = entry.default_reasoning_level;
875
+ if (typeof currentDefault === "string" && !supported.has(currentDefault)) {
876
+ const surviving = (Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels : [])
877
+ .flatMap(level => typeof (level as { effort?: string })?.effort === "string"
878
+ ? [(level as { effort: string }).effort]
879
+ : []);
880
+ entry.default_reasoning_level = clampedDefaultEffort(currentDefault, surviving);
881
+ }
882
+ }
883
+
884
+ /** Clamp every catalog entry to the reasoning ladder accepted by the installed Codex binary. */
885
+ export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: BundledCatalogDeps = {}): RawEntry[] {
886
+ const supported = codexSupportedReasoningEfforts(deps);
887
+ if (!supported) return models;
888
+ for (const entry of models) clampEntryToCodexSupportedEfforts(entry, supported);
889
+ return models;
890
+ }
891
+
832
892
  /**
833
893
  * Native entry from the pinned upstream snapshot, finished for emission. Keeps the entry's
834
894
  * OWN identity (display_name, description, priority, availability_nux — it is the model's own
@@ -1240,11 +1300,22 @@ function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModel
1240
1300
  async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs: number, contextCap?: number): Promise<CatalogModel[]> {
1241
1301
  if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
1242
1302
  const apiKey = await resolveModelsAuthToken(name, prov);
1243
- const configured: CatalogModel[] = (prov.models ?? []).map(id => ({
1303
+ const seedVertexDefault = prov.adapter === "google"
1304
+ && prov.googleMode === "vertex"
1305
+ && (prov.models?.length ?? 0) === 0
1306
+ && Boolean(prov.defaultModel);
1307
+ const configuredIds = seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : (prov.models ?? []);
1308
+ const configured: CatalogModel[] = configuredIds.map(id => ({
1244
1309
  id,
1245
1310
  provider: name,
1246
1311
  ...catalogHintsFromProviderConfig(name, prov, id, contextCap),
1247
1312
  }));
1313
+ const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined;
1314
+ const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => (
1315
+ vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id)
1316
+ ? [...models, vertexDefaultSeed]
1317
+ : models
1318
+ );
1248
1319
  if (prov.adapter === "cursor") {
1249
1320
  if (prov.liveModels === false || !apiKey) return configured;
1250
1321
  // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
@@ -1276,20 +1347,27 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1276
1347
  return configured;
1277
1348
  }
1278
1349
  const fresh = getFreshCached(name, ttlMs);
1279
- if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh, contextCap); // dedups Codex's frequent /v1/models polling within the TTL
1350
+ if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL
1280
1351
  if (isModelsFetchCoolingDown(name)) {
1281
1352
  // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the
1282
1353
  // fetch timeout on every catalog poll — the dashboard polls this path per page load.
1283
1354
  const stale = getStaleCached(name);
1284
- return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
1355
+ return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
1285
1356
  }
1286
1357
  const { url, headers } = buildModelsRequest(prov, apiKey, name);
1358
+ const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
1359
+ ? "vertex-aiplatform"
1360
+ : "provider-models";
1287
1361
  try {
1288
1362
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
1289
1363
  if (!res.ok) {
1290
1364
  markModelsFetchFailure(name);
1291
1365
  const stale = getStaleCached(name);
1292
- return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
1366
+ const fallback = stale ? "stale" : "configured";
1367
+ console.warn(
1368
+ `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
1369
+ );
1370
+ return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
1293
1371
  }
1294
1372
  const json = await res.json() as unknown;
1295
1373
  const data = json !== null && typeof json === "object" && !Array.isArray(json)
@@ -1301,7 +1379,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1301
1379
  `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data; using stale/static catalog degradation.`,
1302
1380
  );
1303
1381
  const stale = getStaleCached(name);
1304
- return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
1382
+ return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
1305
1383
  }
1306
1384
  const items = data;
1307
1385
  const live = items.map(m => applyProviderConfigHints(name, prov, {
@@ -1324,7 +1402,7 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1324
1402
  if (dated) {
1325
1403
  // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
1326
1404
  live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
1327
- } else if (shouldRetainConfiguredProviderModel(name, m.id)) {
1405
+ } else if (seedVertexDefault || shouldRetainConfiguredProviderModel(name, m.id)) {
1328
1406
  live.push(m);
1329
1407
  } else {
1330
1408
  droppedConfiguredIds.push(m.id);
@@ -1341,10 +1419,14 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1341
1419
  }
1342
1420
  setCached(name, live);
1343
1421
  return live;
1344
- } catch {
1422
+ } catch (error) {
1345
1423
  markModelsFetchFailure(name);
1346
1424
  const stale = getStaleCached(name);
1347
- return stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap) : configured;
1425
+ const fallback = stale ? "stale" : "configured";
1426
+ console.warn(
1427
+ `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
1428
+ );
1429
+ return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : configured;
1348
1430
  }
1349
1431
  }
1350
1432
 
@@ -1428,7 +1510,16 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
1428
1510
  else warnUncataloguedComboOnce(id, combo, members);
1429
1511
  }
1430
1512
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
1431
- return all;
1513
+ const customModels = (config.customModels ?? []).map(cm => ({
1514
+ id: cm.modelId,
1515
+ provider: cm.provider,
1516
+ ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
1517
+ ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
1518
+ }));
1519
+ // Custom rows override discovered rows that encode to the same Codex-facing slug.
1520
+ const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
1521
+ const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
1522
+ return [...deduped, ...customModels];
1432
1523
  }
1433
1524
 
1434
1525
  const openAiApiCollisionWarnings = new Set<string>();
@@ -1441,11 +1532,12 @@ function intersectStrings(values: readonly string[][]): string[] {
1441
1532
  }
1442
1533
 
1443
1534
  function effectiveComboDefault(
1444
- configured: string | undefined,
1535
+ configured: string | null | undefined,
1445
1536
  common: readonly string[],
1446
1537
  ): string | undefined {
1538
+ if (!configured) return undefined;
1447
1539
  if (configured && common.includes(configured)) return configured;
1448
- const requestedRank = codexEffortRank(configured ?? "medium");
1540
+ const requestedRank = codexEffortRank(configured);
1449
1541
  const ranked = common
1450
1542
  .map(effort => ({ effort, rank: codexEffortRank(effort) }))
1451
1543
  .filter(item => item.rank >= 0)
@@ -1903,6 +1995,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
1903
1995
  // native template can never leak supports_websockets while the flag is off.
1904
1996
  const wsEnabled = websocketsEnabled(config);
1905
1997
  catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider);
1998
+ clampCatalogModelsToCodexSupport(catalog.models);
1906
1999
 
1907
2000
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
1908
2001
  return { added: goEntries.length, path: catalogPath };