Files
OmniRoute/bin/cli/commands/combo.mjs

420 lines
14 KiB
JavaScript

import { Option } from "commander";
import { printHeading } from "../io.mjs";
import { withRuntime } from "../runtime.mjs";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { resolveComboModels, collectModel } from "./comboModels.mjs";
const VALID_STRATEGIES = [
"priority",
"weighted",
"round-robin",
"p2c",
"random",
"auto",
"lkgp",
"context-optimized",
"context-relay",
"fill-first",
"cost-optimized",
"least-used",
"strict-random",
"reset-aware",
];
const suggestSchema = [
{ key: "rank", header: "#" },
{ key: "name", header: "Combo", width: 24 },
{ key: "strategy", header: "Strategy", width: 16 },
{ key: "score", header: "Score", formatter: (v) => (v != null ? v.toFixed(3) : "-") },
{ key: "latencyP50Ms", header: "Latency P50", formatter: (v) => (v != null ? `${v}ms` : "-") },
{ key: "costPer1k", header: "Cost/1k", formatter: (v) => (v != null ? `$${v.toFixed(5)}` : "-") },
{
key: "rationale",
header: "Rationale",
width: 40,
formatter: (v) => {
if (!v) return "-";
const s = String(v);
return s.length > 40 ? s.slice(0, 39) + "…" : s;
},
},
];
const TASK_TYPES = ["coding", "review", "planning", "analysis", "debugging", "documentation"];
function normalizeTaskType(value) {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (TASK_TYPES.includes(normalized)) return normalized;
if (/debug|bug|error|fix/.test(normalized)) return "debugging";
if (/review|audit/.test(normalized)) return "review";
if (/plan|architect|design/.test(normalized)) return "planning";
if (/doc|readme|guide/.test(normalized)) return "documentation";
if (/code|implement|program/.test(normalized)) return "coding";
return "analysis";
}
export function extendComboSuggest(combo) {
combo
.command("suggest")
.description(t("combo.suggest.description"))
.requiredOption("--task <description>", t("combo.suggest.task"))
.option("--max-cost <usd>", t("combo.suggest.maxCost"), parseFloat)
.option("--max-latency-ms <ms>", t("combo.suggest.maxLatencyMs"), parseInt)
.option("--top <n>", t("combo.suggest.top"), parseInt, 5)
.option("--explain", t("combo.suggest.explain"))
.option("--switch", t("combo.suggest.switch"))
.action(async (opts, cmd) => {
const body = {
taskType: normalizeTaskType(opts.task),
budgetConstraint: opts.maxCost,
latencyConstraint: opts.maxLatencyMs,
};
const data = await mcpCallTool("omniroute_best_combo_for_task", body);
const candidates = data.recommendedCombo
? [data.recommendedCombo, ...(Array.isArray(data.alternatives) ? data.alternatives : [])]
: (data.candidates ?? data);
const rows = (Array.isArray(candidates) ? candidates : []).slice(0, opts.top).map((c, i) => ({
rank: i + 1,
...c,
}));
emit(rows, cmd.optsWithGlobals(), suggestSchema);
if (opts.explain && !cmd.optsWithGlobals().quiet) {
process.stderr.write(
`\nRationale:\n${data.recommendedCombo?.reason ?? data.rationale ?? "(no rationale)"}\n`
);
}
if (opts.switch && rows[0]) {
const best = rows[0].name;
const switchRes = await apiFetch("/api/combos/switch", {
method: "POST",
body: { name: best },
});
if (!switchRes.ok) {
process.stderr.write(`Switch failed: ${switchRes.status}\n`);
process.exit(1);
}
process.stderr.write(`\nSwitched to: ${best}\n`);
}
});
}
export function registerCombo(program) {
const combo = program.command("combo").description(t("combo.title"));
combo
.command("list")
.description("List configured routing combos")
.option("--json", "Output as JSON")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runComboListCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
combo
.command("switch <name>")
.description("Activate a routing combo")
.action(async (name, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runComboSwitchCommand(name, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
combo
.command("create <name>")
.description("Create a new routing combo")
.addOption(
new Option("--strategy <strategy>", "Routing strategy")
.choices(VALID_STRATEGIES)
.default("priority")
)
.option(
"--models <spec>",
"Models for the combo: comma-separated provider/model entries, or a JSON array " +
'(e.g. --models "openai/gpt-4o,anthropic/claude-3-opus" or ' +
'--models \'[{"model":"gpt-4o","providerId":"openai"}]\')'
)
.option(
"--model <spec>",
"Add one model to the combo (provider/model or bare model id) — repeatable",
collectModel,
[]
)
.action(async (name, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
let models;
try {
models = resolveComboModels(opts);
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
return;
}
const exitCode = await runComboCreateCommand(name, opts.strategy, {
...opts,
models,
output: globalOpts.output,
});
if (exitCode !== 0) process.exit(exitCode);
});
combo
.command("delete <name>")
.description("Delete a routing combo")
.option("--yes", "Skip confirmation")
.action(async (name, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runComboDeleteCommand(name, { ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
extendComboSuggest(combo);
}
export async function runComboListCommand(opts = {}) {
try {
return await withRuntime(async ({ kind, api, db }) => {
let combos = [];
let activeCombo = null;
let listError = null;
if (kind === "http") {
const [listRes, activeRes] = await Promise.all([
api("/api/combos", { retry: false, timeout: 5000, acceptNotOk: true }),
api("/api/settings", { retry: false, timeout: 3000, acceptNotOk: true }),
]);
if (listRes.ok) {
const data = await listRes.json();
combos = Array.isArray(data) ? data : (data.combos ?? []);
} else {
// The server answered, but not with a combo list. Falling through to
// an empty array here rendered "No combos configured" — which is
// indistinguishable from genuine emptiness and reads as real state,
// so a transport/auth failure looked like a wiped configuration.
listError = listRes.status;
}
if (activeRes.ok) {
const settings = await activeRes.json();
activeCombo = settings?.activeCombo ?? null;
}
} else {
combos = await db.combos.getCombos();
}
if (opts.json || opts.output === "json") {
console.log(
JSON.stringify(
{ combos, active: activeCombo, error: listError && `HTTP ${listError}` },
null,
2
)
);
return listError ? 1 : 0;
}
printHeading(t("combo.title"));
if (listError) {
console.error(
t("common.error", {
message: `could not list combos from the server (HTTP ${listError})`,
})
);
return 1;
}
if (combos.length === 0) {
console.log(t("combo.noCombos"));
return 0;
}
for (const combo of combos) {
const comboName = combo.name ?? combo.id ?? "?";
const isActive = activeCombo && (comboName === activeCombo || combo.id === activeCombo);
const icon = isActive ? "\x1b[32m●\x1b[0m" : "\x1b[2m○\x1b[0m";
const enabled = combo.enabled !== false;
const status = enabled ? "\x1b[32menabled\x1b[0m" : "\x1b[31mdisabled\x1b[0m";
const strategy = (combo.strategy ?? "priority").padEnd(12);
console.log(` ${icon} ${comboName.padEnd(25)} [${strategy}] ${status}`);
}
return 0;
});
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runComboSwitchCommand(name, opts = {}) {
if (!name) {
console.error("Combo name is required.");
return 1;
}
try {
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const listRes = await api("/api/combos", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!listRes.ok) {
console.error(`Failed to fetch combo list (HTTP ${listRes.status}).`);
return 1;
}
const data = await listRes.json();
const combos = Array.isArray(data) ? data : (data.combos ?? []);
const found = combos.find((c) => c.name === name || c.id === name);
if (!found) {
console.error(`Combo '${name}' not found.`);
return 1;
}
const patchRes = await api("/api/settings", {
method: "PATCH",
body: { activeCombo: name },
retry: false,
acceptNotOk: true,
});
if (!patchRes.ok) {
console.error(`Failed to switch combo (HTTP ${patchRes.status}).`);
return 1;
}
} else {
const combo = await db.combos.getComboByName(name);
if (!combo) {
console.error(`Combo '${name}' not found.`);
return 1;
}
db.combos.setActiveCombo(name);
}
console.log(t("combo.switched", { name }));
return 0;
});
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runComboCreateCommand(name, strategy = "priority", opts = {}) {
if (!name) {
console.error("Combo name is required.");
return 1;
}
if (!VALID_STRATEGIES.includes(strategy)) {
console.error(`Invalid strategy '${strategy}'. Valid: ${VALID_STRATEGIES.join(", ")}`);
return 1;
}
const models = Array.isArray(opts.models) ? opts.models : [];
if (!models.length) {
console.error(
"combo create requires at least one target. Pass --models <provider/model,...> and/or repeat --model <provider/model>."
);
return 1;
}
try {
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const res = await api("/api/combos", {
method: "POST",
body: { name, strategy, enabled: true, models, config: {} },
retry: false,
acceptNotOk: true,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
const msg = body ? `${body}` : "";
console.error(`Failed to create combo (HTTP ${res.status})${msg}`);
return 1;
}
} else {
const existing = await db.combos.getComboByName(name);
if (existing) {
console.error(`Combo '${name}' already exists. Delete it first.`);
return 1;
}
await db.combos.createCombo({ name, strategy, enabled: true, models, config: {} });
}
console.log(t("combo.created", { name }));
return 0;
});
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}
export async function runComboDeleteCommand(name, opts = {}) {
if (!name) {
console.error("Combo name is required.");
return 1;
}
if (!opts.yes) {
const readline = await import("node:readline");
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) =>
rl.question(t("combo.confirmDelete", { name }) + " [y/N] ", resolve)
);
rl.close();
if (!/^y(es)?$/i.test(answer)) {
console.log(t("common.cancelled"));
return 0;
}
}
try {
return await withRuntime(async ({ kind, api, db }) => {
if (kind === "http") {
const listRes = await api("/api/combos", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
if (!listRes.ok) {
console.error(`Failed to fetch combo list (HTTP ${listRes.status}).`);
return 1;
}
const data = await listRes.json();
const combos = Array.isArray(data) ? data : (data.combos ?? []);
const found = combos.find((c) => c.name === name || c.id === name);
if (!found) {
console.error(`Combo '${name}' not found.`);
return 1;
}
const delRes = await api(`/api/combos/${encodeURIComponent(found.id)}`, {
method: "DELETE",
retry: false,
acceptNotOk: true,
});
if (!delRes.ok) {
console.error(`Failed to delete combo (HTTP ${delRes.status}).`);
return 1;
}
} else {
const deleted = await db.combos.deleteComboByName(name);
if (!deleted) {
console.error(`Combo '${name}' not found.`);
return 1;
}
}
console.log(t("combo.deleted", { name }));
return 0;
});
} catch (err) {
console.error(t("common.error", { message: err instanceof Error ? err.message : String(err) }));
return 1;
}
}