import { channelKey, type PriceBook, type PriceSource } from "./pricing.ts"; import type { UsageRecord } from "./scan.ts"; export type Range = "today" | "7d" | "30d" | "all" | { from: number; to: number }; export type GroupDim = "project" | "model"; export type SortKey = "cost" | "input" | "output" | "cacheRead" | "hitRate"; export interface Row { /** 唯一标识,用于记录展开状态 */ key: string; label: string; /** 次要说明,目前用于会话层的首条消息摘要 */ sub?: string; input: number; output: number; cacheRead: number; cacheWrite: number; /** 缓存命中率;分母为 0 时为 null,渲染成 "—" 而非 NaN */ hitRate: number | null; /** 可计价部分的金额之和;组内没有任何可计价记录时为 null——null 不等于 0 */ cost: number | null; /** 未能计价的 token 量。大于 0 说明该行金额不完整 */ unpricedTokens: number; children?: Row[]; } export function filterByRange(records: UsageRecord[], range: Range, now = Date.now()): UsageRecord[] { if (range === "all") return records; if (typeof range === "object") { return records.filter((r) => r.ts >= range.from && r.ts <= range.to); } let from: number; if (range === "today") { const d = new Date(now); d.setHours(0, 0, 0, 0); from = d.getTime(); } else { from = now - (range === "7d" ? 7 : 30) * 86_400_000; } return records.filter((r) => r.ts >= from); } /** 项目在表格里显示末两段路径:全路径太长,只有 basename 又会让同名项目混淆 */ export function shortProject(path: string): string { const parts = path.split("/").filter(Boolean); return parts.slice(-2).join("/") || path; } function modelKey(r: UsageRecord): string { return channelKey(r.provider, r.model); } function pad2(n: number): string { return String(n).padStart(2, "0"); } function sessionLabel(r: UsageRecord): string { if (!r.ts) return r.sessionId.slice(0, 8) || "unknown"; const d = new Date(r.ts); return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } interface Dimension { keyOf: (r: UsageRecord) => string; labelOf: (r: UsageRecord) => string; subOf?: (r: UsageRecord) => string; } const PROJECT_DIM: Dimension = { keyOf: (r) => r.project, labelOf: (r) => shortProject(r.project) }; const MODEL_DIM: Dimension = { keyOf: modelKey, labelOf: modelKey }; const SESSION_DIM: Dimension = { keyOf: (r) => r.sessionFile, labelOf: sessionLabel, subOf: (r) => r.sessionTitle, }; export interface Totals { input: number; output: number; cacheRead: number; cacheWrite: number; hitRate: number | null; cost: number | null; unpricedTokens: number; } /** * 逐条计价后累加。绝不能先把 token 汇总再乘单价——阶梯档位按单次请求的 * prompt 量判定,汇总后的量必然落在最高档,金额会被系统性抬高。 */ export function sumRecords(records: UsageRecord[], book: PriceBook): Totals { let input = 0; let output = 0; let cacheRead = 0; let cacheWrite = 0; let cost = 0; let priced = 0; let unpricedTokens = 0; for (const r of records) { input += r.input; output += r.output; cacheRead += r.cacheRead; cacheWrite += r.cacheWrite; const c = book.cost(r); if (c === null) unpricedTokens += r.input + r.output + r.cacheRead + r.cacheWrite; else { cost += c; priced++; } } const promptTokens = input + cacheRead; return { input, output, cacheRead, cacheWrite, // output 不属于 prompt,不参与命中率分母 hitRate: promptTokens > 0 ? cacheRead / promptTokens : null, cost: priced > 0 ? cost : null, unpricedTokens, }; } function makeRow(key: string, records: UsageRecord[], dim: Dimension, book: PriceBook): Row { const first = records[0]!; return { key, label: dim.labelOf(first), sub: dim.subOf?.(first), ...sumRecords(records, book), }; } function buildLevel( records: UsageRecord[], dims: Dimension[], prefix: string, sortKey: SortKey, book: PriceBook, ): Row[] { const [dim, ...rest] = dims; if (!dim) return []; const groups = new Map(); for (const r of records) { const k = dim.keyOf(r); const bucket = groups.get(k); if (bucket) bucket.push(r); else groups.set(k, [r]); } const rows: Row[] = []; for (const [k, rs] of groups) { const row = makeRow(`${prefix}${k}`, rs, dim, book); if (rest.length > 0) row.children = buildLevel(rs, rest, `${row.key} `, sortKey, book); rows.push(row); } return sortRows(rows, sortKey); } export function sortRows(rows: Row[], key: SortKey): Row[] { return [...rows].sort((a, b) => { if (key === "hitRate" || key === "cost") { // 无值的行沉底,避免 null 混在数值中间 const av = a[key]; const bv = b[key]; if (av === null && bv === null) return 0; if (av === null) return 1; if (bv === null) return -1; return bv - av; } return b[key] - a[key]; }); } /** * 构建三层下钻树。层级对称于当前分组维度: * 分组=项目 → 项目 › 模型 › 会话;分组=模型 → 模型 › 项目 › 会话 */ export function buildTree( records: UsageRecord[], groupBy: GroupDim, sortKey: SortKey, book: PriceBook, ): Row[] { const dims = groupBy === "project" ? [PROJECT_DIM, MODEL_DIM, SESSION_DIM] : [MODEL_DIM, PROJECT_DIM, SESSION_DIM]; return buildLevel(records, dims, "", sortKey, book); } export interface ChannelInfo { channel: string; provider: string; model: string; tokens: number; source: PriceSource | null; borrowedFrom?: string; rate: { input: number; output: number; cacheRead: number; cacheWrite: number } | null; hasTiers: boolean; cost: number | null; } /** 定价视图与底部说明的数据源:当前时间窗内用到的每个渠道及其生效单价 */ export function channelSummary(records: UsageRecord[], book: PriceBook): ChannelInfo[] { const groups = new Map(); for (const r of records) { const k = modelKey(r); const bucket = groups.get(k); if (bucket) bucket.push(r); else groups.set(k, [r]); } const out: ChannelInfo[] = []; for (const [channel, rs] of groups) { const first = rs[0]!; const resolved = book.resolve(first.provider, first.model); const totals = sumRecords(rs, book); out.push({ channel, provider: first.provider, model: first.model, tokens: totals.input + totals.output + totals.cacheRead + totals.cacheWrite, source: resolved?.source ?? null, borrowedFrom: resolved?.borrowedFrom, rate: resolved ? { input: resolved.rate.input, output: resolved.rate.output, cacheRead: resolved.rate.cacheRead, cacheWrite: resolved.rate.cacheWrite, } : null, hasTiers: (resolved?.rate.tiers?.length ?? 0) > 0, cost: totals.cost, }); } return out.sort((a, b) => b.tokens - a.tokens); } /** 表格是否需要 cacheWrite 列:全为 0 时隐藏,避免占用终端宽度 */ export function hasCacheWrite(records: UsageRecord[]): boolean { return records.some((r) => r.cacheWrite > 0); }