feat(cli): adicionar comando cost com breakdown de custos (Fase 2.4)

Implementa `omniroute cost` que consulta /api/usage/analytics com:
- --period <range>: 1d|7d|30d|90d|ytd|all (padrão: 30d)
- --since/--until: faixa de datas ISO (substitui --period)
- --group-by: provider|model|api-key|combo|day (padrão: provider)
- --api-key: filtrar por chave específica
- --limit: top N resultados
- Total em stderr ao final (modo tabela)
- Ordenação por custo decrescente
This commit is contained in:
diegosouzapw
2026-05-15 00:52:15 -03:00
parent 18e5bf26a6
commit 993cd32829
5 changed files with 379 additions and 0 deletions

144
bin/cli/commands/cost.mjs Normal file
View File

@@ -0,0 +1,144 @@
import { apiFetch } from "../api.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
const costSchema = [
{ key: "group", header: "Group", width: 30 },
{ key: "requests", header: "Reqs", formatter: (v) => (v != null ? v.toLocaleString() : "0") },
{ key: "tokensIn", header: "Tokens In", formatter: fmtTokens },
{ key: "tokensOut", header: "Tokens Out", formatter: fmtTokens },
{ key: "costUsd", header: "Cost (USD)", formatter: (v) => (v ? `$${v.toFixed(4)}` : "$0.0000") },
{
key: "costPct",
header: "% of Total",
formatter: (v) => (v != null ? `${v.toFixed(1)}%` : "-"),
},
];
function fmtTokens(v) {
if (!v) return "0";
if (v > 1e6) return `${(v / 1e6).toFixed(1)}M`;
if (v > 1e3) return `${(v / 1e3).toFixed(1)}K`;
return String(v);
}
export function registerCost(program) {
program
.command("cost")
.description(t("cost.description"))
.option("--period <range>", t("cost.period"), "30d")
.option("--since <date>", t("cost.since"))
.option("--until <date>", t("cost.until"))
.option("--group-by <field>", t("cost.group_by"), "provider")
.option("--api-key <key>", t("cost.api_key_filter"))
.option("--limit <n>", t("cost.limit"), parseInt, 100)
.action(runCostCommand);
}
export async function runCostCommand(opts, cmd) {
const globalOpts = cmd.optsWithGlobals();
const params = buildParams(opts);
const res = await apiFetch(`/api/usage/analytics?${params}`, {
timeout: globalOpts.timeout,
acceptNotOk: true,
});
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
process.stderr.write(t("common.authRequired") + "\n");
} else if (res.status >= 500) {
process.stderr.write(t("common.serverOffline") + "\n");
} else {
process.stderr.write(t("common.error", { message: `HTTP ${res.status}` }) + "\n");
}
process.exit(res.exitCode ?? 1);
}
const data = await res.json();
const rows = aggregateByGroup(data, opts.groupBy ?? "provider", opts.limit ?? 100);
emit(rows, globalOpts, costSchema);
if (!globalOpts.quiet && globalOpts.output !== "json" && globalOpts.output !== "jsonl") {
const total = rows.reduce((s, r) => s + (r.costUsd ?? 0), 0);
process.stderr.write(
`\nTotal: $${total.toFixed(4)} across ${rows.length} ${opts.groupBy ?? "provider"}(s)\n`
);
}
}
function buildParams(opts) {
const p = new URLSearchParams();
if (opts.since || opts.until) {
if (opts.since) p.set("startDate", opts.since);
if (opts.until) p.set("endDate", opts.until);
} else {
p.set("range", opts.period ?? "30d");
}
if (opts.apiKey) p.set("apiKeyIds", opts.apiKey);
return p.toString();
}
function aggregateByGroup(data, groupBy, limit) {
const source = pickSource(data, groupBy);
if (!Array.isArray(source)) return [];
const totalCost = source.reduce((s, r) => s + toNum(r.totalCost ?? r.cost ?? r.costUsd), 0);
const rows = source.map((r) => {
const costUsd = toNum(r.totalCost ?? r.cost ?? r.costUsd);
return {
group: groupLabel(r, groupBy),
requests: toNum(r.totalRequests ?? r.requests ?? r.count),
tokensIn: toNum(r.totalTokensIn ?? r.tokensIn ?? r.promptTokens),
tokensOut: toNum(r.totalTokensOut ?? r.tokensOut ?? r.completionTokens),
costUsd,
costPct: totalCost > 0 ? (costUsd / totalCost) * 100 : 0,
};
});
rows.sort((a, b) => b.costUsd - a.costUsd);
return rows.slice(0, limit);
}
function pickSource(data, groupBy) {
switch (groupBy) {
case "model":
return data.byModel ?? data.models ?? [];
case "combo":
return data.byCombo ?? data.combos ?? [];
case "api-key":
case "apiKey":
return data.byApiKey ?? data.apiKeys ?? [];
case "day":
return data.byDay ?? data.daily ?? data.trend ?? [];
default:
return data.byProvider ?? data.providers ?? [];
}
}
function groupLabel(row, groupBy) {
switch (groupBy) {
case "model":
return row.model ?? row.modelId ?? String(row.group ?? "");
case "combo":
return row.comboName ?? row.combo ?? row.name ?? String(row.group ?? "");
case "api-key":
case "apiKey":
return row.keyName ?? row.apiKey ?? row.label ?? String(row.group ?? "");
case "day":
return row.date ?? row.day ?? String(row.group ?? "");
default:
return row.provider ?? row.providerId ?? String(row.group ?? "");
}
}
function toNum(v) {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string") {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
return 0;
}

