mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
144
bin/cli/commands/cost.mjs
Normal file
144
bin/cli/commands/cost.mjs
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
215
tests/unit/cli-cost.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user