mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
- canonical executable manifest (bin/cli/cli-manifest.mjs): run/configure/completion derive targets, aliases and --model wiring from one table; drift test cross-checks manifest x cliRuntime x UI catalog (tests/unit/cli/cli-manifest-drift.test.ts) - dashboard Codex generator converged to ~/.codex/config.toml (modern Codex v0.137+, verified against codex-cli 0.147.0): conservative merge, env_key auth (key never written), refuses invalid TOML, reports legacy config.yaml as migration note - omniroute run gemini: launcher over OmniRoute's /v1beta surface via GOOGLE_GEMINI_BASE_URL + isolated GEMINI_CLI_HOME forcing gemini-api-key auth (contract proven against @google/gemini-cli 0.50.0); ACP registration kept distinct - opt-in real smoke harness for upstream CLIs (RUN_CLI_SMOKE=1, credential by env NAME, redacted output): tests/integration/upstream-cli-smoke.int.test.ts - container-guard homologation for POST /api/cli-tools/apply (422 in container, dry-run preview allowed, host write passes) + docs; guard untouched - typecheck: omniglyphAdapter union narrowing, usageTracking typed signatures (UsageLike, no any), models.ts isValidModel params — typecheck:core and typecheck:noimplicit:core now clean - relay core (prior session of this effort): omniroute run for 6 CLIs, configure picker with per-context favorites/recents, contexts with optional keychain + 0600 fallback, provider CRUD with recursive redaction, completion updates, docs
344 lines
13 KiB
JavaScript
344 lines
13 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 { loadContexts, resolveActiveContext } from "../contexts.mjs";
|
|
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
|
|
import { t } from "../i18n.mjs";
|
|
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
|
|
import {
|
|
getModelPreferenceState,
|
|
loadModelPreferences,
|
|
rankPreferredModels,
|
|
recordModelPreference,
|
|
} from "../model-preferences.mjs";
|
|
import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.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.
|
|
*
|
|
* Codex keeps its profile-specific TOML files. Other targets delegate to their
|
|
* existing setup-* recipe after the same provider/model selection, so the
|
|
* picker remains a read-only orchestration layer and does not duplicate config
|
|
* merge logic.
|
|
*/
|
|
|
|
const SUPPORTED = listManifestTargets("configure");
|
|
|
|
export const SETUP_MODULES = {
|
|
claude: { module: "./setup-claude.mjs", exportName: "runSetupClaudeCommand" },
|
|
opencode: { module: "./setup-opencode.mjs", exportName: "runSetupOpencodeCommand" },
|
|
qwen: { module: "./setup-qwen.mjs", exportName: "runSetupQwenCommand" },
|
|
aider: { module: "./setup-aider.mjs", exportName: "runSetupAiderCommand" },
|
|
goose: { module: "./setup-goose.mjs", exportName: "runSetupGooseCommand" },
|
|
cline: { module: "./setup-cline.mjs", exportName: "runSetupClineCommand" },
|
|
continue: { module: "./setup-continue.mjs", exportName: "runSetupContinueCommand" },
|
|
kilo: { module: "./setup-kilo.mjs", exportName: "runSetupKiloCommand" },
|
|
};
|
|
|
|
/**
|
|
* Materialize the active server before delegating to a setup recipe.
|
|
*
|
|
* `apiFetch` knows how to prefer a named context over an ambient
|
|
* `OMNIROUTE_API_KEY`, but the older setup modules receive plain options and
|
|
* resolve those themselves. Passing the resolved URL/key here keeps the
|
|
* picker and the delegated recipe on the same local/remote target, including
|
|
* Claude Code which predates context-aware setup resolution.
|
|
*/
|
|
export function resolveConfigureTargetOptions(opts = {}) {
|
|
const resolved = { ...opts };
|
|
const ambientKey = process.env.OMNIROUTE_API_KEY || "";
|
|
const explicitRemote = opts.remote || opts.baseUrl;
|
|
let context;
|
|
try {
|
|
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
|
} catch {
|
|
// A missing/corrupt context file should retain the normal local fallback.
|
|
}
|
|
|
|
if (!explicitRemote) {
|
|
const localDefault = `http://localhost:${opts.port || process.env.PORT || "20128"}`;
|
|
const contextBase = String(context?.baseUrl || "").replace(/\/+$/, "");
|
|
if (contextBase && contextBase !== localDefault) {
|
|
resolved.remote = contextBase;
|
|
} else if (opts.port) {
|
|
resolved.remote = localDefault;
|
|
}
|
|
} else if (!resolved.remote && resolved.baseUrl) {
|
|
resolved.remote = resolved.baseUrl;
|
|
}
|
|
|
|
const contextKey = context?.accessToken || context?.apiKey;
|
|
if (contextKey && (!opts.apiKey || opts.apiKey === ambientKey)) {
|
|
resolved.apiKey = contextKey;
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
export function listConfigureTargets() {
|
|
return [...SUPPORTED];
|
|
}
|
|
|
|
export { getModelPreferenceState, rankPreferredModels };
|
|
|
|
function preferenceContextName(opts = {}) {
|
|
if (opts.context || process.env.OMNIROUTE_CONTEXT) {
|
|
return String(opts.context || process.env.OMNIROUTE_CONTEXT);
|
|
}
|
|
try {
|
|
return String(loadContexts().currentContext || "default");
|
|
} catch {
|
|
return "default";
|
|
}
|
|
}
|
|
|
|
/** 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");
|
|
const guard = await guardHostConfigTarget(codexHome, {
|
|
toolLabel: "Codex",
|
|
hostCommand: "omniroute configure codex",
|
|
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
|
|
dryRun: Boolean(opts.dryRun ?? opts["dry-run"]),
|
|
});
|
|
if (guard !== 0) return guard;
|
|
if (opts.dryRun ?? opts["dry-run"]) {
|
|
const profile = opts.name || profileNameFromModel(modelId);
|
|
const filePath = path.join(codexHome, `${profile}.config.toml`);
|
|
printInfo(`[dry-run] would write ${filePath}`);
|
|
return 0;
|
|
}
|
|
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).");
|
|
return 0;
|
|
}
|
|
|
|
export async function runConfigureCommand(cli, opts = {}, cmd) {
|
|
const target = resolveManifestTarget(cli, "configure");
|
|
if (!target) {
|
|
printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`);
|
|
return 2;
|
|
}
|
|
if (opts.favorite && opts.unfavorite) {
|
|
printError("Choose only one of --favorite or --unfavorite.");
|
|
return 2;
|
|
}
|
|
const globalOpts = cmd ? cmd.optsWithGlobals() : {};
|
|
const requestOpts = resolveConfigureTargetOptions({ ...globalOpts, ...opts });
|
|
const contextKey = preferenceContextName({ ...globalOpts, ...opts });
|
|
|
|
let models;
|
|
try {
|
|
models = await fetchModels(requestOpts);
|
|
} 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 && !opts.yes) {
|
|
const ids = models.map((m) => (typeof m === "string" ? m : m.id));
|
|
const preferences = loadModelPreferences();
|
|
const rankedIds = rankPreferredModels(target, ids, preferences, contextKey);
|
|
const preferenceState = getModelPreferenceState(target, preferences, contextKey);
|
|
const providers = [...new Set(models.map(providerOf))].sort();
|
|
const prompt = createPrompt();
|
|
try {
|
|
printHeading(`Configure ${target} 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 = rankedIds.filter((id) =>
|
|
providerList.includes(providerOf(byId(models, id)))
|
|
);
|
|
const candidates = inProvider.length ? inProvider : rankedIds;
|
|
if (preferenceState.favorites.length) {
|
|
printInfo(
|
|
`Favorites: ${preferenceState.favorites.filter((id) => ids.includes(id)).join(", ")}`
|
|
);
|
|
}
|
|
if (preferenceState.recent.length) {
|
|
printInfo(`Recent: ${preferenceState.recent.filter((id) => ids.includes(id)).join(", ")}`);
|
|
}
|
|
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);
|
|
|
|
let result;
|
|
if (target === "codex") {
|
|
result = await configureCodex(chosenId, ctxWindow, opts);
|
|
} else {
|
|
const setup = SETUP_MODULES[target];
|
|
if (!setup) {
|
|
printError(`No setup recipe is registered for '${target}'.`);
|
|
return 2;
|
|
}
|
|
|
|
try {
|
|
const module = await import(setup.module);
|
|
const runSetup = module[setup.exportName];
|
|
if (typeof runSetup !== "function") {
|
|
printError(`Setup recipe '${target}' is unavailable.`);
|
|
return 1;
|
|
}
|
|
|
|
const setupOpts = {
|
|
...requestOpts,
|
|
...opts,
|
|
model: chosenId,
|
|
// The picker already selected a model. Setup recipes that can generate
|
|
// a model subset receive an exact filter; the others use `model`.
|
|
...(target === "claude" || target === "continue" ? { only: chosenId } : {}),
|
|
yes: true,
|
|
};
|
|
result = await runSetup(setupOpts);
|
|
} catch (error) {
|
|
printError(error instanceof Error ? error.message : String(error));
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
if (result === 0 && !(opts.dryRun ?? opts["dry-run"])) {
|
|
recordModelPreference(target, chosenId, {
|
|
favorite: Boolean(opts.favorite),
|
|
unfavorite: Boolean(opts.unfavorite),
|
|
context: contextKey,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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 configure a supported local CLI"
|
|
)
|
|
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
|
.option("--remote <url>", "Remote OmniRoute URL")
|
|
.option("--context <name>", "Named local/remote context")
|
|
.option("--api-key <key>", "OmniRoute API key (defaults to the active context/env)")
|
|
.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)")
|
|
.option("--yes", "Non-interactive; requires --model")
|
|
.option("--favorite", "Remember the selected model as a favorite for this CLI")
|
|
.option("--unfavorite", "Remove the selected model from this CLI's favorites")
|
|
.option("--dry-run", "Preview the generated config without writing")
|
|
.option(
|
|
"--allow-container-write",
|
|
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
|
|
)
|
|
.action(async (cli, opts, cmd) => {
|
|
const code = await runConfigureCommand(cli, opts, cmd);
|
|
if (code !== 0) process.exit(code);
|
|
});
|
|
}
|