View File

@@ -1,6 +1,7 @@
import { registerChat } from "./chat.mjs";
import { registerStream } from "./stream.mjs";
import { registerSimulate } from "./simulate.mjs";
import { registerCost } from "./cost.mjs";
import { registerServe } from "./serve.mjs";
import { registerStop } from "./stop.mjs";
import { registerRestart } from "./restart.mjs";
@@ -31,6 +32,7 @@ export function registerCommands(program) {
registerChat(program);
registerStream(program);
registerSimulate(program);
registerCost(program);
registerServe(program);
registerStop(program);
registerRestart(program);

View File

@@ -70,6 +70,15 @@
"empty_prompt": "Error: prompt is required (positional arg, --file, or --stdin)"
}
},
"cost": {
"description": "Show cost report with breakdown by provider, model, combo, or API key",
"period": "Time range: 1d|7d|30d|90d|ytd|all (default: 30d)",
"since": "Start date (ISO format, e.g. 2026-01-01) — overrides --period",
"until": "End date (ISO format)",
"group_by": "Group results by: provider|model|api-key|combo|day (default: provider)",
"api_key_filter": "Filter by a specific API key",
"limit": "Maximum number of rows to show (default: 100)"
},
"simulate": {
"description": "Dry-run routing simulation — show which providers would be selected without calling upstream",
"file": "Load full request body from JSON file",

View File

@@ -70,6 +70,15 @@
"empty_prompt": "Erro: prompt obrigatório (argumento posicional, --file ou --stdin)"
}
},
"cost": {
"description": "Exibir relatório de custos com breakdown por provedor, modelo, combo ou chave de API",
"period": "Período: 1d|7d|30d|90d|ytd|all (padrão: 30d)",
"since": "Data de início (formato ISO, ex: 2026-01-01) — substitui --period",
"until": "Data de fim (formato ISO)",
"group_by": "Agrupar por: provider|model|api-key|combo|day (padrão: provider)",
"api_key_filter": "Filtrar por uma chave de API específica",
"limit": "Número máximo de linhas (padrão: 100)"
},
"simulate": {
"description": "Simulação de routing em dry-run — mostra quais provedores seriam selecionados sem chamar o upstream",
"file": "Carregar body completo da requisição de arquivo JSON",

215
tests/unit/cli-cost.test.ts Normal file
View File

@@ -0,0 +1,215 @@
import test from "node:test";
import assert from "node:assert/strict";
const ANALYTICS_RESPONSE = {
byProvider: [
{
provider: "openai",
totalRequests: 150,
totalTokensIn: 50000,
totalTokensOut: 20000,
totalCost: 0.42,
},
{
provider: "anthropic",
totalRequests: 80,
totalTokensIn: 30000,
totalTokensOut: 12000,
totalCost: 0.18,
},
],
byModel: [
{
model: "gpt-4o",
totalRequests: 100,
totalTokensIn: 40000,
totalTokensOut: 16000,
totalCost: 0.35,
},
{
model: "claude-3-5-sonnet",
totalRequests: 80,
totalTokensIn: 30000,
totalTokensOut: 12000,
totalCost: 0.18,
},
],
byDay: [
{
date: "2026-05-14",
totalRequests: 50,
totalTokensIn: 20000,
totalTokensOut: 8000,
totalCost: 0.15,
},
{
date: "2026-05-15",
totalRequests: 60,
totalTokensIn: 22000,
totalTokensOut: 9000,
totalCost: 0.17,
},
],
};
function makeResp(data: unknown, status = 200) {
const obj = {
ok: status < 400,
status,
exitCode: status >= 200 && status < 300 ? 0 : 1,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
headers: new Headers(),
};
obj.json = obj.json.bind(obj);
obj.text = obj.text.bind(obj);
return obj;
}
function mockFetch() {
return (url: string) => {
if (url.includes("/api/usage/analytics")) {
return Promise.resolve(makeResp(ANALYTICS_RESPONSE));
}
return Promise.resolve(makeResp({}, 404));
};
}
async function captureOutput(fn: () => Promise<void>): Promise<{ stdout: string; stderr: string }> {
const out: string[] = [];
const err: string[] = [];
const origOut = process.stdout.write.bind(process.stdout);
const origErr = process.stderr.write.bind(process.stderr);
process.stdout.write = (c: string | Uint8Array) => {
out.push(typeof c === "string" ? c : c.toString());
return true;
};
process.stderr.write = (c: string | Uint8Array) => {
err.push(typeof c === "string" ? c : c.toString());
return true;
};
try {
await fn();
} finally {
process.stdout.write = origOut;
process.stderr.write = origErr;
}
return { stdout: out.join(""), stderr: err.join("") };
}
test("runCostCommand exibe tabela com provedores", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = mockFetch() as any;
const { runCostCommand } = await import("../../bin/cli/commands/cost.mjs");
const cmd = { optsWithGlobals: () => ({ output: "table", quiet: false }) };
const { stdout } = await captureOutput(() =>
runCostCommand({ period: "30d", groupBy: "provider", limit: 100 }, cmd as any)
);
globalThis.fetch = origFetch;
assert.ok(stdout.includes("openai") || stdout.includes("Group"));
});
test("runCostCommand --output json retorna array de rows", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = mockFetch() as any;
const { runCostCommand } = await import("../../bin/cli/commands/cost.mjs");
const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) };
const { stdout } = await captureOutput(() =>
runCostCommand({ period: "30d", groupBy: "provider", limit: 100 }, cmd as any)
);
globalThis.fetch = origFetch;
const parsed = JSON.parse(stdout);
assert.ok(Array.isArray(parsed));
assert.equal(parsed.length, 2);
assert.equal(parsed[0].group, "openai");
assert.ok(parsed[0].costUsd > 0);
assert.ok(parsed[0].costPct > 0);
});
test("runCostCommand --group-by model usa byModel do response", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = mockFetch() as any;
const { runCostCommand } = await import("../../bin/cli/commands/cost.mjs");
const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) };
const { stdout } = await captureOutput(() =>
runCostCommand({ period: "7d", groupBy: "model", limit: 100 }, cmd as any)
);
globalThis.fetch = origFetch;
const parsed = JSON.parse(stdout);
assert.ok(Array.isArray(parsed));
assert.ok(parsed.some((r: any) => r.group === "gpt-4o"));
});
test("runCostCommand --group-by day usa byDay do response", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = mockFetch() as any;
const { runCostCommand } = await import("../../bin/cli/commands/cost.mjs");
const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) };
const { stdout } = await captureOutput(() =>
runCostCommand({ period: "7d", groupBy: "day", limit: 100 }, cmd as any)
);
globalThis.fetch = origFetch;
const parsed = JSON.parse(stdout);
assert.ok(Array.isArray(parsed));
assert.ok(parsed.some((r: any) => r.group.includes("2026-05")));
});
test("runCostCommand imprime total em stderr", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = mockFetch() as any;
const { runCostCommand } = await import("../../bin/cli/commands/cost.mjs");
const cmd = { optsWithGlobals: () => ({ output: "table", quiet: false }) };
const { stderr } = await captureOutput(() =>
runCostCommand({ period: "30d", groupBy: "provider", limit: 100 }, cmd as any)
);
globalThis.fetch = origFetch;
assert.ok(stderr.includes("Total:") && stderr.includes("$"));
});
test("runCostCommand --since/--until envia startDate/endDate na query", async () => {
let capturedUrl = "";
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string) => {
capturedUrl = url;
return Promise.resolve(makeResp(ANALYTICS_RESPONSE));
}) as any;
const { runCostCommand } = await import("../../bin/cli/commands/cost.mjs");
const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) };
await captureOutput(() =>
runCostCommand(
{ since: "2026-01-01", until: "2026-05-01", groupBy: "provider", limit: 100 },
cmd as any
)
);
globalThis.fetch = origFetch;
assert.ok(capturedUrl.includes("startDate=2026-01-01"));
assert.ok(capturedUrl.includes("endDate=2026-05-01"));
assert.ok(!capturedUrl.includes("range="));
});
test("runCostCommand --limit trunca resultado", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = mockFetch() as any;
const { runCostCommand } = await import("../../bin/cli/commands/cost.mjs");
const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) };
const { stdout } = await captureOutput(() =>
runCostCommand({ period: "30d", groupBy: "provider", limit: 1 }, cmd as any)
);
globalThis.fetch = origFetch;
const parsed = JSON.parse(stdout);
assert.equal(parsed.length, 1);
});