From 6bd180bb7b7cba0a0db97f78c23df84c2bcb743b Mon Sep 17 00:00:00 2001 From: moranzhi Date: Mon, 14 Sep 2026 01:14:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AF=BC=E5=85=A5=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E6=96=87=E6=A1=A3=E5=B9=B6=E5=9C=A8=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=A1=B5=E4=BC=B0=E7=AE=97=E8=B4=B9=E7=94=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把用量与单价对齐,方便对照各模型实际开销。 Co-authored-by: Cursor --- src/server/stats-handlers.ts | 159 ++++++++- src/stats/pricing-cost.ts | 176 ++++++++++ src/stats/pricing-parse.ts | 615 +++++++++++++++++++++++++++++++++++ src/stats/pricing.ts | 275 ++++++++++++++++ src/stats/token-store.ts | 7 +- tests/pricing.test.ts | 299 +++++++++++++++++ web/stats.css | 191 ++++++++++- web/stats.html | 54 ++- web/stats.js | 262 ++++++++++++++- 9 files changed, 1999 insertions(+), 39 deletions(-) create mode 100644 src/stats/pricing-cost.ts create mode 100644 src/stats/pricing-parse.ts create mode 100644 src/stats/pricing.ts create mode 100644 tests/pricing.test.ts diff --git a/src/server/stats-handlers.ts b/src/server/stats-handlers.ts index 2a85d7a..56b3950 100644 --- a/src/server/stats-handlers.ts +++ b/src/server/stats-handlers.ts @@ -1,37 +1,160 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { readRecords, - summarizeTokenUsage, + summarizeRecords, type TokenStatsQuery, } from "../stats/token-store.js"; +import { + addPricingSource, + deletePricingSource, + getPricingSource, + listPricingSources, + refreshPricingSource, + resolveRateCatalog, + SUGGESTED_PRICING_DOCS, +} from "../stats/pricing.js"; +import { attachRecordCost, buildCostReport } from "../stats/pricing-cost.js"; + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} function json(res: ServerResponse, status: number, data: unknown): void { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify(data)); } +function publicSource(source: ReturnType[number]) { + return { + id: source.id, + name: source.name, + url: source.url, + createdAt: source.createdAt, + fetchedAt: source.fetchedAt ?? null, + fetchError: source.fetchError ?? null, + title: source.title ?? null, + modelCount: source.rates.length, + rates: source.rates.map((r) => ({ + model: r.model, + aliases: r.aliases, + currency: r.currency, + unitTokens: r.unitTokens, + inputCacheHit: r.inputCacheHit ?? null, + inputCacheMiss: r.inputCacheMiss, + output: r.output, + })), + }; +} + export async function handleStatsApi( - _req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, pathname: string, searchParams: URLSearchParams, ): Promise { - if (pathname !== "/api/stats/tokens") return false; + if (!pathname.startsWith("/api/stats/")) return false; - const query: TokenStatsQuery = { - bookId: searchParams.get("bookId") ?? undefined, - orchestratorId: searchParams.get("orchestratorId") ?? undefined, - sessionId: searchParams.get("sessionId") ?? undefined, - from: searchParams.get("from") ?? undefined, - to: searchParams.get("to") ?? undefined, - limit: searchParams.get("limit") - ? Number(searchParams.get("limit")) - : undefined, - }; + if (pathname === "/api/stats/tokens") { + if (req.method && req.method !== "GET") { + json(res, 405, { error: "Method Not Allowed" }); + return true; + } + const query: TokenStatsQuery = { + bookId: searchParams.get("bookId") ?? undefined, + orchestratorId: searchParams.get("orchestratorId") ?? undefined, + sessionId: searchParams.get("sessionId") ?? undefined, + from: searchParams.get("from") ?? undefined, + to: searchParams.get("to") ?? undefined, + limit: searchParams.get("limit") + ? Number(searchParams.get("limit")) + : undefined, + }; + const summaryRecords = readRecords({ ...query, limit: query.limit ?? 2000 }); + const records = readRecords({ ...query, limit: query.limit ?? 100 }); + const catalog = resolveRateCatalog(); + const cost = buildCostReport(summaryRecords, catalog); + json(res, 200, { + summary: { + ...summarizeRecords(summaryRecords), + estimatedCost: cost.total, + unpricedCalls: cost.unpricedCalls, + byCurrency: cost.byCurrency, + byModel: cost.byModel, + }, + cost, + records: records.map((r) => { + const priced = attachRecordCost(r, catalog); + return { + ...r, + estimatedCost: priced.cost, + unmatchedRate: priced.unmatched, + }; + }), + }); + return true; + } - json(res, 200, { - summary: summarizeTokenUsage(query), - records: readRecords({ ...query, limit: query.limit ?? 100 }), - }); - return true; + if (pathname === "/api/stats/pricing") { + if (req.method !== "GET") { + json(res, 405, { error: "Method Not Allowed" }); + return true; + } + json(res, 200, { + sources: listPricingSources().map(publicSource), + suggested: SUGGESTED_PRICING_DOCS, + }); + return true; + } + + if (pathname === "/api/stats/pricing/sources" && req.method === "POST") { + try { + const body = JSON.parse(await readBody(req)) as { url?: string; name?: string }; + if (!body.url?.trim()) { + json(res, 400, { error: "请填写官方价目文档 URL" }); + return true; + } + const source = await addPricingSource({ url: body.url, name: body.name }); + json(res, 201, { source: publicSource(source) }); + } catch (err) { + json(res, 400, { error: err instanceof Error ? err.message : "添加失败" }); + } + return true; + } + + const refreshMatch = pathname.match( + /^\/api\/stats\/pricing\/sources\/([^/]+)\/refresh$/, + ); + if (refreshMatch && req.method === "POST") { + const id = decodeURIComponent(refreshMatch[1]); + if (!getPricingSource(id)) { + json(res, 404, { error: "价目来源不存在" }); + return true; + } + try { + const source = await refreshPricingSource(id); + json(res, 200, { + source: publicSource(source), + }); + } catch (err) { + json(res, 400, { error: err instanceof Error ? err.message : "更新失败" }); + } + return true; + } + + const sourceMatch = pathname.match(/^\/api\/stats\/pricing\/sources\/([^/]+)$/); + if (sourceMatch && req.method === "DELETE") { + const id = decodeURIComponent(sourceMatch[1]); + if (!deletePricingSource(id)) { + json(res, 404, { error: "价目来源不存在" }); + return true; + } + json(res, 200, { ok: true }); + return true; + } + + return false; } diff --git a/src/stats/pricing-cost.ts b/src/stats/pricing-cost.ts new file mode 100644 index 0000000..9b8d7e5 --- /dev/null +++ b/src/stats/pricing-cost.ts @@ -0,0 +1,176 @@ +import type { TokenUsageRecord } from "./token-store.js"; +import { + findModelRate, + resolveRateCatalog, + type ResolvedRate, +} from "./pricing.js"; +import type { PeakWindow, RateBand } from "./pricing-parse.js"; + +export type Money = { + amount: number; + currency: string; +}; + +export type RecordCost = { + cost: Money | null; + unmatched: boolean; + sourceName?: string; +}; + +export type ModelCostRow = { + model: string; + calls: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + cachedTokens: number; + cacheMissTokens: number; + cost: Money | null; + unmatched: boolean; + sourceName?: string; + sourceUrl?: string; +}; + +export type CostReport = { + total: Money | null; + byCurrency: Record; + unpricedCalls: number; + pricedCalls: number; + byModel: ModelCostRow[]; +}; + +export function isPeakAt( + at: Date, + windows: PeakWindow[] | undefined, + weekdays: number[] | undefined, +): boolean { + if (!windows?.length) return false; + if (weekdays?.length && !weekdays.includes(at.getUTCDay())) return false; + const minutes = at.getUTCHours() * 60 + at.getUTCMinutes(); + return windows.some((w) => minutes >= w.startMinutes && minutes < w.endMinutes); +} + +export function splitPromptTokens(record: TokenUsageRecord): { + cached: number; + miss: number; + output: number; +} { + const cached = Math.max(0, record.cachedTokens ?? 0); + let miss = record.cacheMissTokens; + if (miss == null) { + miss = Math.max(0, record.promptTokens - cached); + } + const covered = cached + miss; + if (covered < record.promptTokens) { + miss += record.promptTokens - covered; + } + return { cached, miss, output: Math.max(0, record.completionTokens) }; +} + +function bandPrice(band: RateBand | undefined, peak: boolean): number { + if (!band) return 0; + if (peak && band.peak != null) return band.peak; + return band.offPeak; +} + +export function costForRecord( + record: TokenUsageRecord, + rate: ResolvedRate, +): Money { + const at = new Date(record.at); + const peak = isPeakAt(at, rate.peakWindows, rate.peakWeekdays); + const parts = splitPromptTokens(record); + const hitPrice = rate.inputCacheHit + ? bandPrice(rate.inputCacheHit, peak) + : bandPrice(rate.inputCacheMiss, peak); + const missPrice = bandPrice(rate.inputCacheMiss, peak); + const outPrice = bandPrice(rate.output, peak); + const unit = rate.unitTokens || 1_000_000; + const amount = + (parts.cached * hitPrice + parts.miss * missPrice + parts.output * outPrice) / + unit; + return { amount: Math.round(amount * 1e10) / 1e10, currency: rate.currency }; +} + +export function attachRecordCost( + record: TokenUsageRecord, + catalog = resolveRateCatalog(), +): RecordCost { + const rate = findModelRate(record.model || "", catalog); + if (!rate) return { cost: null, unmatched: true }; + return { + cost: costForRecord(record, rate), + unmatched: false, + sourceName: rate.sourceName, + }; +} + +export function buildCostReport( + records: TokenUsageRecord[], + catalog = resolveRateCatalog(), +): CostReport { + const byModel = new Map(); + const byCurrency: Record = {}; + let unpricedCalls = 0; + let pricedCalls = 0; + + for (const record of records) { + const model = record.model?.trim() || "(未填型号)"; + const rate = findModelRate(record.model || "", catalog); + const priced = rate ? costForRecord(record, rate) : null; + if (priced) { + pricedCalls += 1; + byCurrency[priced.currency] = (byCurrency[priced.currency] ?? 0) + priced.amount; + } else { + unpricedCalls += 1; + } + + const prev = byModel.get(model) ?? { + model, + calls: 0, + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cachedTokens: 0, + cacheMissTokens: 0, + cost: priced ? { amount: 0, currency: priced.currency } : null, + unmatched: !rate, + sourceName: rate?.sourceName, + sourceUrl: rate?.sourceUrl, + }; + prev.calls += 1; + prev.promptTokens += record.promptTokens; + prev.completionTokens += record.completionTokens; + prev.totalTokens += record.totalTokens; + prev.cachedTokens += record.cachedTokens ?? 0; + prev.cacheMissTokens += record.cacheMissTokens ?? 0; + if (priced) { + if (!prev.cost) prev.cost = { amount: 0, currency: priced.currency }; + if (prev.cost.currency === priced.currency) { + prev.cost.amount += priced.amount; + } + prev.unmatched = false; + } + byModel.set(model, prev); + } + + const currencies = Object.keys(byCurrency); + const total = + currencies.length === 1 + ? { amount: byCurrency[currencies[0]], currency: currencies[0] } + : null; + + const rows = [...byModel.values()].sort((a, b) => { + const ac = a.cost?.amount ?? -1; + const bc = b.cost?.amount ?? -1; + return bc - ac; + }); + + return { + total, + byCurrency, + unpricedCalls, + pricedCalls, + byModel: rows, + }; +} diff --git a/src/stats/pricing-parse.ts b/src/stats/pricing-parse.ts new file mode 100644 index 0000000..5f89760 --- /dev/null +++ b/src/stats/pricing-parse.ts @@ -0,0 +1,615 @@ +/** 从官方价目文档(HTML / Markdown / JSON)抽出按型号的单价。 */ + +export type PeakWindow = { + /** 距 UTC 当天 00:00 的分钟数,含起点不含终点 */ + startMinutes: number; + endMinutes: number; +}; + +export type RateBand = { + offPeak: number; + peak?: number; +}; + +export type ModelRate = { + model: string; + aliases: string[]; + currency: string; + unitTokens: number; + inputCacheHit?: RateBand; + inputCacheMiss: RateBand; + output: RateBand; + peakWindows?: PeakWindow[]; + /** JS getUTCDay:0 日 … 6 六。省略则每天都可能高峰 */ + peakWeekdays?: number[]; +}; + +export type ParsePricingResult = { + title?: string; + rates: ModelRate[]; + warnings: string[]; +}; + +const UNIT_1M = 1_000_000; + +const MODEL_STOP = new Set( + [ + "model", + "models", + "pricing", + "features", + "peak", + "off-peak", + "offpeak", + "input", + "output", + "tokens", + "token", + "cache", + "hit", + "miss", + "thinking", + "maximum", + "vision", + "usd", + "cny", + "base", + "url", + "openai", + "anthropic", + "format", + "version", + "concurrency", + "limit", + ].map((s) => s.toLowerCase()), +); + +export function parsePricingDocument( + text: string, + contentType = "text/html", +): ParsePricingResult { + const trimmed = text.trim(); + const type = contentType.toLowerCase(); + if (type.includes("json") || (trimmed.startsWith("{") && trimmed.includes("pricing"))) { + const jsonResult = parsePricingJson(trimmed); + if (jsonResult.rates.length) return jsonResult; + } + const html = /]/i.test(trimmed) ? parsePricingHtml(trimmed) : { rates: [], warnings: [] as string[], title: pageTitle(trimmed) }; + if (html.rates.length) { + return decorate(html, trimmed); + } + const md = parsePricingMarkdown(stripTags(trimmed)); + if (md.rates.length) return decorate(md, trimmed); + return { + title: pageTitle(trimmed), + rates: [], + warnings: ["未能从文档解析出型号价格"], + }; +} + +function decorate(result: ParsePricingResult, source: string): ParsePricingResult { + const peak = parsePeakSchedule(source); + const aliases = parseLegacyAliases(source, result.rates.map((r) => r.model)); + const rates = result.rates.map((r) => ({ + ...r, + aliases: unique([...(r.aliases ?? []), ...(aliases.get(r.model.toLowerCase()) ?? [])]), + ...(peak + ? { peakWindows: peak.windows, peakWeekdays: peak.weekdays } + : {}), + })); + return { + title: result.title ?? pageTitle(source), + rates, + warnings: result.warnings, + }; +} + +function parsePricingHtml(html: string): ParsePricingResult { + const tables = extractHtmlTables(html).map(expandTableGrid); + return extractFromGrids(tables, html); +} + +function parsePricingMarkdown(text: string): ParsePricingResult { + const tables = extractMarkdownTables(text); + return extractFromGrids(tables, text); +} + +function extractFromGrids(grids: string[][][], source: string): ParsePricingResult { + const warnings: string[] = []; + const rates: ModelRate[] = []; + const currencyHint = detectCurrency(source); + const unit = detectUnitTokens(source); + + for (const grid of grids) { + const fromMatrix = extractDeepSeekStyle(grid, currencyHint, unit); + if (fromMatrix.length) { + rates.push(...fromMatrix); + continue; + } + const generic = extractGenericHeaderTable(grid, currencyHint, unit); + rates.push(...generic); + } + + const uniqueRates = mergeRatesByModel(rates); + if (!uniqueRates.length) { + warnings.push("表格里没有识别到单价"); + } + return { title: pageTitle(source), rates: uniqueRates, warnings }; +} + +function extractDeepSeekStyle( + grid: string[][], + currencyHint: string, + unit: number, +): ModelRate[] { + let models: string[] = []; + for (const row of grid) { + const idx = row.findIndex((c) => /^model$/i.test(cleanCell(c))); + if (idx < 0) continue; + const found = row + .slice(idx + 1) + .map(cleanCell) + .filter((c) => looksLikeModelId(c)); + if (found.length) { + models = unique(found); + break; + } + } + if (!models.length) return []; + + const hit: Partial> = {}; + const miss: Partial> = {}; + const output: Partial> = {}; + let currency = currencyHint; + + for (const row of grid) { + const cells = row.map(cleanCell); + const joined = cells.join(" ").toLowerCase(); + const band = bandOf(joined); + if (!band) continue; + const money = parseMoneyCells(row); + if (money.length < models.length) continue; + const slice = money.slice(-models.length); + currency = slice[0]?.currency ?? currency; + const amounts = slice.map((m) => m.amount); + if (/cache\s*hit/.test(joined) || /缓存命中/.test(joined)) { + hit[band] = amounts; + } else if (/cache\s*miss/.test(joined) || /缓存未命中/.test(joined)) { + miss[band] = amounts; + } else if (/output/.test(joined) || /输出/.test(joined)) { + output[band] = amounts; + } + } + + if (!miss.offPeak && !output.offPeak) return []; + + return models.map((model, i) => { + const missOff = miss.offPeak?.[i] ?? miss.peak?.[i]; + const outOff = output.offPeak?.[i] ?? output.peak?.[i]; + const missPeak = miss.peak?.[i]; + const outPeak = output.peak?.[i]; + const hitOff = hit.offPeak?.[i]; + const hitPeak = hit.peak?.[i]; + const inputMiss = missOff ?? missPeak; + const out = outOff ?? outPeak; + if (inputMiss == null || out == null) { + return null; + } + const rate: ModelRate = { + model, + aliases: [], + currency, + unitTokens: unit, + inputCacheMiss: { + offPeak: inputMiss, + ...(missPeak != null && missPeak !== inputMiss ? { peak: missPeak } : {}), + }, + output: { + offPeak: out, + ...(outPeak != null && outPeak !== out ? { peak: outPeak } : {}), + }, + }; + if (hitOff != null || hitPeak != null) { + const off = hitOff ?? hitPeak!; + rate.inputCacheHit = { + offPeak: off, + ...(hitPeak != null && hitPeak !== off ? { peak: hitPeak } : {}), + }; + } + return rate; + }).filter((r): r is ModelRate => r != null); +} + +function extractGenericHeaderTable( + grid: string[][], + currencyHint: string, + unit: number, +): ModelRate[] { + if (grid.length < 2) return []; + const header = grid[0].map(cleanCell); + const kinds = header.map(headerKind); + const modelCol = kinds.indexOf("model"); + const inputCol = kinds.indexOf("input"); + const cachedCol = kinds.indexOf("cached"); + const outputCol = kinds.indexOf("output"); + if (modelCol < 0 || inputCol < 0 || outputCol < 0) return []; + + const rates: ModelRate[] = []; + for (const row of grid.slice(1)) { + const model = cleanCell(row[modelCol] ?? ""); + if (!looksLikeModelId(model)) continue; + const input = parsePriceCell(row[inputCol] ?? "", currencyHint); + const output = parsePriceCell(row[outputCol] ?? "", currencyHint); + if (!input || !output) continue; + const cached = + cachedCol >= 0 ? parsePriceCell(row[cachedCol] ?? "", currencyHint) : null; + const currency = input.currency ?? output.currency ?? currencyHint; + const rate: ModelRate = { + model, + aliases: [], + currency, + unitTokens: unit, + inputCacheMiss: { offPeak: input.amount }, + output: { offPeak: output.amount }, + }; + if (cached) rate.inputCacheHit = { offPeak: cached.amount }; + rates.push(rate); + } + return rates; +} + +function headerKind(cell: string): "model" | "input" | "cached" | "output" | "other" { + const t = cell.toLowerCase().replace(/\s+/g, " ").trim(); + if (/模型名称|^(model|型号|模型)$/i.test(t) || t === "id") return "model"; + if (/缓存存储|cache(?:d)?\s*input\s*storage/.test(t)) return "other"; + if (/cache\s*hit|cached(?:\s+input)?$|缓存命中/.test(t)) return "cached"; + if (/output|completion|输出/.test(t)) return "output"; + if (/input|prompt|cache\s*miss|输入/.test(t)) return "input"; + return "other"; +} + +function bandOf(joined: string): "offPeak" | "peak" | null { + if (/off[- ]?peak|闲时|非高峰|低峰/.test(joined)) return "offPeak"; + if (/(^|[^a-z])peak([^a-z]|$)|高峰/.test(joined)) return "peak"; + return null; +} + +export function parsePeakSchedule(text: string): { + windows: PeakWindow[]; + weekdays: number[]; +} | null { + const plain = stripTags(text).replace(/\s+/g, " "); + const windows: PeakWindow[] = []; + const utcRange = + /peak hours are\s+(\d{1,2}:\d{2})\s*[-–—]\s*(\d{1,2}:\d{2})\s+and\s+(\d{1,2}:\d{2})\s*[-–—]\s*(\d{1,2}:\d{2})\s*utc/i.exec( + plain, + ) ?? + /高峰.*?(\d{1,2}:\d{2})\s*[-–—]\s*(\d{1,2}:\d{2}).*?(\d{1,2}:\d{2})\s*[-–—]\s*(\d{1,2}:\d{2}).*utc/i.exec( + plain, + ); + if (utcRange) { + windows.push( + { startMinutes: parseHhMm(utcRange[1]), endMinutes: parseHhMm(utcRange[2]) }, + { startMinutes: parseHhMm(utcRange[3]), endMinutes: parseHhMm(utcRange[4]) }, + ); + } + if (!windows.length) return null; + const weekdays = /monday through friday|周一至周五|星期一至星期五/i.test(plain) + ? [1, 2, 3, 4, 5] + : [0, 1, 2, 3, 4, 5, 6]; + return { windows, weekdays }; +} + +export function parseLegacyAliases( + text: string, + models: string[], +): Map { + const map = new Map(); + const codes = [ + ...text.matchAll(/]*>([^<]+)<\/code>/gi), + ...text.matchAll(/`([a-zA-Z][a-zA-Z0-9._:+/-]{1,80})`/g), + ].map((m) => cleanCell(m[1])); + const plain = stripTags(text).replace(/\s+/g, " "); + const flash = models.find((m) => /flash/i.test(m)); + if ( + flash && + /legacy names[\s\S]{0,800}billed at the flash price/i.test(plain) + ) { + const legacy = codes.filter( + (c) => /flash/i.test(c) && c.toLowerCase() !== flash.toLowerCase(), + ); + if (legacy.length) map.set(flash.toLowerCase(), unique(legacy)); + } + return map; +} + +function parsePricingJson(text: string): ParsePricingResult { + try { + const data = JSON.parse(text) as unknown; + const rates: ModelRate[] = []; + const rows = jsonModelRows(data); + for (const row of rows) { + const model = String(row.id ?? row.model ?? "").trim(); + if (!model) continue; + const pricing = (row.pricing ?? row) as Record; + const prompt = jsonPrice(pricing.prompt ?? pricing.input ?? pricing.inputCacheMiss); + const completion = jsonPrice(pricing.completion ?? pricing.output); + if (prompt == null || completion == null) continue; + const cached = jsonPrice(pricing.input_cache_read ?? pricing.cached ?? pricing.inputCacheHit); + // OpenRouter 等接口用「每个 token」;官方价目表通常是每 1M。 + const perToken = prompt > 0 && prompt < 1e-4; + const rate: ModelRate = { + model: model.includes("/") ? model.split("/").pop()! : model, + aliases: model.includes("/") ? [model] : [], + currency: "USD", + unitTokens: UNIT_1M, + inputCacheMiss: { offPeak: perToken ? prompt * UNIT_1M : prompt }, + output: { offPeak: perToken ? completion * UNIT_1M : completion }, + }; + if (cached != null) { + rate.inputCacheHit = { offPeak: perToken ? cached * UNIT_1M : cached }; + } + rates.push(rate); + } + return { rates: mergeRatesByModel(rates), warnings: rates.length ? [] : ["JSON 中没有单价"] }; + } catch { + return { rates: [], warnings: ["JSON 解析失败"] }; + } +} + +function jsonModelRows(data: unknown): Array> { + if (Array.isArray(data)) return data.filter(isRecord); + if (!isRecord(data)) return []; + if (Array.isArray(data.models)) return data.models.filter(isRecord); + if (Array.isArray(data.data)) return data.data.filter(isRecord); + if (Array.isArray(data.rates)) return data.rates.filter(isRecord); + return []; +} + +function jsonPrice(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + return null; +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v != null && !Array.isArray(v); +} + +type RawCell = { text: string; colspan: number; rowspan: number }; + +function extractHtmlTables(html: string): RawCell[][][] { + const tables: RawCell[][][] = []; + const re = /]*>([\s\S]*?)<\/table>/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(html))) { + tables.push(parseHtmlTableRows(m[1])); + } + return tables; +} + +function parseHtmlTableRows(inner: string): RawCell[][] { + const rows: RawCell[][] = []; + const trRe = /]*>([\s\S]*?)<\/tr>/gi; + let tr: RegExpExecArray | null; + while ((tr = trRe.exec(inner))) { + const cells: RawCell[] = []; + const tdRe = /]*)>([\s\S]*?)<\/t[dh]>/gi; + let td: RegExpExecArray | null; + while ((td = tdRe.exec(tr[1]))) { + const attrs = td[1]; + cells.push({ + text: cellText(td[2]), + colspan: attrInt(attrs, "colspan") || 1, + rowspan: attrInt(attrs, "rowspan") || 1, + }); + } + if (cells.length) rows.push(cells); + } + return rows; +} + +function expandTableGrid(rows: RawCell[][]): string[][] { + const occupier: Array<{ left: number; text: string } | undefined> = []; + const grid: string[][] = []; + for (const row of rows) { + const out: string[] = []; + let col = 0; + let i = 0; + while (i < row.length || occupier.some((o, idx) => idx >= col && o && o.left > 0)) { + const occ = occupier[col]; + if (occ && occ.left > 0) { + out[col] = occ.text; + occ.left -= 1; + col += 1; + continue; + } + if (i >= row.length) break; + const cell = row[i++]; + for (let k = 0; k < cell.colspan; k++) { + while (occupier[col]?.left) { + out[col] = occupier[col]!.text; + occupier[col]!.left -= 1; + col += 1; + } + out[col] = cell.text; + if (cell.rowspan > 1) { + occupier[col] = { left: cell.rowspan - 1, text: cell.text }; + } + col += 1; + } + } + grid.push(out); + } + return grid; +} + +function extractMarkdownTables(text: string): string[][][] { + const lines = text.split(/\r?\n/); + const tables: string[][][] = []; + let current: string[][] = []; + const flush = () => { + if (current.length >= 2) tables.push(current); + current = []; + }; + for (const line of lines) { + if (!/^\s*\|/.test(line)) { + flush(); + continue; + } + if (/^\s*\|?\s*:?-{3,}/.test(line)) continue; + const cells = line + .replace(/^\s*\|/, "") + .replace(/\|\s*$/, "") + .split("|") + .map((c) => cleanCell(c)); + current.push(cells); + } + flush(); + return tables; +} + +function attrInt(attrs: string, name: string): number { + const m = new RegExp(`${name}\\s*=\\s*["']?(\\d+)`, "i").exec(attrs); + return m ? Number(m[1]) : 0; +} + +function cellText(raw: string): string { + return cleanCell( + raw + .replace(/]*>[\s\S]*?<\/sup>/gi, "") + .replace(//gi, " ") + .replace(/<\/(p|div|tr|h\d)>/gi, " "), + ); +} + +export function cleanCell(raw: string): string { + return decodeEntities(stripTags(raw)) + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function stripTags(html: string): string { + return html.replace(//gi, " ").replace(/<[^>]+>/g, " "); +} + +function decodeEntities(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " "); +} + +function pageTitle(text: string): string | undefined { + const m = /]*>([\s\S]*?)<\/title>/i.exec(text); + if (!m) return undefined; + return decodeEntities(m[1]).replace(/\s+/g, " ").trim() || undefined; +} + +function looksLikeModelId(s: string): boolean { + const t = s.trim(); + if (t.length < 2 || t.length > 80) return false; + if (MODEL_STOP.has(t.toLowerCase())) return false; + if (!/^[a-zA-Z][a-zA-Z0-9._:+/-]*$/.test(t)) return false; + if (/^(https?:|www\.)/i.test(t)) return false; + return /[-/._]/.test(t) || /^(gpt|o[1-9]|claude|gemini|deepseek|qwen|glm|mistral|llama)/i.test(t); +} + +function parseMoneyCells(row: string[]): Array<{ amount: number; currency: string }> { + const out: Array<{ amount: number; currency: string }> = []; + for (const cell of row) { + const m = parseMoneyCell(cell); + if (m) out.push(m); + } + return out; +} + +function parsePriceCell( + cell: string, + currencyHint: string, +): { amount: number; currency: string } | null { + const t = cleanCell(cell); + if (!t) return null; + if (/^(?:[-–—\\/]|不支持|暂不支持)$/i.test(t)) return null; + if (/^(free|免费)$/i.test(t)) return { amount: 0, currency: currencyHint }; + const money = parseMoneyCell(t); + if (money) return money; + return parseBareNumber(t, currencyHint); +} + +function parseMoneyCell(cell: string): { amount: number; currency: string } | null { + const t = cleanCell(cell); + const m = + /^[\$]\s*([0-9]+(?:\.[0-9]+)?)\b/.exec(t) || + /^[¥¥]\s*([0-9]+(?:\.[0-9]+)?)\b/.exec(t) || + /^([0-9]+(?:\.[0-9]+)?)\s*元/.exec(t); + if (!m) return null; + const amount = Number(m[1]); + if (!Number.isFinite(amount)) return null; + const currency = t.includes("¥") || t.includes("¥") || t.includes("元") ? "CNY" : "USD"; + return { amount, currency }; +} + +function parseBareNumber( + cell: string, + currencyHint = "USD", +): { amount: number; currency: string } | null { + const t = cleanCell(cell); + if (!/^[0-9]+(?:\.[0-9]+)?$/.test(t)) return null; + const amount = Number(t); + return Number.isFinite(amount) ? { amount, currency: currencyHint } : null; +} + +function detectCurrency(text: string): string { + if (/元\s*[//]\s*百万|元\/百万|人民币/.test(text)) return "CNY"; + if (/\$\s*[0-9]|prices are in usd/i.test(text)) return "USD"; + if (/[¥¥]|CNY/.test(text)) return "CNY"; + return "USD"; +} + +function detectUnitTokens(text: string): number { + if (/1\s*[Mm]\s*tokens|per\s*1M|每\s*100\s*万|百万\s*token/i.test(text)) return UNIT_1M; + return UNIT_1M; +} + +function parseHhMm(s: string): number { + const [h, m] = s.split(":").map(Number); + return h * 60 + (m || 0); +} + +function unique(xs: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const x of xs) { + const k = x.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); + out.push(x); + } + return out; +} + +function mergeRatesByModel(rates: ModelRate[]): ModelRate[] { + const map = new Map(); + for (const r of rates) { + const key = r.model.toLowerCase(); + const prev = map.get(key); + if (!prev) { + map.set(key, { ...r, aliases: unique(r.aliases) }); + continue; + } + map.set(key, { + ...prev, + ...r, + aliases: unique([...prev.aliases, ...r.aliases]), + }); + } + return [...map.values()]; +} diff --git a/src/stats/pricing.ts b/src/stats/pricing.ts new file mode 100644 index 0000000..f76bff5 --- /dev/null +++ b/src/stats/pricing.ts @@ -0,0 +1,275 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { ensureUserDataDirs, getUserDataDir } from "../config/user-data-dir.js"; +import { + parsePricingDocument, + type ModelRate, +} from "./pricing-parse.js"; + +export type PricingSource = { + id: string; + name: string; + url: string; + createdAt: string; + fetchedAt?: string; + fetchError?: string; + title?: string; + rates: ModelRate[]; +}; + +type PricingFile = { + version: 1; + sources: PricingSource[]; +}; + +const FILE_NAME = "pricing-sources.json"; +const FETCH_TIMEOUT_MS = 20_000; +const MAX_BYTES = 2_000_000; + +export const SUGGESTED_PRICING_DOCS: Array<{ name: string; url: string }> = [ + { + name: "DeepSeek Models & Pricing", + url: "https://api-docs.deepseek.com/quick_start/pricing", + }, + { + name: "DeepSeek 模型与价格", + url: "https://api-docs.deepseek.com/zh-cn/quick_start/pricing", + }, + { + name: "智谱 GLM API 定价", + url: "https://docs.bigmodel.cn/cn/guide/start/pricing", + }, + { + name: "Z.AI GLM Pricing", + url: "https://docs.z.ai/guides/overview/pricing", + }, +]; + +function filePath(): string { + return path.join(getUserDataDir(), FILE_NAME); +} + +function emptyFile(): PricingFile { + return { version: 1, sources: [] }; +} + +function readFile(): PricingFile { + ensureUserDataDirs(); + try { + const raw = readFileSync(filePath(), "utf8"); + const parsed = JSON.parse(raw) as PricingFile; + if (parsed.version !== 1 || !Array.isArray(parsed.sources)) return emptyFile(); + return parsed; + } catch { + return emptyFile(); + } +} + +function writeFile(data: PricingFile): void { + ensureUserDataDirs(); + writeFileSync(filePath(), JSON.stringify(data, null, 2), "utf8"); +} + +export function listPricingSources(): PricingSource[] { + return readFile().sources; +} + +export function getPricingSource(id: string): PricingSource | null { + return listPricingSources().find((s) => s.id === id) ?? null; +} + +export function normalizePricingUrl(raw: string): string { + const trimmed = raw.trim(); + let url: URL; + try { + url = new URL(trimmed); + } catch { + throw new Error("不是合法 URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("只支持 http/https 官方文档"); + } + url.hash = ""; + if (url.pathname.length > 1) { + url.pathname = url.pathname.replace(/\/+$/, ""); + } + return url.toString(); +} + +export async function addPricingSource(input: { + url: string; + name?: string; +}): Promise { + const url = normalizePricingUrl(input.url); + const data = readFile(); + const existing = data.sources.find((s) => s.url === url); + if (existing) { + return refreshPricingSource(existing.id); + } + const source: PricingSource = { + id: randomUUID(), + name: input.name?.trim() || hostLabel(url), + url, + createdAt: new Date().toISOString(), + rates: [], + }; + data.sources.push(source); + writeFile(data); + return refreshPricingSource(source.id); +} + +export function deletePricingSource(id: string): boolean { + const data = readFile(); + const next = data.sources.filter((s) => s.id !== id); + if (next.length === data.sources.length) return false; + writeFile({ version: 1, sources: next }); + return true; +} + +export async function refreshPricingSource(id: string): Promise { + const data = readFile(); + const source = data.sources.find((s) => s.id === id); + if (!source) throw new Error("价目来源不存在"); + try { + const parsed = await fetchAndParsePricing(source.url); + source.fetchedAt = new Date().toISOString(); + source.title = parsed.title; + source.rates = parsed.rates; + if (parsed.title && looksGenericName(source.name)) { + source.name = shortenTitle(parsed.title); + } + source.fetchError = parsed.rates.length + ? undefined + : parsed.warnings[0] || "未能从文档解析出型号价格"; + } catch (err) { + source.fetchedAt = new Date().toISOString(); + source.fetchError = err instanceof Error ? err.message : String(err); + } + writeFile(data); + return source; +} + +export type FetchedDoc = { contentType: string; text: string }; + +export function markdownDocFallbacks(url: string): string[] { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return []; + if (parsed.pathname.endsWith(".md")) return []; + const docPath = parsed.pathname.replace(/\/+$/, "") || "/"; + parsed.hash = ""; + parsed.search = ""; + parsed.pathname = `${docPath}.md`; + return [parsed.toString()]; + } catch { + return []; + } +} + +export async function fetchAndParsePricing(url: string): Promise<{ + title?: string; + rates: ModelRate[]; + warnings: string[]; +}> { + const candidates = [url, ...markdownDocFallbacks(url)]; + let lastError = "未能从文档解析出型号价格"; + let lastTitle: string | undefined; + for (const candidate of candidates) { + try { + const doc = await fetchPricingDocument(candidate); + const parsed = parsePricingDocument(doc.text, doc.contentType); + lastTitle = parsed.title ?? lastTitle; + if (parsed.rates.length) return parsed; + lastError = parsed.warnings[0] || lastError; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + } + return { title: lastTitle, rates: [], warnings: [lastError] }; +} + +export async function fetchPricingDocument(url: string): Promise { + const res = await fetch(url, { + redirect: "follow", + headers: { + Accept: "text/html, text/markdown, application/json, text/plain;q=0.9", + "User-Agent": "WritingAgent/0.1 (local token cost)", + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error(`拉取失败 HTTP ${res.status}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length > MAX_BYTES) { + throw new Error("文档过大"); + } + const contentType = res.headers.get("content-type") ?? "text/html"; + return { contentType, text: buf.toString("utf8") }; +} + +export type ResolvedRate = ModelRate & { + sourceId: string; + sourceName: string; + sourceUrl: string; +}; + +export function resolveRateCatalog(sources = listPricingSources()): { + byKey: Map; + rates: ResolvedRate[]; +} { + const byKey = new Map(); + for (const source of sources) { + for (const rate of source.rates) { + const resolved: ResolvedRate = { + ...rate, + sourceId: source.id, + sourceName: source.name, + sourceUrl: source.url, + }; + byKey.set(rate.model.toLowerCase(), resolved); + for (const alias of rate.aliases) { + byKey.set(alias.toLowerCase(), resolved); + } + } + } + const seen = new Set(); + const rates: ResolvedRate[] = []; + for (const rate of byKey.values()) { + const id = `${rate.sourceId}:${rate.model.toLowerCase()}`; + if (seen.has(id)) continue; + seen.add(id); + rates.push(rate); + } + return { byKey, rates }; +} + +export function findModelRate( + model: string, + catalog = resolveRateCatalog(), +): ResolvedRate | undefined { + const key = model.trim().toLowerCase(); + if (!key) return undefined; + const exact = catalog.byKey.get(key); + if (exact) return exact; + const tail = key.split("/").pop(); + if (tail && tail !== key) return catalog.byKey.get(tail); + return undefined; +} + +function hostLabel(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +function looksGenericName(name: string): boolean { + return /^(https?:|www\.|[a-z0-9.-]+\.[a-z]{2,}$)/i.test(name); +} + +function shortenTitle(title: string): string { + return title.replace(/\s*[·|]\s*.*$/, "").trim() || title; +} diff --git a/src/stats/token-store.ts b/src/stats/token-store.ts index 68c443e..6885983 100644 --- a/src/stats/token-store.ts +++ b/src/stats/token-store.ts @@ -129,7 +129,7 @@ export function readRecords(query: TokenStatsQuery = {}): TokenUsageRecord[] { const lines = readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean); for (let j = lines.length - 1; j >= 0 && records.length < limit; j--) { try { - const record = JSON.parse(lines[j]) as TokenUsageRecord; + const record = JSON.parse(lines[j].trim()) as TokenUsageRecord; if (query.bookId && record.bookId !== query.bookId) continue; if (query.orchestratorId && record.orchestratorId !== query.orchestratorId) { continue; @@ -171,7 +171,10 @@ function accumulateCaller( } export function summarizeTokenUsage(query: TokenStatsQuery = {}): TokenStatsSummary { - const records = readRecords({ ...query, limit: query.limit ?? 2000 }); + return summarizeRecords(readRecords({ ...query, limit: query.limit ?? 2000 })); +} + +export function summarizeRecords(records: TokenUsageRecord[]): TokenStatsSummary { const summary: TokenStatsSummary = { totalCalls: records.length, promptTokens: 0, diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts new file mode 100644 index 0000000..0096eb3 --- /dev/null +++ b/tests/pricing.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it, beforeAll } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + parseLegacyAliases, + parsePeakSchedule, + parsePricingDocument, +} from "../src/stats/pricing-parse.js"; +import { buildCostReport, costForRecord, isPeakAt, splitPromptTokens } from "../src/stats/pricing-cost.js"; +import type { ResolvedRate } from "../src/stats/pricing.js"; +import type { TokenUsageRecord } from "../src/stats/token-store.js"; + +const DEEPSEEK_HTML = ` +Models & Pricing | DeepSeek API Docs + +

The prices listed below are in units of per 1M tokens.

+ + + + + + + + + +
MODELdeepseek-flash(1)deepseek-v4-pro(2)
BASE URL (OpenAI Format)https://api.deepseek.com
PRICING(3)1M INPUT TOKENS
(CACHE HIT)
OFF-PEAK$0.003$0.022
PEAK$0.006$0.044
1M INPUT TOKENS
(CACHE MISS)
OFF-PEAK$0.15$0.66
PEAK$0.3$1.32
1M OUTPUT TOKENSOFF-PEAK$0.6$1.98
PEAK$1.2$3.96
+

(1) Use deepseek-flash as the model name. The legacy names deepseek-v4-flash and deepseek-v4-flash-vision-exp are still accepted, but the corresponding models have been retired, their requests are served by the DeepSeek-V4.1-Flash model and billed at the Flash price.

+

(3) Off-peak rates are half of the peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday (all other hours are off-peak).

+`; + +function flashRate(partial?: Partial): ResolvedRate { + return { + model: "deepseek-flash", + aliases: ["deepseek-v4-flash"], + currency: "USD", + unitTokens: 1_000_000, + inputCacheHit: { offPeak: 0.003, peak: 0.006 }, + inputCacheMiss: { offPeak: 0.15, peak: 0.3 }, + output: { offPeak: 0.6, peak: 1.2 }, + peakWindows: [ + { startMinutes: 60, endMinutes: 240 }, + { startMinutes: 360, endMinutes: 600 }, + ], + peakWeekdays: [1, 2, 3, 4, 5], + sourceId: "src", + sourceName: "DeepSeek", + sourceUrl: "https://api-docs.deepseek.com/quick_start/pricing", + ...partial, + }; +} + +describe("parsePricingDocument DeepSeek HTML", () => { + it("reads flash/pro peak and off-peak from the official table", () => { + const parsed = parsePricingDocument(DEEPSEEK_HTML, "text/html"); + expect(parsed.rates.map((r) => r.model)).toEqual([ + "deepseek-flash", + "deepseek-v4-pro", + ]); + const flash = parsed.rates[0]; + expect(flash.inputCacheHit).toEqual({ offPeak: 0.003, peak: 0.006 }); + expect(flash.inputCacheMiss).toEqual({ offPeak: 0.15, peak: 0.3 }); + expect(flash.output).toEqual({ offPeak: 0.6, peak: 1.2 }); + expect(flash.aliases).toEqual( + expect.arrayContaining(["deepseek-v4-flash", "deepseek-v4-flash-vision-exp"]), + ); + expect(flash.peakWeekdays).toEqual([1, 2, 3, 4, 5]); + expect(flash.peakWindows).toEqual([ + { startMinutes: 60, endMinutes: 240 }, + { startMinutes: 360, endMinutes: 600 }, + ]); + const pro = parsed.rates[1]; + expect(pro.inputCacheMiss.offPeak).toBe(0.66); + expect(pro.output.peak).toBe(3.96); + }); +}); + +describe("parsePricingDocument generic markdown", () => { + it("reads model / input / cached / output columns", () => { + const md = ` +| Model | Input | Cached | Output | +| --- | --- | --- | --- | +| gpt-4o | $2.50 | $1.25 | $10.00 | +`; + const parsed = parsePricingDocument(md, "text/markdown"); + expect(parsed.rates).toHaveLength(1); + expect(parsed.rates[0]).toMatchObject({ + model: "gpt-4o", + inputCacheMiss: { offPeak: 2.5 }, + inputCacheHit: { offPeak: 1.25 }, + output: { offPeak: 10 }, + }); + }); +}); + +describe("parsePricingDocument GLM official tables", () => { + it("reads Z.AI USD table including Free", () => { + const md = ` +Prices per 1M tokens. + +| Model | Input | Cached Input | Cached Input Storage | Output | +| --- | --- | --- | --- | --- | +| GLM-5.3-Flash | $0.15 | $0.03 | Limited-time Free | $0.50 | +| GLM-5.3 | $1.4 | $0.26 | Limited-time Free | $4.4 | +| GLM-4.7-Flash | Free | Free | Free | Free | +`; + const parsed = parsePricingDocument(md, "text/markdown"); + const flash = parsed.rates.find((r) => r.model === "GLM-5.3-Flash"); + expect(flash).toMatchObject({ + currency: "USD", + inputCacheMiss: { offPeak: 0.15 }, + inputCacheHit: { offPeak: 0.03 }, + output: { offPeak: 0.5 }, + }); + const free = parsed.rates.find((r) => r.model === "GLM-4.7-Flash"); + expect(free?.inputCacheMiss.offPeak).toBe(0); + expect(free?.output.offPeak).toBe(0); + }); + + it("reads 智谱 CNY 元/百万 Tokens table", () => { + const md = ` +除特别说明外,模型价格统一按照“元/百万 Tokens”展示。 + +| 模型名称 | 上下文 | 输入单价(元/百万 Tokens) | 输出单价(元/百万 Tokens) | 缓存存储(元/百万 Tokens/小时) | 缓存命中(元/百万 Tokens) | 输入模态 | +| --- | --- | --- | --- | --- | --- | --- | +| GLM-5.3 | 1M | 8 | 28 | 限时免费 | 2 | 文本 | +| GLM-5.3-Flash | 1M | 0.8 | 2.8 | 限时免费 | 0.23 | 图片、视频、文件、文本 | +`; + const parsed = parsePricingDocument(md, "text/markdown"); + const glm = parsed.rates.find((r) => r.model === "GLM-5.3"); + expect(glm).toMatchObject({ + currency: "CNY", + inputCacheMiss: { offPeak: 8 }, + inputCacheHit: { offPeak: 2 }, + output: { offPeak: 28 }, + }); + const flash = parsed.rates.find((r) => r.model === "GLM-5.3-Flash"); + expect(flash?.inputCacheMiss.offPeak).toBe(0.8); + expect(flash?.inputCacheHit?.offPeak).toBe(0.23); + }); + + it("keeps 智谱 CNY even if the HTML also contains $", () => { + const html = ` + +

模型价格统一按照“元/百万 Tokens”展示。

+ + + +
模型名称输入单价(元/百万 Tokens)输出单价(元/百万 Tokens)缓存命中(元/百万 Tokens)
GLM-5.38282
`; + const parsed = parsePricingDocument(html, "text/html"); + expect(parsed.rates[0]).toMatchObject({ + model: "GLM-5.3", + currency: "CNY", + inputCacheMiss: { offPeak: 8 }, + output: { offPeak: 28 }, + }); + }); +}); + +describe("parsePricingDocument JSON", () => { + it("converts OpenRouter per-token strings to per-million", () => { + const parsed = parsePricingDocument( + JSON.stringify({ + data: [ + { + id: "deepseek/deepseek-chat", + pricing: { prompt: "0.00000014", completion: "0.00000028" }, + }, + ], + }), + "application/json", + ); + expect(parsed.rates[0].model).toBe("deepseek-chat"); + expect(parsed.rates[0].inputCacheMiss.offPeak).toBeCloseTo(0.14); + expect(parsed.rates[0].output.offPeak).toBeCloseTo(0.28); + }); +}); + +describe("peak schedule", () => { + it("parses DeepSeek UTC weekday windows", () => { + const peak = parsePeakSchedule( + "Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday", + ); + expect(peak?.weekdays).toEqual([1, 2, 3, 4, 5]); + expect(isPeakAt(new Date("2026-09-14T02:00:00Z"), peak?.windows, peak?.weekdays)).toBe( + true, + ); + expect(isPeakAt(new Date("2026-09-13T02:00:00Z"), peak?.windows, peak?.weekdays)).toBe( + false, + ); + expect(isPeakAt(new Date("2026-09-14T05:00:00Z"), peak?.windows, peak?.weekdays)).toBe( + false, + ); + }); + + it("maps legacy flash names", () => { + const map = parseLegacyAliases(DEEPSEEK_HTML, ["deepseek-flash"]); + expect(map.get("deepseek-flash")).toEqual( + expect.arrayContaining(["deepseek-v4-flash"]), + ); + }); +}); + +describe("costForRecord", () => { + const rate = flashRate(); + + it("uses cache hit/miss and off-peak on weekend", () => { + const record: TokenUsageRecord = { + id: "1", + at: "2026-09-13T02:00:00.000Z", + caller: "dictate_agent", + model: "deepseek-flash", + promptTokens: 1000, + completionTokens: 100, + totalTokens: 1100, + cachedTokens: 800, + }; + expect(splitPromptTokens(record)).toEqual({ cached: 800, miss: 200, output: 100 }); + const cost = costForRecord(record, rate); + expect(cost.currency).toBe("USD"); + expect(cost.amount).toBeCloseTo((800 * 0.003 + 200 * 0.15 + 100 * 0.6) / 1e6); + }); + + it("uses peak weekday window", () => { + const record: TokenUsageRecord = { + id: "2", + at: "2026-09-14T02:30:00.000Z", + caller: "dictate_agent", + model: "deepseek-flash", + promptTokens: 1000, + completionTokens: 100, + totalTokens: 1100, + cachedTokens: 800, + cacheMissTokens: 200, + }; + const cost = costForRecord(record, rate); + expect(cost.amount).toBeCloseTo((800 * 0.006 + 200 * 0.3 + 100 * 1.2) / 1e6); + }); +}); + +describe("normalizePricingUrl", () => { + it("rejects non-http URLs", async () => { + const { normalizePricingUrl, markdownDocFallbacks } = await import("../src/stats/pricing.js"); + expect(() => normalizePricingUrl("file:///tmp/x")).toThrow(/http/); + expect(markdownDocFallbacks("https://docs.bigmodel.cn/cn/guide/start/pricing")).toEqual([ + "https://docs.bigmodel.cn/cn/guide/start/pricing.md", + ]); + }); +}); + +describe("buildCostReport", () => { + beforeAll(() => { + process.env.WRITING_AGENT_DATA_DIR = mkdtempSync( + path.join(tmpdir(), "wa-pricing-"), + ); + }); + + it("groups by model and marks unmatched", () => { + const rate = flashRate(); + const catalog = { + byKey: new Map([ + ["deepseek-flash", rate], + ["deepseek-v4-flash", rate], + ]), + rates: [rate], + }; + const report = buildCostReport( + [ + { + id: "a", + at: "2026-09-13T08:00:00.000Z", + caller: "x", + model: "deepseek-v4-flash", + promptTokens: 100, + completionTokens: 10, + totalTokens: 110, + cachedTokens: 50, + }, + { + id: "b", + at: "2026-09-13T08:00:00.000Z", + caller: "x", + model: "mystery-model", + promptTokens: 10, + completionTokens: 1, + totalTokens: 11, + }, + ], + catalog, + ); + expect(report.unpricedCalls).toBe(1); + expect(report.pricedCalls).toBe(1); + expect(report.byModel[0].model).toBe("deepseek-v4-flash"); + expect(report.byModel[0].cost?.amount).toBeGreaterThan(0); + expect(report.byModel.find((r) => r.model === "mystery-model")?.unmatched).toBe( + true, + ); + }); +}); diff --git a/web/stats.css b/web/stats.css index 4c04993..0f1b401 100644 --- a/web/stats.css +++ b/web/stats.css @@ -102,9 +102,165 @@ box-shadow: 0 0 0 1px var(--st-border-strong); } +.stats-pricing { + margin: 0 24px 16px; + padding: 12px 14px; + background: var(--st-surface); + border: 1px solid var(--st-border); + border-radius: var(--st-radius); +} + +.stats-pricing .stats-section-label { + margin: 0; +} + +.stats-pricing-head { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 10px; +} + +.stats-pricing-hint { + margin: 0; + font-size: 12px; + color: var(--st-muted); + line-height: 1.45; +} + +.stats-pricing-add { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; +} + +.stats-field-url { + flex: 1 1 18rem; + min-width: 0; +} + +.stats-field-url input { + min-width: 0; + width: 100%; +} + +.stats-pricing-suggested { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.stats-suggest { + height: 28px; + padding: 0 10px; + border: 1px solid var(--st-border); + border-radius: 6px; + background: #fafafa; + color: var(--st-text); + font-size: 12px; + cursor: pointer; +} + +.stats-suggest:hover { + background: #f4f4f5; + border-color: var(--st-border-strong); +} + +.stats-suggest:focus-visible, +.stats-pricing button:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--st-accent-soft); +} + +.stats-pricing button:disabled, +.stats-suggest:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.stats-pricing-list { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.stats-pricing-item { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 8px 12px; + padding: 8px 0 0; + border-top: 1px solid var(--st-border); +} + +.stats-pricing-meta { + min-width: 0; + flex: 1 1 14rem; +} + +.stats-pricing-name { + font-size: 13px; + font-weight: 600; + color: var(--st-text); +} + +.stats-pricing-url { + display: block; + margin-top: 1px; + font-size: 12px; + color: var(--st-accent); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stats-pricing-url:hover { + text-decoration: underline; +} + +.stats-pricing-sub { + margin: 2px 0 0; + font-size: 12px; + color: var(--st-muted); +} + +.stats-pricing-sub[data-state="error"] { + color: var(--st-danger); +} + +.stats-pricing-actions { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +.stats-pricing-status { + margin: 8px 0 0; + font-size: 12px; + color: var(--st-muted); +} + +.stats-pricing-status[data-state="error"] { + color: var(--st-danger); +} + +.stats-section-label { + margin: 0 24px 8px; + font-size: 13px; + font-weight: 600; + color: var(--st-text); +} + .stats-summary { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; margin: 0 24px 12px; } @@ -143,10 +299,24 @@ background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface)); } -.stats-metric[data-emphasis="total"] .stats-metric-value { +.stats-metric[data-emphasis="total"] .stats-metric-value, +.stats-metric[data-emphasis="cost"] .stats-metric-value { color: var(--st-accent); } +.stats-metric[data-emphasis="cost"] { + border-color: color-mix(in srgb, var(--st-accent) 28%, var(--st-border)); + background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface)); +} + +.stats-metric-note { + display: block; + margin-top: 2px; + font-size: 11px; + font-weight: 500; + color: var(--st-muted); +} + .stats-status { display: flex; align-items: center; @@ -175,6 +345,19 @@ margin: 0 24px; } +.stats-table-wrap-model { + max-height: none; + margin-bottom: 18px; +} + +.stats-by-model { + margin-bottom: 4px; +} + +.stats-unpriced { + color: var(--st-muted); +} + .stats-table { width: 100%; border-collapse: collapse; @@ -258,7 +441,7 @@ margin-bottom: 4px; } -@media (max-width: 900px) { +@media (max-width: 1100px) { .stats-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -267,8 +450,10 @@ @media (max-width: 640px) { .stats-main .st-main-head, .stats-toolbar, + .stats-pricing, .stats-summary, .stats-status, + .stats-section-label, .stats-table-wrap { margin-left: 14px; margin-right: 14px; diff --git a/web/stats.html b/web/stats.html index c65740e..f98f4be 100644 --- a/web/stats.html +++ b/web/stats.html @@ -24,7 +24,7 @@

Token 统计

-

按时间筛选本机调用记录(stats/*.jsonl)

+

按时间筛选本机调用;花销按你指定的官方价目文档估算(stats/*.jsonl)

@@ -52,11 +52,60 @@ +
+
+ +

+ 粘贴厂商官方价目页,解析型号单价后按调用时间估算(含缓存命中与高峰/闲时,若文档有写)。 +

+
+
+ + +
+
+
    + +
    +

    +
    + +
    + + + + + + + + + + + + + +
    model调用promptcompletioncached估算花销单价
    + +
    +
    + +
    - +
    @@ -66,6 +115,7 @@ + diff --git a/web/stats.js b/web/stats.js index ab7fa04..deb9094 100644 --- a/web/stats.js +++ b/web/stats.js @@ -28,18 +28,62 @@ function formatNum(n) { return Number(n).toLocaleString("zh-CN"); } +function formatMoney(money) { + if (!money || money.amount == null || Number.isNaN(Number(money.amount))) return "—"; + const amount = Number(money.amount); + const currency = money.currency || "USD"; + const symbol = currency === "CNY" ? "¥" : currency === "USD" ? "$" : `${currency} `; + const abs = Math.abs(amount); + let digits = 2; + if (abs > 0 && abs < 0.01) digits = 6; + else if (abs < 1) digits = 4; + const body = amount.toFixed(digits).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, ""); + const shown = body.includes(".") ? body : `${body}.00`; + return `${symbol}${shown}`; +} + +function formatCostTotal(total, byCurrency) { + if (total) return formatMoney(total); + const entries = Object.entries(byCurrency || {}); + if (!entries.length) return "—"; + return entries.map(([currency, amount]) => formatMoney({ amount, currency })).join(" + "); +} + function setStatus(text, state = "idle") { const status = document.getElementById("status"); status.textContent = text; status.dataset.state = state; } +function setPricingStatus(text, state = "idle") { + const el = document.getElementById("pricing-status"); + if (!text) { + el.hidden = true; + el.textContent = ""; + el.dataset.state = "idle"; + return; + } + el.hidden = false; + el.textContent = text; + el.dataset.state = state; +} + function setRangeActive(id) { for (const btn of document.querySelectorAll(".stats-range .btn-secondary")) { btn.classList.toggle("is-active", btn.id === id); } } +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + async function loadStats() { const from = localInputToIso(document.getElementById("from").value); const to = localInputToIso(document.getElementById("to").value); @@ -56,14 +100,20 @@ async function loadStats() { const data = await res.json(); const records = data.records || []; renderSummary(data.summary); + renderModels(data.summary?.byModel || data.cost?.byModel || []); renderRows(records); const totalCalls = data.summary?.totalCalls ?? 0; + const unpriced = data.summary?.unpricedCalls ?? data.cost?.unpricedCalls ?? 0; + const extra = unpriced + ? ` · ${formatNum(unpriced)} 次无报价` + : ""; setStatus( - `共 ${formatNum(totalCalls)} 次调用 · 显示 ${formatNum(records.length)} 条`, + `共 ${formatNum(totalCalls)} 次调用 · 显示 ${formatNum(records.length)} 条${extra}`, "idle", ); } catch (err) { renderSummary(null); + renderModels([]); renderRows([]); setStatus( `加载失败:${err instanceof Error ? err.message : String(err)}`, @@ -78,28 +128,69 @@ function renderSummary(summary) { el.innerHTML = ""; return; } + const costValue = formatCostTotal(summary.estimatedCost, summary.byCurrency); + const costNote = summary.unpricedCalls + ? `${formatNum(summary.unpricedCalls)} 次无报价` + : summary.estimatedCost + ? "按官方文档估算" + : "未配置价目"; const metrics = [ - ["调用次数", summary.totalCalls, null], - ["总 token", summary.totalTokens, "total"], - ["prompt", summary.promptTokens, null], - ["completion", summary.completionTokens, null], - ["cached", summary.totalCached, null], + ["调用次数", formatNum(summary.totalCalls ?? 0), null, null], + ["估算花销", costValue, "cost", costNote], + ["总 token", formatNum(summary.totalTokens ?? 0), "total", null], + ["prompt", formatNum(summary.promptTokens ?? 0), null, null], + ["completion", formatNum(summary.completionTokens ?? 0), null, null], + ["cached", formatNum(summary.totalCached ?? 0), null, null], ]; el.innerHTML = metrics .map( - ([label, value, emphasis]) => + ([label, value, emphasis, note]) => `
    ${label} - ${formatNum(value ?? 0)} + ${value} + ${note ? `${escapeHtml(note)}` : ""}
    `, ) .join(""); } +function renderModels(rows) { + const tbody = document.getElementById("model-rows"); + const empty = document.getElementById("model-empty"); + const table = document.getElementById("model-table"); + if (!rows.length) { + tbody.innerHTML = ""; + table.hidden = true; + empty.hidden = false; + return; + } + table.hidden = false; + empty.hidden = true; + tbody.innerHTML = rows + .map((r) => { + const cost = r.unmatched || !r.cost + ? `无报价` + : formatMoney(r.cost); + const source = r.unmatched + ? "—" + : escapeHtml(r.sourceName || "官方文档"); + return ` + + + + + + + + `; + }) + .join(""); +} + function renderRows(records) { const tbody = document.getElementById("rows"); const empty = document.getElementById("empty"); - const table = tbody.closest("table"); + const table = document.getElementById("call-table") || tbody.closest("table"); if (!records.length) { tbody.innerHTML = ""; @@ -111,8 +202,11 @@ function renderRows(records) { if (table) table.hidden = false; empty.hidden = true; tbody.innerHTML = records - .map( - (r) => ` + .map((r) => { + const cost = r.unmatchedRate || !r.estimatedCost + ? `—` + : formatMoney(r.estimatedCost); + return ` @@ -120,8 +214,9 @@ function renderRows(records) { - `, - ) + + `; + }) .join(""); } @@ -138,6 +233,99 @@ function setRange(fromDate, toDate) { document.getElementById("to").value = toLocalInputValue(toDate); } +async function loadPricing() { + try { + const data = await api("/api/stats/pricing"); + renderSuggested(data.suggested || [], data.sources || []); + renderSources(data.sources || []); + } catch (err) { + setPricingStatus( + `价目来源加载失败:${err instanceof Error ? err.message : String(err)}`, + "error", + ); + } +} + +function renderSuggested(items, sources = []) { + const el = document.getElementById("pricing-suggested"); + const used = new Set((sources || []).map((s) => s.url)); + const remaining = items.filter((s) => !used.has(s.url)); + if (!remaining.length) { + el.innerHTML = ""; + return; + } + el.innerHTML = items + .map( + (s) => + ``, + ) + .join(""); +} + +function renderSources(sources) { + const el = document.getElementById("pricing-sources"); + if (!sources.length) { + el.innerHTML = ""; + return; + } + el.innerHTML = sources + .map((s) => { + const when = s.fetchedAt ? formatAt(s.fetchedAt) : "尚未拉取"; + const sub = s.fetchError + ? s.fetchError + : `${formatNum(s.modelCount ?? 0)} 个型号 · ${when}`; + const state = s.fetchError ? "error" : "idle"; + return `
  • +
    +
    ${escapeHtml(s.name)}
    + ${escapeHtml(s.url)} +

    ${escapeHtml(sub)}

    +
    +
    + + +
    +
  • `; + }) + .join(""); +} + +function setPricingBusy(busy) { + document.getElementById("btn-add-pricing").disabled = busy; + for (const btn of document.querySelectorAll(".stats-pricing-actions button, .stats-suggest")) { + btn.disabled = busy; + } +} + +async function addPricingSource(url, name) { + const trimmed = (url || "").trim(); + if (!trimmed) { + setPricingStatus("请填写官方价目文档 URL", "error"); + return; + } + setPricingBusy(true); + setPricingStatus("正在拉取并解析官方文档…", "idle"); + try { + const data = await api("/api/stats/pricing/sources", { + method: "POST", + body: JSON.stringify({ url: trimmed, name }), + }); + document.getElementById("pricing-url").value = ""; + if (data.source?.fetchError) { + setPricingStatus(data.source.fetchError, "error"); + } else { + setPricingStatus("已按文档更新单价", "idle"); + } + await loadPricing(); + await loadStats(); + } catch (err) { + setPricingStatus(err instanceof Error ? err.message : String(err), "error"); + await loadPricing(); + } finally { + setPricingBusy(false); + } +} + document.getElementById("btn-load").addEventListener("click", () => { setRangeActive(""); loadStats(); @@ -160,11 +348,57 @@ document.getElementById("btn-7d").addEventListener("click", () => { document.getElementById("from").addEventListener("change", () => setRangeActive("")); document.getElementById("to").addEventListener("change", () => setRangeActive("")); -// 默认:今天 +document.getElementById("pricing-form").addEventListener("submit", (ev) => { + ev.preventDefault(); + addPricingSource(document.getElementById("pricing-url").value); +}); + +document.getElementById("pricing-suggested").addEventListener("click", (ev) => { + const btn = ev.target.closest(".stats-suggest"); + if (!btn) return; + addPricingSource(btn.dataset.url, btn.textContent); +}); + +document.getElementById("pricing-sources").addEventListener("click", async (ev) => { + const btn = ev.target.closest("button[data-action]"); + if (!btn) return; + const item = btn.closest(".stats-pricing-item"); + const id = item?.dataset.id; + if (!id) return; + const action = btn.dataset.action; + setPricingBusy(true); + try { + if (action === "refresh") { + setPricingStatus("正在重新拉取官方文档…", "idle"); + const data = await api(`/api/stats/pricing/sources/${encodeURIComponent(id)}/refresh`, { + method: "POST", + }); + if (data.source?.fetchError) { + setPricingStatus(data.source.fetchError, "error"); + } else { + setPricingStatus("已按文档更新单价", "idle"); + } + } else if (action === "delete") { + await api(`/api/stats/pricing/sources/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + setPricingStatus("已移除价目来源", "idle"); + } + await loadPricing(); + await loadStats(); + } catch (err) { + setPricingStatus(err instanceof Error ? err.message : String(err), "error"); + await loadPricing(); + } finally { + setPricingBusy(false); + } +}); + (() => { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); setRange(start, now); setRangeActive("btn-today"); + loadPricing(); loadStats(); })();
    时间completion total cached花销
    ${escapeHtml(r.model || "—")}${formatNum(r.calls ?? 0)}${formatNum(r.promptTokens ?? 0)}${formatNum(r.completionTokens ?? 0)}${formatNum(r.cachedTokens ?? 0)}${cost}${source}
    ${formatAt(r.at)} ${escapeHtml(r.caller || "—")} ${escapeHtml(r.model || "—")}${formatNum(r.completionTokens ?? 0)} ${formatNum(r.totalTokens ?? 0)} ${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)}
    ${cost}