/** * omniroute setup-codex — Remote-aware Codex CLI profile generator. * * Connects to a running OmniRoute instance (local or remote VPS), fetches the * live model catalog via GET /v1/models, then generates ~/.codex/.config.toml * profile files for each model — so you can switch providers with a single flag * (`codex --profile glm52`) without editing config files by hand. * * Primary use-case: configure a local Codex CLI to use models from a VPS. * omniroute setup-codex --remote http://100.67.86.91:20128 --api-key sk-xxx * * The command is idempotent: re-running updates existing profile files in place. */ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { t } from "../i18n.mjs"; // ── Model categorisation ────────────────────────────────────────────────────── /** * Map a model ID (as returned by /v1/models) to a Codex profile configuration. * Returns null for models that should not get their own profile (e.g. aliases). * * Exported so the Claude Code profile generator (`setup-claude`) reuses the SAME * profile names (glm52, kimi-k27, …) for cross-CLI consistency. * * @param {string} modelId * @returns {{ name:string, ctx:number, compact:number, effort?:string, summary?:boolean, toolLimit:number }|null} */ export function categoriseModel(modelId) { const id = modelId.toLowerCase(); // ── Thinking models (max effort, detailed summary) ──────────────────────── const thinkingPatterns = [ { re: /kmc\/kimi-k2\.7/, name: "kimi-k27", ctx: 131072, compact: 112000, toolLimit: 32768 }, { re: /kmc\/kimi-k2\.6/, name: "kimi-k26", ctx: 131072, compact: 112000, toolLimit: 32768 }, { re: /glm\/glm-5\.2-max/, name: "glm52max", ctx: 131072, compact: 112000, toolLimit: 32768 }, { re: /glm\/glm-5\.2$/, name: "glm52", ctx: 131072, compact: 112000, toolLimit: 32768 }, { re: /opencode-go\/mimo-v2\.5-pro/, name: "mimo-pro", ctx: 131072, compact: 112000, toolLimit: 32768 }, { re: /opencode-go\/qwen3\.7-plus/, name: "qwen37plus", ctx: 32768, compact: 28000, toolLimit: 16384 }, ]; // ── Good models (high effort) ───────────────────────────────────────────── const goodPatterns = [ { re: /ollamacloud\/deepseek-v4-pro/, name: "deepseek-pro", ctx: 131072, compact: 112000, toolLimit: 32768 }, { re: /opencode-go\/mimo-v2\.5$/, name: "mimo", ctx: 131072, compact: 112000, toolLimit: 32768 }, ]; // ── Simple models (no effort) ───────────────────────────────────────────── const simplePatterns = [ { re: /ollamacloud\/gemma4:31b/, name: "gemma4", ctx: 32768, compact: 28000, toolLimit: 16384 }, { re: /ollamacloud\/nemotron-3-super/, name: "nemotron", ctx: 32768, compact: 28000, toolLimit: 16384 }, { re: /ollamacloud\/gpt-oss:20b/, name: "gptoss", ctx: 32768, compact: 28000, toolLimit: 16384 }, ]; // ── Fast models (low effort) ────────────────────────────────────────────── const fastPatterns = [ { re: /ollamacloud\/deepseek-v4-flash/, name: "deepseek-flash", ctx: 65536, compact: 56000, toolLimit: 16384 }, { re: /ollamacloud\/gemini-3-flash/, name: "gemini-flash", ctx: 1000000, compact: 850000, toolLimit: 32768 }, { re: /glm\/glm-5-turbo/, name: "glm5turbo", ctx: 131072, compact: 112000, toolLimit: 16384 }, { re: /glm\/glm-4\.7-flash/, name: "glm47flash", ctx: 131072, compact: 112000, toolLimit: 16384 }, ]; for (const p of thinkingPatterns) { if (p.re.test(id)) return { ...p, effort: "xhigh", summary: true }; } for (const p of goodPatterns) { if (p.re.test(id)) return { ...p, effort: "high", summary: false }; } for (const p of simplePatterns) { if (p.re.test(id)) return { ...p, effort: undefined, summary: false }; } for (const p of fastPatterns) { if (p.re.test(id)) return { ...p, effort: "low", summary: false }; } return null; } /** Build the TOML content for a single profile. */ function buildProfileToml(modelId, cfg) { const lines = [ `# codex --profile ${cfg.name}`, `# ${modelId}`, `model = "${modelId}"`, `model_provider = "omniroute"`, ]; if (cfg.effort) { lines.push(`model_reasoning_effort = "${cfg.effort}"`); } if (cfg.summary) { lines.push(`model_reasoning_summary = "detailed"`); } lines.push( `model_context_window = ${cfg.ctx}`, `model_auto_compact_token_limit = ${cfg.compact}`, `tool_output_token_limit = ${cfg.toolLimit}` ); return lines.join("\n") + "\n"; } // ── Command ─────────────────────────────────────────────────────────────────── /** * @param {{remote?:string, port?:string, apiKey?:string, codexHome?:string, dryRun?:boolean, only?:string}} opts * @returns {Promise} */ export async function runSetupCodexCommand(opts = {}) { const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128; const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/v1$/, ""); const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? ""; const codexHome = opts.codexHome ?? opts["codex-home"] ?? join(os.homedir(), ".codex"); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null; printHeading(`OmniRoute → Codex CLI profile generator`); printInfo(`Connecting to ${baseUrl} …`); // ── Fetch model catalog ─────────────────────────────────────────────────── let models; try { const headers = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); const body = await res.json(); models = body.data ?? body.models ?? []; } catch (err) { printError(`Failed to fetch models: ${err.message}`); printInfo( "Make sure OmniRoute is running and the --remote URL is correct.\n" + "You may also need --api-key if OmniRoute requires authentication." ); return 1; } printInfo(`Received ${models.length} models from ${baseUrl}`); // ── Ensure codex home exists ────────────────────────────────────────────── if (!dryRun && !existsSync(codexHome)) { mkdirSync(codexHome, { recursive: true }); } // ── Generate profiles ───────────────────────────────────────────────────── let written = 0; let skipped = 0; for (const m of models) { const id = typeof m === "string" ? m : (m.id ?? ""); if (!id) continue; if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue; const cfg = categoriseModel(id); if (!cfg) continue; const filePath = join(codexHome, `${cfg.name}.config.toml`); const content = buildProfileToml(id, cfg); if (dryRun) { console.log(`\n── [dry-run] ${filePath} ──`); console.log(content); } else { writeFileSync(filePath, content, "utf8"); printSuccess(` ✓ ${cfg.name}.config.toml (${id})`); } written++; } skipped = models.length - written; if (!dryRun) { console.log(""); printSuccess(`${written} profiles written to ${codexHome}`); if (skipped > 0) { printInfo(`${skipped} models skipped (no matching profile pattern)`); } console.log("\nTo use a profile:"); console.log(" codex --profile # e.g. codex --profile glm52"); console.log(" codex -p # short form"); } else { console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`); } return 0; } export function registerSetupCodex(program) { program .command("setup-codex") .description( "Fetch the live model catalog from OmniRoute (local or remote VPS) and generate " + "~/.codex/.config.toml profiles for each supported model" ) .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") .option( "--remote ", "Remote OmniRoute URL, e.g. http://100.67.86.91:20128 — fetches models from there" ) .option( "--api-key ", "OmniRoute API key for the remote instance (defaults to OMNIROUTE_API_KEY env var)" ) .option( "--codex-home ", "Directory where profile files are written (default: ~/.codex)" ) .option( "--only ", "Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)" ) .option("--dry-run", "Print what would be written without touching the filesystem") .action(async (opts) => { const exitCode = await runSetupCodexCommand(opts); if (exitCode !== 0) process.exit(exitCode); }); }