Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
},
"git.ignoreLimitWarning": true,

// ─── Git: não adicionar os ~44 repos aninhados (9 worktrees + _references/* +
// _mono_repo/* + _ideia/_tasks/.agents) ao Source Control. Era a causa do
// "validando muito": um git status + watcher por repo. Só o repo raiz fica. ───
"git.autoRepositoryDetection": false,
"git.repositoryScanMaxDepth": 0,
"git.detectSubmodules": false,
"git.autofetch": false,
"git.autorefresh": true,

// ─── Performance: não seguir o symlink de node_modules criado pelas worktrees ───
// As worktrees em .worktrees/ apontam node_modules para o checkout principal;
// sem isto a busca atravessa o symlink mesmo com node_modules excluído.
Expand Down
103 changes: 103 additions & 0 deletions bin/cli/commands/launch-codex.mjs
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`, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If baseUrl has 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 like http://localhost:20128/v1//api/monitoring/health.

We can make this more robust by handling optional trailing slashes.

Suggested change
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/api/monitoring/health`, {
const cleanBase = baseUrl.replace(/\/v1\/?$/, "").replace(/\/$/, "");
const res = await fetch(`${cleanBase}/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);
});
}
4 changes: 4 additions & 0 deletions bin/cli/commands/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

According to the Repository Style Guide (Rule 9 under 2. Hard Rules):

Always include tests when changing production code (src/, open-sse/, electron/, bin/).

Since this PR introduces new production CLI commands in bin/cli/commands/, please ensure that corresponding unit or integration tests are added within the tests/ directory to maintain coverage.

References
  1. Always include tests when changing production code (src/, open-sse/, electron/, bin/). (link)

import { registerConnect } from "./connect.mjs";
import { registerTokens } from "./tokens.mjs";
import { registerConfigure } from "./configure.mjs";
Expand Down Expand Up @@ -122,6 +124,8 @@ export function registerCommands(program) {
registerAutostart(program);
registerRepl(program);
registerLaunch(program);
registerLaunchCodex(program);
registerSetupCodex(program);
registerConnect(program);
registerTokens(program);
registerConfigure(program);
Expand Down
223 changes: 223 additions & 0 deletions bin/cli/commands/setup-codex.mjs
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There are a few discrepancies between the model categorization patterns defined here and the documentation in CODEX-CLI-CONFIGURATION.md:

  1. kimi-k2.6: Listed under thinkingPatterns in the code (which assigns effort: "xhigh" and summary: true), but documented under ### Good models (bons) — high effort in the markdown. It should be moved to goodPatterns.
  2. ds/deepseek-v4-pro: Documented under ### Good models (bons) — high effort with a 1M context window, but completely missing from the code patterns.
  3. mistral/mistral-large-latest: Documented under ### Fast models (rápidos) — low effort with a 256k context window, but completely missing from the code patterns.

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$/, "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the health check, if opts.remote contains a trailing slash (e.g., http://100.67.86.91:20128/v1/), the regex \/\/v1$/ will fail to strip the /v1/ path correctly. Let's make this robust against trailing slashes.

Suggested change
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/v1$/, "");
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/v1\/?$/, "").replace(/\/$/, "");

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 ?? [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 body is nullish), we should perform a safer check.

Suggested change
models = body.data ?? body.models ?? [];
models = Array.isArray(body) ? body : (body?.data ?? body?.models ?? []);

} catch (err) {
printError(`Failed to fetch models: ${err.message}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If err is not an object or is null/undefined, accessing err.message will throw a TypeError and crash the process. Let's safely access the message or fall back to the string representation of the error.

Suggested change
printError(`Failed to fetch models: ${err.message}`);
printError(`Failed to fetch models: ${err?.message || String(err)}`);

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);
});
}
6 changes: 6 additions & 0 deletions bin/cli/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1271,5 +1271,11 @@
},
"configure": {
"description": "Pick a provider+model from the active server and write a local CLI config"
},
"launchCodex": {
"description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
},
"setupCodex": {
"description": "Generate ~/.codex profile files from OmniRoute live model catalog"
}
}
6 changes: 6 additions & 0 deletions bin/cli/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -1270,5 +1270,11 @@
},
"configure": {
"description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local"
},
"launchCodex": {
"description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)"
},
"setupCodex": {
"description": "Gera os arquivos de perfil ~/.codex a partir do catálogo de modelos ao vivo do OmniRoute"
}
}
Loading
Loading