181 lines
6.4 KiB
JavaScript
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);
|
|
});
|
|
}
|