Files
2026-07-13 13:39:12 +08:00

145 lines
4.5 KiB
JavaScript

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;
}