diff --git a/bin/cli/commands/cost.mjs b/bin/cli/commands/cost.mjs new file mode 100644 index 0000000000..5745c2761d --- /dev/null +++ b/bin/cli/commands/cost.mjs @@ -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 ", t("cost.period"), "30d") + .option("--since ", t("cost.since")) + .option("--until ", t("cost.until")) + .option("--group-by ", t("cost.group_by"), "provider") + .option("--api-key ", t("cost.api_key_filter")) + .option("--limit ", 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; +} diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index 4fd5a762fc..d8772a856b 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -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); diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index fa1a728d2b..9b2d38b33d 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -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", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 799a399f8b..0bfd8cf6df 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -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", diff --git a/tests/unit/cli-cost.test.ts b/tests/unit/cli-cost.test.ts new file mode 100644 index 0000000000..2cda136918 --- /dev/null +++ b/tests/unit/cli-cost.test.ts @@ -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): 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); +});