-
-
Notifications
You must be signed in to change notification settings - Fork 10.3k
feat(cli): Codex CLI launcher + setup commands #4270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| import { spawn } from "node:child_process"; | ||
| import { t } from "../i18n.mjs"; | ||
|
|
||
| /** | ||
| * Health-check an OmniRoute endpoint (local or remote) before launching Codex. | ||
| * @param {string} baseUrl | ||
| * @param {number} timeoutMs | ||
| * @returns {Promise<boolean>} | ||
| */ | ||
| async function healthCheck(baseUrl, timeoutMs = 3000) { | ||
| try { | ||
| const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/api/monitoring/health`, { | ||
| signal: AbortSignal.timeout(timeoutMs), | ||
| }); | ||
| return res.ok; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build the env for the Codex child process. | ||
| * Injects OMNIROUTE_API_KEY if an explicit api-key was provided on the command line | ||
| * (useful when launching against a remote VPS whose key differs from ~/.bashrc). | ||
| * @param {Record<string,string>} baseEnv | ||
| * @param {string|undefined} apiKey | ||
| * @returns {Record<string,string>} | ||
| */ | ||
| export function buildCodexEnv(baseEnv, apiKey) { | ||
| const env = { ...baseEnv }; | ||
| if (apiKey) env.OMNIROUTE_API_KEY = apiKey; | ||
| return env; | ||
| } | ||
|
|
||
| /** | ||
| * @param {{port?:string, remote?:string, profile?:string, apiKey?:string}} opts | ||
| * @param {string[]} codexArgs pass-through args for the codex binary | ||
| * @returns {Promise<number>} exit code | ||
| */ | ||
| export async function runLaunchCodexCommand(opts = {}, codexArgs = []) { | ||
| const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128; | ||
| const baseUrl = opts.remote ?? `http://localhost:${port}/v1`; | ||
|
|
||
| const ok = await healthCheck(baseUrl); | ||
| if (!ok) { | ||
| const location = opts.remote ?? `port ${port}`; | ||
| console.error( | ||
| (t("launch.notRunning") || "OmniRoute is not running on port {port}. Start it with 'omniroute serve'.") | ||
| .replace("{port}", String(location)) | ||
| ); | ||
| return 1; | ||
| } | ||
|
|
||
| const profile = opts.profile; | ||
| const extraArgs = profile ? ["--profile", profile, ...codexArgs] : codexArgs; | ||
| const env = buildCodexEnv(process.env, opts.apiKey ?? opts["api-key"]); | ||
|
|
||
| return await new Promise((resolve) => { | ||
| const child = spawn("codex", extraArgs, { env, stdio: "inherit" }); | ||
| child.on("error", (err) => { | ||
| if (err?.code === "ENOENT") { | ||
| console.error( | ||
| "The 'codex' CLI was not found in PATH. Install with:\n" + | ||
| " npm install -g @openai/codex" | ||
| ); | ||
| resolve(127); | ||
| } else { | ||
| console.error(String(err?.message || err)); | ||
| resolve(1); | ||
| } | ||
| }); | ||
| child.on("exit", (code) => resolve(code ?? 0)); | ||
| }); | ||
| } | ||
|
|
||
| export function registerLaunchCodex(program) { | ||
| program | ||
| .command("launch-codex") | ||
| .description( | ||
| t("launchCodex.description") || | ||
| "Launch Codex CLI pointed at OmniRoute (local or remote VPS)" | ||
| ) | ||
| .option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128") | ||
| .option( | ||
| "--remote <url>", | ||
| "Remote OmniRoute base URL, e.g. http://100.67.86.91:20128/v1 (overrides --port)" | ||
| ) | ||
| .option("--profile <name>", "Codex profile to activate (passed as --profile <name>)") | ||
| .option("-p, --p <name>", "Alias for --profile") | ||
| .option( | ||
| "--api-key <key>", | ||
| "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)" | ||
| ) | ||
| .allowUnknownOption(true) | ||
| .allowExcessArguments(true) | ||
| .argument("[codexArgs...]", "arguments passed through to the codex binary") | ||
| .action(async (codexArgs, opts) => { | ||
| // -p is an alias for --profile | ||
| const merged = { ...opts, profile: opts.profile ?? opts.p }; | ||
| const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []); | ||
| if (exitCode !== 0) process.exit(exitCode); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,8 @@ import { registerTray } from "./tray.mjs"; | |
| import { registerAutostart } from "./autostart.mjs"; | ||
| import { registerRepl } from "./repl.mjs"; | ||
| import { registerLaunch } from "./launch.mjs"; | ||
| import { registerLaunchCodex } from "./launch-codex.mjs"; | ||
| import { registerSetupCodex } from "./setup-codex.mjs"; | ||
|
Comment on lines
+59
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. According to the Repository Style Guide (Rule 9 under 2. Hard Rules):
Since this PR introduces new production CLI commands in References
|
||
| import { registerConnect } from "./connect.mjs"; | ||
| import { registerTokens } from "./tokens.mjs"; | ||
| import { registerConfigure } from "./configure.mjs"; | ||
|
|
@@ -122,6 +124,8 @@ export function registerCommands(program) { | |
| registerAutostart(program); | ||
| registerRepl(program); | ||
| registerLaunch(program); | ||
| registerLaunchCodex(program); | ||
| registerSetupCodex(program); | ||
| registerConnect(program); | ||
| registerTokens(program); | ||
| registerConfigure(program); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,223 @@ | ||||||
| /** | ||||||
| * 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/<name>.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). | ||||||
| * | ||||||
| * @param {string} modelId | ||||||
| * @returns {{ name:string, ctx:number, compact:number, effort?:string, summary?:boolean, toolLimit:number }|null} | ||||||
| */ | ||||||
| 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 }, | ||||||
| ]; | ||||||
|
Comment on lines
+34
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are a few discrepancies between the model categorization patterns defined here and the documentation in
Let's update the patterns to align with the documentation. const thinkingPatterns = [
{ re: /kmc\/kimi-k2\.7/, name: "kimi-k27", 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: /kmc\/kimi-k2\.6/, name: "kimi-k26", ctx: 131072, compact: 112000, toolLimit: 32768 },
{ re: /ollamacloud\/deepseek-v4-pro/, name: "deepseek-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
{ re: /ds\/deepseek-v4-pro/, name: "deepseek", ctx: 1000000, compact: 900000, toolLimit: 65536 },
{ 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 },
{ re: /mistral\/mistral-large-latest/, name: "mistral", ctx: 262144, compact: 220000, 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<number>} | ||||||
| */ | ||||||
| export async function runSetupCodexCommand(opts = {}) { | ||||||
| const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128; | ||||||
| const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/v1$/, ""); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to the health check, if
Suggested change
|
||||||
| 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 ?? []; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To make this more robust against different server response formats (e.g., if the server returns a direct JSON array instead of an object, or if
Suggested change
|
||||||
| } catch (err) { | ||||||
| printError(`Failed to fetch models: ${err.message}`); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If
Suggested change
|
||||||
| 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 <name> # e.g. codex --profile glm52"); | ||||||
| console.log(" codex -p <name> # 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/<name>.config.toml profiles for each supported model" | ||||||
| ) | ||||||
| .option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128") | ||||||
| .option( | ||||||
| "--remote <url>", | ||||||
| "Remote OmniRoute URL, e.g. http://100.67.86.91:20128 — fetches models from there" | ||||||
| ) | ||||||
| .option( | ||||||
| "--api-key <key>", | ||||||
| "OmniRoute API key for the remote instance (defaults to OMNIROUTE_API_KEY env var)" | ||||||
| ) | ||||||
| .option( | ||||||
| "--codex-home <dir>", | ||||||
| "Directory where profile files are written (default: ~/.codex)" | ||||||
| ) | ||||||
| .option( | ||||||
| "--only <patterns>", | ||||||
| "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); | ||||||
| }); | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
baseUrlhas a trailing slash (e.g.,http://localhost:20128/v1/), the regex\/\/v1$/will not match, leaving the trailing slash and resulting in a malformed URL likehttp://localhost:20128/v1//api/monitoring/health.We can make this more robust by handling optional trailing slashes.