mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
feat(cli): adicionar grupo usage com 7 subcomandos (Fase 2.5)
Implementa `omniroute usage` com subcomandos: - analytics: agregados por provedor com filtro --period/--provider - budget list|get|set|reset: gerenciamento de budgets de custo - quota: estado de quota por provedor com --check - logs: call-logs com --search, --since, --api-key e --follow (tail 2s) - utilization: métricas de uso por API key - history: histórico de requisições - proxy-logs: logs em nível de proxy API keys mascaradas em outputs human. Todos os subcomandos suportam --output json/table/csv/jsonl via emit().
This commit is contained in:
@@ -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);
|
||||
|
||||
331
bin/cli/commands/usage.mjs
Normal file
331
bin/cli/commands/usage.mjs
Normal file
@@ -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 <range>", t("usage.analytics.period"), "30d")
|
||||
.option("--provider <id>", 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 <amount>")
|
||||
.option("--scope <s>", t("usage.budget.set.scope"), "global")
|
||||
.option("--period <p>", 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 <id>", t("usage.quota.provider"))
|
||||
.option("--check", t("usage.quota.check"))
|
||||
.action(runUsageQuota);
|
||||
|
||||
// logs
|
||||
usage
|
||||
.command("logs")
|
||||
.description(t("usage.logs.description"))
|
||||
.option("--limit <n>", t("usage.logs.limit"), parseInt, 100)
|
||||
.option("--search <q>", t("usage.logs.search"))
|
||||
.option("--since <ts>", t("usage.logs.since"))
|
||||
.option("--follow", t("usage.logs.follow"))
|
||||
.option("--api-key <k>", t("usage.logs.api_key"))
|
||||
.action(runUsageLogs);
|
||||
|
||||
// utilization
|
||||
usage
|
||||
.command("utilization")
|
||||
.description(t("usage.utilization.description"))
|
||||
.option("--api-key <k>", t("usage.utilization.api_key"))
|
||||
.action(runUsageUtilization);
|
||||
|
||||
// history
|
||||
usage
|
||||
.command("history")
|
||||
.description(t("usage.history.description"))
|
||||
.option("--limit <n>", t("usage.history.limit"), parseInt, 100)
|
||||
.action(runUsageHistory);
|
||||
|
||||
// proxy-logs
|
||||
usage
|
||||
.command("proxy-logs")
|
||||
.description(t("usage.proxy_logs.description"))
|
||||
.option("--limit <n>", 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 : [];
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
219
tests/unit/cli-usage.test.ts
Normal file
219
tests/unit/cli-usage.test.ts
Normal file
@@ -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<string, unknown> = {}) {
|
||||
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<void>): Promise<string> {
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user