Files
2026-07-13 13:39:12 +08:00

181 lines
6.4 KiB
JavaScript

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 <cli>` — 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/<name>.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 <cli>")
.description(
t("configure.description") ||
"Pick a provider+model from the active server and write a local CLI config (v1: codex)"
)
.option("--provider <id>", "Provider id (skips the interactive provider prompt)")
.option("--model <id>", "Model id (skips the interactive model prompt)")
.option("--name <name>", "Profile name to write (default: derived from model)")
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
.action(async (cli, opts, cmd) => {
const code = await runConfigureCommand(cli, opts, cmd);
if (code !== 0) process.exit(code);
});
}