Files
diegosouzapw--omniroute/bin/cli/commands/setup-codex.mjs
T
2026-07-13 13:39:12 +08:00

388 lines
13 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;
}
function firstPositiveNumber(...values) {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
}
return null;
}
function shortHash(value) {
let hash = 5381;
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
export function profileNameFromModelId(modelId) {
const normalized = String(modelId)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const base = normalized || "model";
if (base.length <= 96) return base;
return `${base.slice(0, 84).replace(/-+$/g, "")}-${shortHash(base)}`;
}
function hasAnyValue(values, patterns) {
return values.some((value) => patterns.some((pattern) => pattern.test(value)));
}
export function isCodexCompatibleTextModel(model) {
if (typeof model === "string") return true;
const id = String(model?.id ?? "").toLowerCase();
const type = String(model?.type ?? "").toLowerCase();
const outputModalities = Array.isArray(model?.output_modalities)
? model.output_modalities.map((value) => String(value).toLowerCase())
: [];
if (type && !["chat", "text", "language", "llm", "model"].includes(type)) {
return false;
}
const unsupportedPatterns = [
/(^|[/_-])(image|img|video|veo|seedance|audio|speech|voice|tts|stt|whisper)([/_-]|$)/,
/(^|[/_-])(embedding|embeddings|embed|rerank|moderation|transcription)([/_-]|$)/,
];
if (hasAnyValue([id, type], unsupportedPatterns)) return false;
const nonTextModalities = [/^(image|video|audio)$/];
if (hasAnyValue(outputModalities, nonTextModalities)) return false;
if (outputModalities.length > 0 && !outputModalities.includes("text")) {
return false;
}
return true;
}
export function fallbackCodexProfile(modelId, model) {
if (!isCodexCompatibleTextModel(model)) return null;
const ctx =
typeof model === "string"
? 128000
: (firstPositiveNumber(
model.context_length,
model.max_context_window_tokens,
model.max_input_tokens
) ?? 128000);
const maxOutput =
typeof model === "string"
? null
: firstPositiveNumber(model.max_output_tokens, model.output_token_limit);
const toolLimit = Math.min(Math.max(maxOutput ?? 16384, 8192), 32768);
return {
name: profileNameFromModelId(modelId),
ctx,
compact: Math.floor(ctx * 0.85),
summary: false,
toolLimit,
};
}
/** 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";
}
export async function syncCodexProfilesFromModels(models, opts = {}) {
const codexHome = opts.codexHome || join(os.homedir(), ".codex");
const dryRun = Boolean(opts.dryRun);
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
if (!dryRun && !existsSync(codexHome)) {
mkdirSync(codexHome, { recursive: true });
}
let written = 0;
let skipped = 0;
const profiles = [];
for (const m of models) {
const id = typeof m === "string" ? m : (m.id ?? "");
if (!id) {
skipped++;
continue;
}
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) {
skipped++;
continue;
}
const cfg = categoriseModel(id) ?? fallbackCodexProfile(id, m);
if (!cfg) {
skipped++;
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");
}
profiles.push({ name: cfg.name, model: id, filePath });
written++;
}
return { written, skipped, profiles };
}
// ── 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}`);
// ── Generate profiles ─────────────────────────────────────────────────────
const { written, skipped, profiles } = await syncCodexProfilesFromModels(models, {
codexHome,
dryRun,
only: opts.only,
});
if (!dryRun) {
for (const profile of profiles) {
printSuccess(` ✓ ${profile.name}.config.toml (${profile.model})`);
}
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);
});
}