import os from "node:os"; import path from "node:path"; import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs"; import { apiFetch } from "../api.mjs"; import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs"; import { t } from "../i18n.mjs"; /** * `omniroute configure ` — interactive provider+model picker that writes a * local CLI config pointed at the ACTIVE OmniRoute context (local or remote). * * The model catalog comes from the active context's GET /v1/models, so when you * are in remote mode (`omniroute connect ...`) you pick from the remote server's * live models and the profile is written on THIS machine. * * v1 targets the Codex CLI (writes ~/.codex/.config.toml). The credential * is referenced by env var (OMNIROUTE_API_KEY) — never written to disk. */ const SUPPORTED = ["codex"]; /** Derive a short, filesystem-safe profile name from a model id. */ export function profileNameFromModel(modelId) { const afterProvider = String(modelId).includes("/") ? String(modelId).split("/").slice(1).join("/") : String(modelId); return afterProvider.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "model"; } /** Provider id for a catalog entry: explicit owned_by, else the id prefix. */ function providerOf(entry) { if (entry && typeof entry.owned_by === "string" && entry.owned_by) return entry.owned_by; const id = typeof entry === "string" ? entry : entry?.id || ""; return id.includes("/") ? id.split("/")[0] : "(none)"; } function contextWindowOf(entry) { for (const c of [entry?.context_length, entry?.max_context_window_tokens]) { if (typeof c === "number" && Number.isFinite(c) && c > 0) return c; } return null; } async function fetchModels(globalOpts) { const res = await apiFetch("/v1/models", { ...globalOpts, acceptNotOk: true }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const b = await res.json(); msg = b?.error?.message || b?.error || msg; } catch { /* ignore */ } throw new Error(`Could not fetch models: ${msg}`); } const body = await res.json(); const list = Array.isArray(body) ? body : body.data || body.models || []; return list.filter((m) => (typeof m === "string" ? m : m?.id)); } function buildCodexProfile(modelId, ctx) { const lines = [ `# codex --profile ${profileNameFromModel(modelId)}`, `# ${modelId} — generated by 'omniroute configure codex'`, `model = "${modelId}"`, `model_provider = "omniroute"`, ]; if (ctx && ctx > 0) { const compact = Math.floor(ctx * 0.85); lines.push(`model_context_window = ${ctx}`); lines.push(`model_auto_compact_token_limit = ${compact}`); } return lines.join("\n") + "\n"; } async function configureCodex(modelId, ctxWindow, opts) { const codexHome = opts.codexHome || path.join(os.homedir(), ".codex"); if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true }); const profile = opts.name || profileNameFromModel(modelId); const filePath = path.join(codexHome, `${profile}.config.toml`); if (existsSync(filePath)) { copyFileSync(filePath, `${filePath}.bak`); } writeFileSync(filePath, buildCodexProfile(modelId, ctxWindow), "utf8"); printSuccess(`Wrote ${filePath}`); printInfo(`Use it: codex --profile ${profile}`); printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block"); printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md)."); } export async function runConfigureCommand(cli, opts = {}, cmd) { const target = String(cli || "").toLowerCase(); if (!SUPPORTED.includes(target)) { printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`); return 2; } const globalOpts = cmd ? cmd.optsWithGlobals() : {}; let models; try { models = await fetchModels(globalOpts); } catch (e) { printError(e instanceof Error ? e.message : String(e)); return 1; } if (!models.length) { printError("The server returned no models."); return 1; } // Resolve model: explicit flags or interactive pick. let chosenId = opts.model; if (chosenId && opts.provider && !chosenId.includes("/")) { chosenId = `${opts.provider}/${chosenId}`; } if (!chosenId) { const ids = models.map((m) => (typeof m === "string" ? m : m.id)); const providers = [...new Set(models.map(providerOf))].sort(); const prompt = createPrompt(); try { printHeading("Configure Codex CLI"); let providerList = providers; if (opts.provider) { providerList = providers.filter((p) => p === opts.provider); } else { printInfo(`Providers: ${providers.join(", ")}`); const p = await prompt.ask("Provider"); if (p) providerList = providers.filter((x) => x === p); } const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id)))); const candidates = inProvider.length ? inProvider : ids; printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`); chosenId = await prompt.ask("Model id"); } finally { prompt.close(); } } if (!chosenId) { printError("No model selected."); return 2; } const entry = byId(models, chosenId); if (!entry) { printError(`Model '${chosenId}' is not in the catalog.`); return 2; } const ctxWindow = contextWindowOf(entry); if (target === "codex") { await configureCodex(chosenId, ctxWindow, opts); } return 0; } function byId(models, id) { for (const m of models) { const mid = typeof m === "string" ? m : m.id; if (mid === id) return m; } return null; } export function registerConfigure(program) { program .command("configure ") .description( t("configure.description") || "Pick a provider+model from the active server and write a local CLI config (v1: codex)" ) .option("--provider ", "Provider id (skips the interactive provider prompt)") .option("--model ", "Model id (skips the interactive model prompt)") .option("--name ", "Profile name to write (default: derived from model)") .option("--codex-home ", "Codex home dir (default: ~/.codex)") .action(async (cli, opts, cmd) => { const code = await runConfigureCommand(cli, opts, cmd); if (code !== 0) process.exit(code); }); }