126 lines
4.5 KiB
JavaScript
126 lines
4.5 KiB
JavaScript
import { readFileSync } from "node:fs";
|
||
import { apiFetch } from "../api.mjs";
|
||
import { emit } from "../output.mjs";
|
||
import { t } from "../i18n.mjs";
|
||
|
||
const simulateSchema = [
|
||
{ key: "order", header: "#", width: 4 },
|
||
{ key: "provider", header: "Provider", width: 20 },
|
||
{ key: "model", header: "Model", width: 35 },
|
||
{ key: "probability", header: "Probability", formatter: (v) => `${Math.round(v * 100)}%` },
|
||
{ key: "estimatedCost", header: "Est. Cost", formatter: (v) => (v ? `$${v.toFixed(4)}` : "-") },
|
||
{ key: "healthStatus", header: "Breaker", width: 10 },
|
||
{ key: "quotaAvailable", header: "Quota %", formatter: (v) => `${v}%` },
|
||
];
|
||
|
||
export function registerSimulate(program) {
|
||
program
|
||
.command("simulate [prompt]")
|
||
.description(t("simulate.description"))
|
||
.option("--file <path>", t("simulate.file"))
|
||
.option("-m, --model <id>", t("simulate.model"), "auto")
|
||
.option("--combo <name>", t("simulate.combo"))
|
||
.option("--reasoning-effort <level>", t("simulate.reasoning"))
|
||
.option("--thinking-budget <n>", t("simulate.thinking"), parseInt)
|
||
.option("--explain", t("simulate.explain"))
|
||
.action(runSimulateCommand);
|
||
}
|
||
|
||
export async function runSimulateCommand(promptArg, opts, cmd) {
|
||
const globalOpts = cmd.optsWithGlobals();
|
||
|
||
let promptTokenEstimate = 100;
|
||
if (opts.file) {
|
||
try {
|
||
const raw = readFileSync(opts.file, "utf8");
|
||
const parsed = JSON.parse(raw);
|
||
const text = JSON.stringify(parsed);
|
||
promptTokenEstimate = Math.ceil(text.length / 4);
|
||
} catch {
|
||
promptTokenEstimate = 100;
|
||
}
|
||
} else if (promptArg) {
|
||
promptTokenEstimate = Math.ceil(promptArg.length / 4);
|
||
}
|
||
|
||
const [combosRes, healthRes, quotaRes] = await Promise.allSettled([
|
||
apiFetch("/api/combos", { timeout: globalOpts.timeout }).then((r) => r.json()),
|
||
apiFetch("/api/monitoring/health", { timeout: globalOpts.timeout }).then((r) => r.json()),
|
||
apiFetch("/api/usage/quota", { timeout: globalOpts.timeout }).then((r) => r.json()),
|
||
]);
|
||
|
||
if (combosRes.status === "rejected") {
|
||
process.stderr.write(t("common.serverOffline") + "\n");
|
||
process.exit(3);
|
||
}
|
||
|
||
const combos = normalizeCombos(combosRes.value);
|
||
const health = healthRes.status === "fulfilled" ? healthRes.value : {};
|
||
const quota = quotaRes.status === "fulfilled" ? quotaRes.value : {};
|
||
|
||
const targetCombo = opts.combo
|
||
? combos.find((c) => c.id === opts.combo || c.name === opts.combo)
|
||
: combos.find((c) => c.enabled !== false);
|
||
|
||
if (!targetCombo) {
|
||
process.stderr.write(t("simulate.noCombo") + "\n");
|
||
process.exit(1);
|
||
}
|
||
|
||
const models = getComboModels(targetCombo, opts.model);
|
||
const breakers = toArray(health.circuitBreakers ?? health.breakers);
|
||
const providers = toArray(quota.providers ?? quota.data);
|
||
|
||
const simulatedPath = models.map((m, idx) => {
|
||
const cb = breakers.find((b) => String(b.provider) === m.provider);
|
||
const q = providers.find((p) => p.provider === m.provider);
|
||
const inputCost = m.inputCostPer1M ?? 0;
|
||
const estimatedCost = Math.round((promptTokenEstimate / 1_000_000) * inputCost * 10000) / 10000;
|
||
return {
|
||
order: idx + 1,
|
||
provider: m.provider,
|
||
model: m.model || opts.model,
|
||
probability: idx === 0 ? 0.85 : 0.15 / Math.max(models.length - 1, 1),
|
||
estimatedCost,
|
||
healthStatus: String(cb?.state ?? "CLOSED"),
|
||
quotaAvailable: q?.percentRemaining ?? 100,
|
||
};
|
||
});
|
||
|
||
emit(simulatedPath, globalOpts, simulateSchema);
|
||
|
||
if (opts.explain && !globalOpts.quiet) {
|
||
const primary = simulatedPath[0];
|
||
const fallbacks = simulatedPath.slice(1).map((s) => s.provider);
|
||
process.stderr.write(`\nPrimary: ${primary?.provider} / ${primary?.model}\n`);
|
||
if (fallbacks.length > 0) {
|
||
process.stderr.write(`Fallbacks: ${fallbacks.join(" → ")}\n`);
|
||
}
|
||
const costs = simulatedPath.map((s) => s.estimatedCost);
|
||
process.stderr.write(
|
||
`Est. cost range: $${Math.min(...costs).toFixed(4)} – $${Math.max(...costs).toFixed(4)}\n`
|
||
);
|
||
}
|
||
}
|
||
|
||
function normalizeCombos(raw) {
|
||
if (Array.isArray(raw)) return raw;
|
||
if (raw && Array.isArray(raw.combos)) return raw.combos;
|
||
if (raw && Array.isArray(raw.data)) return raw.data;
|
||
return [];
|
||
}
|
||
|
||
function getComboModels(combo, modelFallback) {
|
||
const steps = combo.steps ?? combo.models ?? combo.targets ?? [];
|
||
return steps.map((step) => ({
|
||
provider: step.provider ?? step.providerId ?? "",
|
||
model: step.model ?? step.modelId ?? modelFallback ?? "auto",
|
||
inputCostPer1M: step.inputCostPer1M ?? step.costPer1MInput ?? 0,
|
||
}));
|
||
}
|
||
|
||
function toArray(val) {
|
||
if (Array.isArray(val)) return val;
|
||
return [];
|
||
}
|