diff --git a/bin/cli/commands/registry.mjs b/bin/cli/commands/registry.mjs index d8772a856b..03099fe0c7 100644 --- a/bin/cli/commands/registry.mjs +++ b/bin/cli/commands/registry.mjs @@ -2,6 +2,7 @@ import { registerChat } from "./chat.mjs"; import { registerStream } from "./stream.mjs"; import { registerSimulate } from "./simulate.mjs"; import { registerCost } from "./cost.mjs"; +import { registerUsage } from "./usage.mjs"; import { registerServe } from "./serve.mjs"; import { registerStop } from "./stop.mjs"; import { registerRestart } from "./restart.mjs"; @@ -33,6 +34,7 @@ export function registerCommands(program) { registerStream(program); registerSimulate(program); registerCost(program); + registerUsage(program); registerServe(program); registerStop(program); registerRestart(program); diff --git a/bin/cli/commands/usage.mjs b/bin/cli/commands/usage.mjs new file mode 100644 index 0000000000..780f6d3e3f --- /dev/null +++ b/bin/cli/commands/usage.mjs @@ -0,0 +1,331 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { apiFetch } from "../api.mjs"; +import { emit, maskSecret } from "../output.mjs"; +import { t } from "../i18n.mjs"; + +const fmtTs = (v) => (v ? new Date(v).toISOString().replace("T", " ").slice(0, 19) : "-"); +const maskKey = (v) => (typeof v === "string" ? maskSecret(v) : (v ?? "-")); +const fmtCost = (v) => (v ? `$${Number(v).toFixed(4)}` : "-"); +const 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); +}; + +const analyticsSchema = [ + { key: "provider", header: "Provider", width: 20 }, + { 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: fmtCost }, +]; + +const budgetSchema = [ + { key: "scope", header: "Scope", width: 25 }, + { key: "period", header: "Period" }, + { key: "limit", header: "Limit (USD)", formatter: (v) => `$${Number(v).toFixed(2)}` }, + { key: "used", header: "Used (USD)", formatter: (v) => `$${Number(v).toFixed(2)}` }, + { key: "remaining", header: "Remaining", formatter: (v) => `$${Number(v).toFixed(2)}` }, + { key: "pct", header: "%", formatter: (v) => `${(Number(v) * 100).toFixed(1)}%` }, +]; + +const quotaSchema = [ + { key: "provider", header: "Provider", width: 20 }, + { key: "limit", header: "Limit", formatter: fmtTokens }, + { key: "used", header: "Used", formatter: fmtTokens }, + { key: "remaining", header: "Remaining", formatter: fmtTokens }, + { key: "resetAt", header: "Reset At", formatter: fmtTs }, + { key: "state", header: "State" }, +]; + +const logsSchema = [ + { key: "timestamp", header: "Time", width: 20, formatter: fmtTs }, + { key: "apiKey", header: "API Key", width: 16, formatter: maskKey }, + { key: "method", header: "Method", width: 8 }, + { key: "provider", header: "Provider", width: 14 }, + { key: "model", header: "Model", width: 25 }, + { key: "tokens", header: "Tokens", formatter: fmtTokens }, + { key: "costUsd", header: "Cost", formatter: fmtCost }, + { key: "latencyMs", header: "Latency", formatter: (v) => (v ? `${v}ms` : "-") }, + { key: "status", header: "Status" }, +]; + +export function registerUsage(program) { + const usage = program.command("usage").description(t("usage.description")); + + // analytics + usage + .command("analytics") + .description(t("usage.analytics.description")) + .option("--period ", t("usage.analytics.period"), "30d") + .option("--provider ", t("usage.analytics.provider")) + .action(runUsageAnalytics); + + // budget + const budget = usage.command("budget").description(t("usage.budget.description")); + budget.command("list").action(runBudgetList); + budget.command("get [scope]").action(runBudgetGet); + budget + .command("set ") + .option("--scope ", t("usage.budget.set.scope"), "global") + .option("--period

", t("usage.budget.set.period"), "monthly") + .action(runBudgetSet); + budget.command("reset [scope]").action(runBudgetReset); + + // quota + usage + .command("quota") + .description(t("usage.quota.description")) + .option("--provider ", t("usage.quota.provider")) + .option("--check", t("usage.quota.check")) + .action(runUsageQuota); + + // logs + usage + .command("logs") + .description(t("usage.logs.description")) + .option("--limit ", t("usage.logs.limit"), parseInt, 100) + .option("--search ", t("usage.logs.search")) + .option("--since ", t("usage.logs.since")) + .option("--follow", t("usage.logs.follow")) + .option("--api-key ", t("usage.logs.api_key")) + .action(runUsageLogs); + + // utilization + usage + .command("utilization") + .description(t("usage.utilization.description")) + .option("--api-key ", t("usage.utilization.api_key")) + .action(runUsageUtilization); + + // history + usage + .command("history") + .description(t("usage.history.description")) + .option("--limit ", t("usage.history.limit"), parseInt, 100) + .action(runUsageHistory); + + // proxy-logs + usage + .command("proxy-logs") + .description(t("usage.proxy_logs.description")) + .option("--limit ", t("usage.proxy_logs.limit"), parseInt, 100) + .action(runUsageProxyLogs); +} + +export async function runUsageAnalytics(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const p = new URLSearchParams({ range: opts.period ?? "30d" }); + if (opts.provider) p.set("provider", opts.provider); + const res = await fetchOrExit(`/api/usage/analytics?${p}`, globalOpts); + const data = await res.json(); + const rows = toArray(data.byProvider ?? data.providers ?? []).map((r) => ({ + provider: r.provider ?? r.providerId ?? "", + requests: r.totalRequests ?? r.requests ?? 0, + tokensIn: r.totalTokensIn ?? r.tokensIn ?? 0, + tokensOut: r.totalTokensOut ?? r.tokensOut ?? 0, + costUsd: r.totalCost ?? r.cost ?? r.costUsd ?? 0, + })); + emit(rows, globalOpts, analyticsSchema); +} + +export async function runBudgetList(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const res = await fetchOrExit("/api/usage/budget", globalOpts); + const data = await res.json(); + const rows = normalizeBudgetRows(data); + emit(rows, globalOpts, budgetSchema); +} + +export async function runBudgetGet(scope, opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const p = new URLSearchParams(); + if (scope) p.set("scope", scope); + const res = await fetchOrExit(`/api/usage/budget?${p}`, globalOpts); + const data = await res.json(); + const rows = normalizeBudgetRows(data); + emit(rows, globalOpts, budgetSchema); +} + +export async function runBudgetSet(amount, opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const res = await apiFetch("/api/usage/budget", { + method: "POST", + body: { + amount: Number(amount), + scope: opts.scope ?? "global", + period: opts.period ?? "monthly", + }, + timeout: globalOpts.timeout, + acceptNotOk: true, + }); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + process.stderr.write(`[error] HTTP ${res.status}: ${txt.slice(0, 200)}\n`); + process.exit(res.exitCode ?? 1); + } + if (!globalOpts.quiet) + process.stdout.write( + `Budget set: $${Number(amount).toFixed(2)} / ${opts.scope ?? "global"} / ${opts.period ?? "monthly"}\n` + ); +} + +export async function runBudgetReset(scope, opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const res = await apiFetch("/api/usage/budget", { + method: "DELETE", + body: { scope: scope ?? "global" }, + timeout: globalOpts.timeout, + acceptNotOk: true, + }); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + process.stderr.write(`[error] HTTP ${res.status}: ${txt.slice(0, 200)}\n`); + process.exit(res.exitCode ?? 1); + } + if (!globalOpts.quiet) process.stdout.write(`Budget reset: ${scope ?? "global"}\n`); +} + +export async function runUsageQuota(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const p = new URLSearchParams(); + if (opts.provider) p.set("provider", opts.provider); + if (opts.check) p.set("check", "true"); + const res = await fetchOrExit(`/api/usage/quota?${p}`, globalOpts); + const data = await res.json(); + const rows = toArray(data.providers ?? data.data ?? (Array.isArray(data) ? data : [])).map( + (r) => ({ + provider: r.provider ?? r.providerId ?? "", + limit: r.limit ?? r.quota ?? r.maxTokens ?? null, + used: r.used ?? r.tokensUsed ?? null, + remaining: r.remaining ?? r.percentRemaining ?? null, + resetAt: r.resetAt ?? r.nextReset ?? null, + state: r.state ?? (r.percentRemaining > 0 ? "available" : "exhausted"), + }) + ); + emit(rows, globalOpts, quotaSchema); +} + +export async function runUsageLogs(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + + if (opts.follow) { + await followLogs(opts, globalOpts); + return; + } + + const p = buildLogParams(opts); + const res = await fetchOrExit(`/api/usage/call-logs?${p}`, globalOpts); + const data = await res.json(); + const rows = toLogRows(toArray(data.logs ?? data.items ?? data)); + emit(rows, globalOpts, logsSchema); +} + +export async function runUsageUtilization(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const p = new URLSearchParams(); + if (opts.apiKey) p.set("apiKey", opts.apiKey); + const res = await fetchOrExit(`/api/usage/utilization?${p}`, globalOpts); + const data = await res.json(); + const rows = Array.isArray(data) ? data : toArray(data.data ?? data.items ?? [data]); + emit(rows, globalOpts, null); +} + +export async function runUsageHistory(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const p = new URLSearchParams({ limit: String(opts.limit ?? 100) }); + const res = await fetchOrExit(`/api/usage/history?${p}`, globalOpts); + const data = await res.json(); + const rows = toArray(data.items ?? data.history ?? (Array.isArray(data) ? data : [])); + emit(rows, globalOpts, null); +} + +export async function runUsageProxyLogs(opts, cmd) { + const globalOpts = cmd.optsWithGlobals(); + const p = new URLSearchParams({ limit: String(opts?.limit ?? 100) }); + const res = await fetchOrExit(`/api/usage/proxy-logs?${p}`, globalOpts); + const data = await res.json(); + const rows = toArray(data.logs ?? data.items ?? (Array.isArray(data) ? data : [])); + emit(rows, globalOpts, null); +} + +async function followLogs(opts, globalOpts) { + let lastId = null; + process.stderr.write("[following logs — press Ctrl+C to stop]\n"); + const sigint = () => process.exit(0); + process.on("SIGINT", sigint); + try { + while (true) { + const p = buildLogParams({ ...opts, limit: opts.limit ?? 20 }); + if (lastId) p.append("afterId", String(lastId)); + const res = await apiFetch(`/api/usage/call-logs?${p}`, { + timeout: globalOpts.timeout, + acceptNotOk: true, + }); + if (res.ok) { + const data = await res.json(); + const rows = toLogRows(toArray(data.logs ?? data.items ?? data)); + if (rows.length > 0) { + emit(rows, { ...globalOpts, quiet: true }, logsSchema); + lastId = rows[rows.length - 1]?.id ?? lastId; + } + } + await sleep(2000); + } + } finally { + process.off("SIGINT", sigint); + } +} + +function buildLogParams(opts) { + const p = new URLSearchParams({ limit: String(opts.limit ?? 100) }); + if (opts.search) p.set("search", opts.search); + if (opts.since) p.set("since", opts.since); + if (opts.apiKey) p.set("apiKey", opts.apiKey); + return p; +} + +function toLogRows(items) { + return items.map((r) => ({ + id: r.id, + timestamp: r.createdAt ?? r.timestamp ?? r.ts, + apiKey: r.apiKey ?? r.keyId ?? r.apiKeyId, + method: r.method ?? "POST", + provider: r.provider ?? r.providerId, + model: r.model ?? r.modelId, + tokens: (r.tokensIn ?? r.promptTokens ?? 0) + (r.tokensOut ?? r.completionTokens ?? 0), + costUsd: r.cost ?? r.costUsd ?? r.totalCost, + latencyMs: r.latencyMs ?? r.durationMs, + status: r.status ?? r.statusCode, + })); +} + +function normalizeBudgetRows(data) { + const items = toArray(data.budgets ?? data.items ?? (Array.isArray(data) ? data : [data])); + return items.map((r) => ({ + scope: r.scope ?? r.scopeId ?? "global", + period: r.period ?? "monthly", + limit: r.limit ?? r.amount ?? 0, + used: r.used ?? r.spent ?? 0, + remaining: r.remaining ?? Math.max(0, (r.limit ?? 0) - (r.used ?? 0)), + pct: r.pct ?? (r.limit > 0 ? (r.used ?? 0) / r.limit : 0), + })); +} + +async function fetchOrExit(path, globalOpts) { + const res = await apiFetch(path, { timeout: globalOpts.timeout, acceptNotOk: true }); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + process.stderr.write(t("common.authRequired") + "\n"); + } else { + process.stderr.write(t("common.serverOffline") + "\n"); + } + process.exit(res.exitCode ?? 1); + } + return res; +} + +function toArray(val) { + return Array.isArray(val) ? val : []; +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 9b2d38b33d..f090385c3e 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -70,6 +70,46 @@ "empty_prompt": "Error: prompt is required (positional arg, --file, or --stdin)" } }, + "usage": { + "description": "Usage analytics, budgets, quotas, and logs", + "analytics": { + "description": "Show aggregated usage analytics", + "period": "Time range: 1d|7d|30d|90d|ytd|all (default: 30d)", + "provider": "Filter by provider ID" + }, + "budget": { + "description": "Manage cost budgets", + "set": { + "scope": "Budget scope (default: global)", + "period": "Budget period: daily|weekly|monthly (default: monthly)" + } + }, + "quota": { + "description": "Show provider quota usage", + "provider": "Filter by provider ID", + "check": "Show whether quota is available for a new request" + }, + "logs": { + "description": "Show request call logs", + "limit": "Number of log entries to return (default: 100)", + "search": "Search query to filter logs", + "since": "Return logs since this timestamp", + "follow": "Continuously tail new log entries", + "api_key": "Filter logs by API key" + }, + "utilization": { + "description": "Show API key utilization metrics", + "api_key": "Filter by API key" + }, + "history": { + "description": "Show request history", + "limit": "Number of history entries (default: 100)" + }, + "proxy_logs": { + "description": "Show proxy-level request logs", + "limit": "Number of proxy log entries (default: 100)" + } + }, "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)", diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 0bfd8cf6df..9bd15feeb1 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -70,6 +70,46 @@ "empty_prompt": "Erro: prompt obrigatório (argumento posicional, --file ou --stdin)" } }, + "usage": { + "description": "Analytics de uso, budgets, quotas e logs", + "analytics": { + "description": "Exibir analytics de uso agregados", + "period": "Período: 1d|7d|30d|90d|ytd|all (padrão: 30d)", + "provider": "Filtrar por ID de provedor" + }, + "budget": { + "description": "Gerenciar budgets de custo", + "set": { + "scope": "Escopo do budget (padrão: global)", + "period": "Período do budget: daily|weekly|monthly (padrão: monthly)" + } + }, + "quota": { + "description": "Exibir uso de quota dos provedores", + "provider": "Filtrar por ID de provedor", + "check": "Mostrar se há quota disponível para uma nova requisição" + }, + "logs": { + "description": "Exibir logs de chamadas", + "limit": "Número de entradas de log a retornar (padrão: 100)", + "search": "Filtro de busca", + "since": "Retornar logs a partir deste timestamp", + "follow": "Fazer tail contínuo de novos logs", + "api_key": "Filtrar logs por chave de API" + }, + "utilization": { + "description": "Exibir métricas de utilização por chave de API", + "api_key": "Filtrar por chave de API" + }, + "history": { + "description": "Exibir histórico de requisições", + "limit": "Número de entradas de histórico (padrão: 100)" + }, + "proxy_logs": { + "description": "Exibir logs de requisições em nível de proxy", + "limit": "Número de entradas de proxy log (padrão: 100)" + } + }, "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)", diff --git a/tests/unit/cli-usage.test.ts b/tests/unit/cli-usage.test.ts new file mode 100644 index 0000000000..e0567b4b70 --- /dev/null +++ b/tests/unit/cli-usage.test.ts @@ -0,0 +1,219 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const ANALYTICS_DATA = { + byProvider: [ + { + provider: "openai", + totalRequests: 100, + totalTokensIn: 40000, + totalTokensOut: 16000, + totalCost: 0.35, + }, + { + provider: "anthropic", + totalRequests: 50, + totalTokensIn: 20000, + totalTokensOut: 8000, + totalCost: 0.15, + }, + ], +}; +const BUDGET_DATA = { + budgets: [ + { scope: "global", period: "monthly", limit: 100, used: 42.5, remaining: 57.5, pct: 0.425 }, + ], +}; +const QUOTA_DATA = { + providers: [ + { provider: "openai", limit: 1000000, used: 500000, remaining: 500000, state: "available" }, + ], +}; +const LOGS_DATA = { + logs: [ + { + id: "1", + createdAt: "2026-05-15T10:00:00Z", + apiKey: "sk-test-key", + provider: "openai", + model: "gpt-4o", + tokensIn: 100, + tokensOut: 50, + cost: 0.001, + latencyMs: 500, + status: 200, + }, + { + id: "2", + createdAt: "2026-05-15T10:01:00Z", + apiKey: "sk-test-key", + provider: "anthropic", + model: "claude-3-5-sonnet", + tokensIn: 80, + tokensOut: 40, + cost: 0.0008, + latencyMs: 400, + status: 200, + }, + ], +}; +const UTILIZATION_DATA = [{ apiKey: "sk-test-key", requests: 150, cost: 0.5, avgLatency: 450 }]; +const HISTORY_DATA = { items: [{ id: "a", model: "gpt-4o", provider: "openai", cost: 0.01 }] }; +const PROXY_LOGS_DATA = { + logs: [{ id: "p1", path: "/v1/chat/completions", method: "POST", status: 200 }], +}; + +function makeResp(data: unknown, status = 200) { + const obj = { + ok: status < 400, + status, + exitCode: status < 400 ? 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(overrides: Record = {}) { + return (url: string) => { + if (url.includes("/api/usage/analytics")) + return Promise.resolve(makeResp(overrides.analytics ?? ANALYTICS_DATA)); + if (url.includes("/api/usage/budget")) + return Promise.resolve(makeResp(overrides.budget ?? BUDGET_DATA)); + if (url.includes("/api/usage/quota")) + return Promise.resolve(makeResp(overrides.quota ?? QUOTA_DATA)); + if (url.includes("/api/usage/call-logs")) + return Promise.resolve(makeResp(overrides.logs ?? LOGS_DATA)); + if (url.includes("/api/usage/utilization")) + return Promise.resolve(makeResp(overrides.utilization ?? UTILIZATION_DATA)); + if (url.includes("/api/usage/history")) + return Promise.resolve(makeResp(overrides.history ?? HISTORY_DATA)); + if (url.includes("/api/usage/proxy-logs")) + return Promise.resolve(makeResp(overrides.proxyLogs ?? PROXY_LOGS_DATA)); + return Promise.resolve(makeResp({}, 404)); + }; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const orig = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: string | Uint8Array) => { + chunks.push(typeof c === "string" ? c : c.toString()); + return true; + }; + try { + await fn(); + } finally { + process.stdout.write = orig; + } + return chunks.join(""); +} + +test("runUsageAnalytics exibe providers em json", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch() as any; + + const { runUsageAnalytics } = await import("../../bin/cli/commands/usage.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) }; + const out = await captureStdout(() => runUsageAnalytics({ period: "30d" }, cmd as any)); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed[0].provider, "openai"); + assert.ok(parsed[0].costUsd > 0); +}); + +test("runBudgetList exibe budgets", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch() as any; + + const { runBudgetList } = await import("../../bin/cli/commands/usage.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) }; + const out = await captureStdout(() => runBudgetList({}, cmd as any)); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed[0].scope, "global"); + assert.ok(parsed[0].limit > 0); +}); + +test("runUsageQuota exibe providers de quota", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch() as any; + + const { runUsageQuota } = await import("../../bin/cli/commands/usage.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) }; + const out = await captureStdout(() => runUsageQuota({}, cmd as any)); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed[0].provider, "openai"); +}); + +test("runUsageLogs exibe logs com mascaramento de API key", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch() as any; + + const { runUsageLogs } = await import("../../bin/cli/commands/usage.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "table", quiet: false }) }; + const out = await captureStdout(() => runUsageLogs({ limit: 10 }, cmd as any)); + + globalThis.fetch = origFetch; + assert.ok(!out.includes("sk-test-key") || out.includes("***")); +}); + +test("runUsageLogs --output json retorna rows com campos esperados", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch() as any; + + const { runUsageLogs } = await import("../../bin/cli/commands/usage.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) }; + const out = await captureStdout(() => runUsageLogs({ limit: 10 }, cmd as any)); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.equal(parsed.length, 2); + assert.ok(typeof parsed[0].provider === "string"); + assert.ok(typeof parsed[0].tokens === "number"); +}); + +test("runUsageHistory exibe histórico", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = mockFetch() as any; + + const { runUsageHistory } = await import("../../bin/cli/commands/usage.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "json", quiet: true }) }; + const out = await captureStdout(() => runUsageHistory({ limit: 50 }, cmd as any)); + + globalThis.fetch = origFetch; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); + assert.ok(parsed.length >= 1); +}); + +test("runBudgetSet envia POST com amount, scope e period", async () => { + let capturedBody: unknown = null; + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string, init: any) => { + if (url.includes("/api/usage/budget") && init?.method === "POST") { + capturedBody = JSON.parse(init.body); + } + return Promise.resolve(makeResp({ ok: true })); + }) as any; + + const { runBudgetSet } = await import("../../bin/cli/commands/usage.mjs"); + const cmd = { optsWithGlobals: () => ({ output: "table", quiet: false }) }; + await captureStdout(() => runBudgetSet("50", { scope: "global", period: "monthly" }, cmd as any)); + + globalThis.fetch = origFetch; + assert.ok(capturedBody !== null); + assert.equal((capturedBody as any).amount, 50); + assert.equal((capturedBody as any).scope, "global"); +});