Files
OmniRoute/bin/cli/commands/setup-codex.mjs
Diego Rodrigues de Sa e Souza 3e6be47012 feat(cli): Claude Code launcher + setup commands (remote mode + profiles) (#4274)
Brings Claude Code to parity with the Codex CLI integration.

- `omniroute launch` is now remote-aware: --remote <url>, --profile <name>,
  --api-key. Resolves base URL + auth from the active context (so
  `omniroute connect <vps>` then `omniroute launch` just works), accepts a bare
  port OR a full base URL, health-checks the (possibly remote) server, and sets
  CLAUDE_CONFIG_DIR for the chosen profile.
- `omniroute setup-claude` (new): fetches the live /v1/models catalog and writes
  ~/.claude/profiles/<name>/settings.json per model. Claude Code has no native
  profile files, so CLAUDE_CONFIG_DIR is the idiomatic mechanism. Reuses the SAME
  profile names as setup-codex (glm52, kimi-k27, …) via the shared categoriseModel
  (now exported). The auth token is NEVER written to disk — launch injects it.
- Docs: docs/guides/CLAUDE-CODE-CONFIGURATION.md + README index. i18n (en + pt-BR).
- Tests: setup-claude profile generation (incl. "no token on disk"), buildClaudeEnv
  (port + URL + CLAUDE_CONFIG_DIR), resolveLaunchTarget.

check:cli-i18n + check-docs-sync green; 13 unit tests pass.
2026-06-19 10:27:24 -03:00

227 lines
9.6 KiB
JavaScript

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