启动时对照开放路由价目,并在统计页看清缓存费用。

最近用过的模型变价时可以在页上直接核对。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 08:29:46 +08:00
co-authored by Cursor
parent b864591afa
commit b59ccf976c
10 changed files with 1271 additions and 18 deletions
+48 -1
View File
@@ -8,12 +8,18 @@ import {
addPricingSource,
deletePricingSource,
getPricingSource,
getPriceWatchStatus,
listOpenRouterCompareModels,
listPricingSources,
readPriceWatchNotice,
RECENT_COMPARE_CALLS,
refreshPricingSource,
syncOpenRouterPrices,
resolveRateCatalog,
searchPricingModels,
SUGGESTED_PRICING_DOCS,
} from "../stats/pricing.js";
import { attachRecordCost, buildCostReport } from "../stats/pricing-cost.js";
import { attachRecordCost, buildCostReport, compareModelsOnRecords } from "../stats/pricing-cost.js";
import { annotateCacheBreaks } from "../llm/context-outline.js";
async function readBody(req: IncomingMessage): Promise<string> {
@@ -113,6 +119,47 @@ export async function handleStatsApi(
return true;
}
if (pathname === "/api/stats/pricing/openrouter") {
if (req.method !== "GET" && req.method !== "POST") {
json(res, 405, { error: "Method Not Allowed" });
return true;
}
try {
const notice = await syncOpenRouterPrices();
const source = listPricingSources().find((item) => item.url.includes("openrouter.ai"));
json(res, 200, {
source: source ? publicSource(source) : null,
notice,
});
} catch (err) {
json(res, 400, { error: err instanceof Error ? err.message : "拉取失败" });
}
return true;
}
if (pathname === "/api/stats/pricing/notice" && req.method === "GET") {
json(res, 200, {
status: getPriceWatchStatus(),
notice: readPriceWatchNotice(),
});
return true;
}
if (pathname === "/api/stats/pricing/models" && req.method === "GET") {
const q = searchParams.get("q") ?? "";
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 12;
json(res, 200, { models: searchPricingModels(q, limit) });
return true;
}
if (pathname === "/api/stats/compare" && req.method === "GET") {
const models = listOpenRouterCompareModels();
const records = readRecords({ limit: RECENT_COMPARE_CALLS });
const compared = compareModelsOnRecords(records, models);
json(res, 200, compared);
return true;
}
if (pathname === "/api/stats/pricing/sources" && req.method === "POST") {
try {
const body = JSON.parse(await readBody(req)) as { url?: string; name?: string };
+88
View File
@@ -17,6 +17,14 @@ export type RecordCost = {
sourceName?: string;
};
export type ModelUnitPrice = {
currency: string;
unitTokens: number;
input: number;
output: number;
cached: number | null;
};
export type ModelCostRow = {
model: string;
calls: number;
@@ -29,8 +37,20 @@ export type ModelCostRow = {
unmatched: boolean;
sourceName?: string;
sourceUrl?: string;
/** 匹配到的当前单价(每 unitTokens),便于不打开控制台也能对照花销 */
unitPrice?: ModelUnitPrice | null;
};
function unitPriceFromRate(rate: ResolvedRate): ModelUnitPrice {
return {
currency: rate.currency,
unitTokens: rate.unitTokens || 1_000_000,
input: rate.inputCacheMiss.offPeak,
output: rate.output.offPeak,
cached: rate.inputCacheHit?.offPeak ?? null,
};
}
export type CostReport = {
total: Money | null;
byCurrency: Record<string, number>;
@@ -137,6 +157,7 @@ export function buildCostReport(
unmatched: !rate,
sourceName: rate?.sourceName,
sourceUrl: rate?.sourceUrl,
unitPrice: rate ? unitPriceFromRate(rate) : null,
};
prev.calls += 1;
prev.promptTokens += record.promptTokens;
@@ -151,6 +172,11 @@ export function buildCostReport(
}
prev.unmatched = false;
}
if (rate) {
prev.sourceName = rate.sourceName;
prev.sourceUrl = rate.sourceUrl;
prev.unitPrice = unitPriceFromRate(rate);
}
byModel.set(model, prev);
}
@@ -174,3 +200,65 @@ export function buildCostReport(
byModel: rows,
};
}
export type CompareUsage = {
calls: number;
promptTokens: number;
completionTokens: number;
cachedTokens: number;
};
export type CompareRow = {
model: string;
found: boolean;
sourceName?: string;
unitPrice: ModelUnitPrice | null;
projected: Money | null;
};
/** 把同一批调用的 token 按候选模型单价重估,便于换模型前对比。 */
export function compareModelsOnRecords(
records: TokenUsageRecord[],
modelIds: string[],
catalog = resolveRateCatalog(),
): { usage: CompareUsage; rows: CompareRow[] } {
const usage: CompareUsage = {
calls: records.length,
promptTokens: 0,
completionTokens: 0,
cachedTokens: 0,
};
for (const record of records) {
usage.promptTokens += record.promptTokens;
usage.completionTokens += record.completionTokens;
usage.cachedTokens += record.cachedTokens ?? 0;
}
const seen = new Set<string>();
const rows: CompareRow[] = [];
for (const raw of modelIds) {
const model = raw.trim();
const key = model.toLowerCase();
if (!model || seen.has(key)) continue;
seen.add(key);
const rate = findModelRate(model, catalog);
if (!rate) {
rows.push({ model, found: false, unitPrice: null, projected: null });
continue;
}
let amount = 0;
for (const record of records) {
amount += costForRecord(record, rate).amount;
}
rows.push({
model: rate.model,
found: true,
sourceName: rate.sourceName,
unitPrice: unitPriceFromRate(rate),
projected: records.length
? { amount: Math.round(amount * 1e10) / 1e10, currency: rate.currency }
: { amount: 0, currency: rate.currency },
});
}
return { usage, rows };
}
+12 -3
View File
@@ -338,9 +338,10 @@ function parsePricingJson(text: string): ParsePricingResult {
const cached = jsonPrice(pricing.input_cache_read ?? pricing.cached ?? pricing.inputCacheHit);
// OpenRouter 等接口用「每个 token」;官方价目表通常是每 1M。
const perToken = prompt > 0 && prompt < 1e-4;
// 保留完整 id(如 openai/gpt-4o),避免不同厂商短名互相覆盖。
const rate: ModelRate = {
model: model.includes("/") ? model.split("/").pop()! : model,
aliases: model.includes("/") ? [model] : [],
model,
aliases: [],
currency: "USD",
unitTokens: UNIT_1M,
inputCacheMiss: { offPeak: perToken ? prompt * UNIT_1M : prompt },
@@ -351,7 +352,15 @@ function parsePricingJson(text: string): ParsePricingResult {
}
rates.push(rate);
}
return { rates: mergeRatesByModel(rates), warnings: rates.length ? [] : ["JSON 中没有单价"] };
const title =
rates.some((r) => r.model.includes("/")) && rates.length > 20
? "OpenRouter Models & Pricing"
: undefined;
return {
title,
rates: mergeRatesByModel(rates),
warnings: rates.length ? [] : ["JSON 中没有单价"],
};
} catch {
return { rates: [], warnings: ["JSON 解析失败"] };
}
+377 -6
View File
@@ -2,6 +2,7 @@ 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 { recentDistinctModels } from "./token-store.js";
import {
parsePricingDocument,
type ModelRate,
@@ -16,6 +17,9 @@ export type PricingSource = {
fetchError?: string;
title?: string;
rates: ModelRate[];
/** 上一次成功拉取的单价,跨进程保留,用来看价格有没有变 */
previousRates?: ModelRate[];
previousFetchedAt?: string;
};
type PricingFile = {
@@ -27,6 +31,10 @@ const FILE_NAME = "pricing-sources.json";
const FETCH_TIMEOUT_MS = 20_000;
const MAX_BYTES = 2_000_000;
export const OPENROUTER_PRICING_URL = "https://openrouter.ai/api/v1/models";
/** 预估对比使用的最近调用条数 */
export const RECENT_COMPARE_CALLS = 100;
export const SUGGESTED_PRICING_DOCS: Array<{ name: string; url: string }> = [
{
name: "DeepSeek Models & Pricing",
@@ -46,6 +54,22 @@ export const SUGGESTED_PRICING_DOCS: Array<{ name: string; url: string }> = [
},
];
const USUAL_SKIP_PARTS = new Set([
"image",
"audio",
"vision",
"codex",
"preview",
"exp",
"lite",
"nano",
"mini",
"instruct",
"banana",
"oss",
"safeguard",
]);
function filePath(): string {
return path.join(getUserDataDir(), FILE_NAME);
}
@@ -79,6 +103,148 @@ export function getPricingSource(id: string): PricingSource | null {
return listPricingSources().find((s) => s.id === id) ?? null;
}
export function isOpenRouterModelsUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (
parsed.hostname === "openrouter.ai" &&
parsed.pathname.replace(/\/+$/, "") === "/api/v1/models"
);
} catch {
return false;
}
}
/**
* 对比用的当前档:每个家族只留最新一档(DeepSeek / Claude / GLM / Gemini / GPT / Grok)。
* 同一档有多个 id 时留 created 更新的那个。
*/
export function usualOpenRouterBucket(modelId: string): string | null {
const id = modelId.trim().toLowerCase();
if (!id || id.startsWith("~") || id.includes(":")) return null;
const name = id.split("/").pop() ?? id;
if (
name.includes("multi-agent") ||
name.includes("chat-latest") ||
name.includes("customtools") ||
name.includes("build-")
) {
return null;
}
const parts = name.split(/[^a-z0-9.]+/);
if (parts.some((part) => USUAL_SKIP_PARTS.has(part))) return null;
if (name.includes("claude-haiku")) return "claude-haiku";
if (name.includes("claude-sonnet")) return "claude-sonnet";
if (name.includes("claude-opus")) return "claude-opus";
if (name.includes("claude-fable")) return "claude-fable";
if (name.includes("grok")) return "grok";
if (name.includes("deepseek") && name.includes("pro")) return "deepseek-pro";
if (name.includes("deepseek") && name.includes("flash")) return "deepseek-flash";
if (name.includes("glm") && name.includes("prime")) return "glm-prime";
if (name.includes("glm") && name.includes("flashx")) return "glm-flashx";
if (name.includes("glm") && name.includes("flash")) return "glm-flash";
if (name.includes("glm") && !name.includes("air") && !name.includes("turbo") && !/v$/.test(name)) {
return "glm";
}
if (name.includes("gemini") && name.includes("pro")) return "gemini-pro";
if (name.includes("gemini") && name.includes("flash")) return "gemini-flash";
if (name.startsWith("gpt-") && name.includes("luna-pro")) return "gpt-luna-pro";
if (name.startsWith("gpt-") && name.includes("luna")) return "gpt-luna";
if (name.startsWith("gpt-") && name.includes("sol-pro")) return "gpt-sol-pro";
if (name.startsWith("gpt-") && /-sol(?:$|-)/.test(name)) return "gpt-sol";
if (name.startsWith("gpt-") && name.includes("astra-pro")) return "gpt-astra-pro";
if (name.startsWith("gpt-") && name.includes("astra")) return "gpt-astra";
return null;
}
export function trimOpenRouterUsualCatalog(text: string, keepIds: string[] = []): string {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
return text;
}
const rows = openRouterRows(data);
if (!rows) return text;
const pinned = new Set(keepIds.map((id) => id.trim().toLowerCase()).filter(Boolean));
const best = new Map<string, { created: number; row: Record<string, unknown> }>();
for (const row of rows) {
const id = String(row.id ?? row.model ?? "").trim();
const bucket = usualOpenRouterBucket(id);
const keepExact = pinned.has(id.toLowerCase()) && isWatchedFamily(id);
if (!bucket && !keepExact) continue;
const key = keepExact ? `keep:${id.toLowerCase()}` : bucket!;
const created = Number(row.created);
const at = Number.isFinite(created) ? created : 0;
const prev = best.get(key);
if (!prev || at >= prev.created) best.set(key, { created: at, row });
}
return JSON.stringify({ data: [...best.values()].map((item) => item.row) });
}
function isWatchedFamily(modelId: string): boolean {
const id = modelId.trim().toLowerCase();
if (!id || id.startsWith("~") || id.includes(":")) return false;
const name = id.split("/").pop() ?? id;
return (
name.includes("deepseek") ||
name.includes("claude") ||
name.includes("glm") ||
name.includes("gemini") ||
name.startsWith("gpt-") ||
name.includes("grok")
);
}
function openRouterRows(data: unknown): Array<Record<string, unknown>> | null {
if (!data || typeof data !== "object" || Array.isArray(data)) return null;
const rows = (data as { data?: unknown }).data;
if (!Array.isArray(rows)) return null;
return rows.filter(
(row): row is Record<string, unknown> =>
typeof row === "object" && row != null && !Array.isArray(row),
);
}
const FAMILY_ORDER = ["deepseek", "claude", "glm", "gemini", "gpt", "grok"];
export function listOpenRouterCompareModels(
sources = listPricingSources(),
): string[] {
const source = sources.find((item) => isOpenRouterModelsUrl(item.url));
if (!source) return [];
return [...source.rates]
.map((rate) => rate.model)
.sort((a, b) => {
const rank = (model: string) => {
const name = model.toLowerCase();
const index = FAMILY_ORDER.findIndex((family) => name.includes(family));
return index < 0 ? 99 : index;
};
const diff = rank(a) - rank(b);
if (diff !== 0) return diff;
return a.localeCompare(b);
});
}
/** 拉取 OpenRouter。keepIds 里最近用过的型号会按原 id 留下,即使不是当前档。 */
export async function refreshOpenRouterPricing(keepIds: string[] = []): Promise<PricingSource> {
const url = normalizePricingUrl(OPENROUTER_PRICING_URL);
const existing = listPricingSources().find((source) => source.url === url);
if (existing) return refreshPricingSource(existing.id, keepIds);
const data = readFile();
const source: PricingSource = {
id: randomUUID(),
name: "OpenRouter",
url,
createdAt: new Date().toISOString(),
rates: [],
};
data.sources.push(source);
writeFile(data);
return refreshPricingSource(source.id, keepIds);
}
export function normalizePricingUrl(raw: string): string {
const trimmed = raw.trim();
let url: URL;
@@ -116,7 +282,7 @@ export async function addPricingSource(input: {
};
data.sources.push(source);
writeFile(data);
return refreshPricingSource(source.id);
return refreshPricingSource(source.id, []);
}
export function deletePricingSource(id: string): boolean {
@@ -127,15 +293,22 @@ export function deletePricingSource(id: string): boolean {
return true;
}
export async function refreshPricingSource(id: string): Promise<PricingSource> {
export async function refreshPricingSource(
id: string,
keepIds: string[] = [],
): Promise<PricingSource> {
const data = readFile();
const source = data.sources.find((s) => s.id === id);
if (!source) throw new Error("价目来源不存在");
try {
const parsed = await fetchAndParsePricing(source.url);
const parsed = await fetchAndParsePricing(source.url, keepIds);
if (parsed.rates.length && source.rates.length) {
source.previousRates = source.rates;
source.previousFetchedAt = source.fetchedAt;
}
source.fetchedAt = new Date().toISOString();
source.title = parsed.title;
source.rates = parsed.rates;
source.rates = parsed.rates.length ? parsed.rates : source.rates;
if (parsed.title && looksGenericName(source.name)) {
source.name = shortenTitle(parsed.title);
}
@@ -167,7 +340,10 @@ export function markdownDocFallbacks(url: string): string[] {
}
}
export async function fetchAndParsePricing(url: string): Promise<{
export async function fetchAndParsePricing(
url: string,
keepIds: string[] = [],
): Promise<{
title?: string;
rates: ModelRate[];
warnings: string[];
@@ -178,7 +354,10 @@ export async function fetchAndParsePricing(url: string): Promise<{
for (const candidate of candidates) {
try {
const doc = await fetchPricingDocument(candidate);
const parsed = parsePricingDocument(doc.text, doc.contentType);
const text = isOpenRouterModelsUrl(candidate)
? trimOpenRouterUsualCatalog(doc.text, keepIds)
: doc.text;
const parsed = parsePricingDocument(text, doc.contentType);
lastTitle = parsed.title ?? lastTitle;
if (parsed.rates.length) return parsed;
lastError = parsed.warnings[0] || lastError;
@@ -245,6 +424,50 @@ export function resolveRateCatalog(sources = listPricingSources()): {
return { byKey, rates };
}
export type CatalogModelHit = {
model: string;
sourceName: string;
currency: string;
unitTokens: number;
input: number;
output: number;
cached: number | null;
};
/** 在已同步价目里按型号子串搜索,供对比条加入候选。 */
export function searchPricingModels(
query: string,
limit = 12,
catalog = resolveRateCatalog(),
): CatalogModelHit[] {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const cap = Math.min(Math.max(limit, 1), 30);
const hits = catalog.rates.filter((rate) => rate.model.toLowerCase().includes(q));
hits.sort((a, b) => {
const rank = (model: string) => {
const id = model.toLowerCase();
let score = 0;
if (id.includes(":batch") || id.includes(":free")) score += 4;
if (id.startsWith("~")) score += 2;
if (id !== q && !id.endsWith(`/${q}`)) score += 1;
return score;
};
const diff = rank(a.model) - rank(b.model);
if (diff !== 0) return diff;
return a.model.length - b.model.length;
});
return hits.slice(0, cap).map((rate) => ({
model: rate.model,
sourceName: rate.sourceName,
currency: rate.currency,
unitTokens: rate.unitTokens,
input: rate.inputCacheMiss.offPeak,
output: rate.output.offPeak,
cached: rate.inputCacheHit?.offPeak ?? null,
}));
}
export function findModelRate(
model: string,
catalog = resolveRateCatalog(),
@@ -273,3 +496,151 @@ function looksGenericName(name: string): boolean {
function shortenTitle(title: string): string {
return title.replace(/\s*[·|]\s*.*$/, "").trim() || title;
}
export type UnitSnap = {
currency: string;
input: number;
output: number;
cached: number | null;
};
export type PriceWatchItem = {
model: string;
status: "changed" | "unchanged" | "unpriced" | "baseline";
previous: UnitSnap | null;
current: UnitSnap | null;
};
export type PriceWatchNotice = {
at: string;
error?: string;
items: PriceWatchItem[];
};
const NOTICE_FILE = "pricing-notice.json";
let watchInFlight: Promise<PriceWatchNotice> | null = null;
let watchStatus: "idle" | "pulling" | "done" | "error" = "idle";
export function getPriceWatchStatus(): "idle" | "pulling" | "done" | "error" {
return watchStatus;
}
export function unitSnap(rate: ModelRate): UnitSnap {
return {
currency: rate.currency,
input: rate.inputCacheMiss.offPeak,
output: rate.output.offPeak,
cached: rate.inputCacheHit?.offPeak ?? null,
};
}
function rateIndex(rates: ModelRate[]): Map<string, ModelRate> {
const map = new Map<string, ModelRate>();
for (const rate of rates) {
map.set(rate.model.toLowerCase(), rate);
for (const alias of rate.aliases) map.set(alias.toLowerCase(), rate);
}
return map;
}
function lookupRate(map: Map<string, ModelRate>, model: string): ModelRate | undefined {
const key = model.trim().toLowerCase();
const exact = map.get(key);
if (exact) return exact;
const tail = key.split("/").pop();
if (tail && tail !== key) return map.get(tail);
return undefined;
}
function sameSnap(a: UnitSnap, b: UnitSnap): boolean {
return (
a.currency === b.currency &&
a.input === b.input &&
a.output === b.output &&
a.cached === b.cached
);
}
export function compareStoredPrices(
previous: ModelRate[],
current: ModelRate[],
models: string[],
): PriceWatchItem[] {
const before = rateIndex(previous);
const after = rateIndex(current);
return models.map((model) => {
const prev = lookupRate(before, model);
const next = lookupRate(after, model);
if (!next) {
return {
model,
status: "unpriced",
previous: prev ? unitSnap(prev) : null,
current: null,
};
}
const currentSnap = unitSnap(next);
if (!prev) {
return { model, status: "baseline", previous: null, current: currentSnap };
}
const previousSnap = unitSnap(prev);
return {
model,
status: sameSnap(previousSnap, currentSnap) ? "unchanged" : "changed",
previous: previousSnap,
current: currentSnap,
};
});
}
export function readPriceWatchNotice(): PriceWatchNotice | null {
try {
const raw = readFileSync(path.join(getUserDataDir(), NOTICE_FILE), "utf8");
const parsed = JSON.parse(raw) as PriceWatchNotice;
if (!parsed || !Array.isArray(parsed.items)) return null;
return parsed;
} catch {
return null;
}
}
function writePriceWatchNotice(notice: PriceWatchNotice): void {
ensureUserDataDirs();
writeFileSync(path.join(getUserDataDir(), NOTICE_FILE), JSON.stringify(notice, null, 2), "utf8");
}
/** 启动或手动拉取:更新本机价目,并和上次留下的单价比较最近 2 个型号。 */
export async function syncOpenRouterPrices(): Promise<PriceWatchNotice> {
if (watchInFlight) return watchInFlight;
watchStatus = "pulling";
watchInFlight = syncOpenRouterPricesInner().finally(() => {
watchInFlight = null;
});
return watchInFlight;
}
async function syncOpenRouterPricesInner(): Promise<PriceWatchNotice> {
const models = recentDistinctModels(2);
try {
const source = await refreshOpenRouterPricing(models);
const notice: PriceWatchNotice = {
at: new Date().toISOString(),
error: source.fetchError,
items: source.fetchError
? []
: compareStoredPrices(source.previousRates ?? [], source.rates, models),
};
writePriceWatchNotice(notice);
watchStatus = source.fetchError ? "error" : "done";
return notice;
} catch (err) {
watchStatus = "error";
const notice: PriceWatchNotice = {
at: new Date().toISOString(),
error: err instanceof Error ? err.message : String(err),
items: [],
};
writePriceWatchNotice(notice);
return notice;
}
}
+17
View File
@@ -178,6 +178,23 @@ export function readRecords(query: TokenStatsQuery = {}): TokenUsageRecord[] {
return records.sort((a, b) => b.at.localeCompare(a.at));
}
/** 最近调用里出现过的不同型号,按时间从新到旧。 */
export function recentDistinctModels(limit = 2): string[] {
const cap = Math.min(Math.max(limit, 1), 8);
const seen = new Set<string>();
const models: string[] = [];
for (const record of readRecords({ limit: 400 })) {
const model = record.model?.trim();
if (!model) continue;
const key = model.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
models.push(model);
if (models.length >= cap) break;
}
return models;
}
function accumulateCaller(
map: Record<string, CallerTokenBreakdown>,
caller: string,
+191 -3
View File
@@ -7,7 +7,7 @@ import {
parsePeakSchedule,
parsePricingDocument,
} from "../src/stats/pricing-parse.js";
import { buildCostReport, costForRecord, isPeakAt, splitPromptTokens } from "../src/stats/pricing-cost.js";
import { buildCostReport, compareModelsOnRecords, 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";
@@ -158,7 +158,7 @@ Prices per 1M tokens.
});
describe("parsePricingDocument JSON", () => {
it("converts OpenRouter per-token strings to per-million", () => {
it("converts OpenRouter per-token strings to per-million and keeps full id", () => {
const parsed = parsePricingDocument(
JSON.stringify({
data: [
@@ -170,7 +170,7 @@ describe("parsePricingDocument JSON", () => {
}),
"application/json",
);
expect(parsed.rates[0].model).toBe("deepseek-chat");
expect(parsed.rates[0].model).toBe("deepseek/deepseek-chat");
expect(parsed.rates[0].inputCacheMiss.offPeak).toBeCloseTo(0.14);
expect(parsed.rates[0].output.offPeak).toBeCloseTo(0.28);
});
@@ -248,6 +248,141 @@ describe("normalizePricingUrl", () => {
});
});
describe("trimOpenRouterUsualCatalog", () => {
it("keeps the newest tier in each family and drops the rest", async () => {
const { trimOpenRouterUsualCatalog, usualOpenRouterBucket } = await import(
"../src/stats/pricing.js"
);
expect(usualOpenRouterBucket("google/gemini-3.8-flash")).toBe("gemini-flash");
expect(usualOpenRouterBucket("google/gemini-2.5-pro")).toBe("gemini-pro");
expect(usualOpenRouterBucket("openai/gpt-4o:batch")).toBeNull();
const trimmed = JSON.parse(
trimOpenRouterUsualCatalog(
JSON.stringify({
data: [
{
id: "openai/gpt-5.6-luna",
created: 10,
pricing: { prompt: "0.0000002", completion: "0.000001" },
},
{
id: "openai/gpt-6-luna",
created: 20,
pricing: { prompt: "0.0000001", completion: "0.0000005" },
},
{
id: "anthropic/claude-haiku-5.5",
created: 30,
pricing: { prompt: "0.0000001", completion: "0.0000005" },
},
{
id: "x-ai/grok-4.7:batch",
created: 40,
pricing: { prompt: "0.000001", completion: "0.000003" },
},
{
id: "deepseek/deepseek-chat",
created: 50,
pricing: { prompt: "0.0000001", completion: "0.0000002" },
},
],
}),
),
) as { data: Array<{ id: string }> };
expect(trimmed.data.map((row) => row.id).sort()).toEqual([
"anthropic/claude-haiku-5.5",
"openai/gpt-6-luna",
]);
const pinned = JSON.parse(
trimOpenRouterUsualCatalog(
JSON.stringify({
data: [
{
id: "openai/gpt-4o-mini",
created: 1,
pricing: { prompt: "0.00000015", completion: "0.0000006" },
},
],
}),
["openai/gpt-4o-mini"],
),
) as { data: Array<{ id: string }> };
expect(pinned.data.map((row) => row.id)).toEqual(["openai/gpt-4o-mini"]);
});
});
describe("compareStoredPrices", () => {
it("marks a change against the previous snapshot", async () => {
const { compareStoredPrices } = await import("../src/stats/pricing.js");
const previous = [
{
model: "openai/gpt-6-luna",
aliases: [],
currency: "USD",
unitTokens: 1_000_000,
inputCacheMiss: { offPeak: 0.1 },
output: { offPeak: 0.5 },
},
];
const current = [
{
...previous[0],
inputCacheMiss: { offPeak: 0.2 },
},
];
const [changed] = compareStoredPrices(previous, current, ["openai/gpt-6-luna"]);
expect(changed.status).toBe("changed");
expect(changed.previous?.input).toBe(0.1);
expect(changed.current?.input).toBe(0.2);
const [same] = compareStoredPrices(current, current, ["openai/gpt-6-luna"]);
expect(same.status).toBe("unchanged");
const [first] = compareStoredPrices([], current, ["openai/gpt-6-luna"]);
expect(first.status).toBe("baseline");
});
});
describe("refreshOpenRouterPricing", () => {
it("stores only the usual OpenRouter tiers", async () => {
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
path.join(tmpdir(), "wa-openrouter-pricing-"),
);
const sample = {
data: [
{
id: "openai/gpt-6-luna-pro",
created: 2,
pricing: {
prompt: "0.0000001",
completion: "0.0000005",
input_cache_read: "0.00000001",
},
},
{
id: "openai/gpt-4o",
created: 1,
pricing: { prompt: "0.0000025", completion: "0.00001" },
},
],
};
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify(sample), {
status: 200,
headers: { "content-type": "application/json" },
})) as typeof fetch;
try {
const { refreshOpenRouterPricing, findModelRate, listOpenRouterCompareModels } =
await import("../src/stats/pricing.js");
const source = await refreshOpenRouterPricing();
expect(source.rates.map((rate) => rate.model)).toEqual(["openai/gpt-6-luna-pro"]);
expect(findModelRate("openai/gpt-6-luna-pro")?.inputCacheMiss.offPeak).toBeCloseTo(0.1);
expect(listOpenRouterCompareModels()).toEqual(["openai/gpt-6-luna-pro"]);
} finally {
globalThis.fetch = originalFetch;
}
});
});
describe("buildCostReport", () => {
beforeAll(() => {
process.env.WRITING_AGENT_DATA_DIR = mkdtempSync(
@@ -292,8 +427,61 @@ describe("buildCostReport", () => {
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[0].unitPrice).toMatchObject({
currency: "USD",
input: 0.15,
output: 0.6,
});
expect(report.byModel.find((r) => r.model === "mystery-model")?.unmatched).toBe(
true,
);
});
});
describe("compareModelsOnRecords", () => {
it("reprices the same tokens onto each candidate", () => {
const cheap = flashRate({
model: "anthropic/claude-haiku-5.5",
inputCacheMiss: { offPeak: 0.1 },
inputCacheHit: { offPeak: 0.01 },
output: { offPeak: 0.5 },
peakWindows: undefined,
peakWeekdays: undefined,
});
const dear = flashRate({
model: "x-ai/grok-4.7",
inputCacheMiss: { offPeak: 2 },
inputCacheHit: { offPeak: 0.5 },
output: { offPeak: 6 },
peakWindows: undefined,
peakWeekdays: undefined,
});
const catalog = {
byKey: new Map([
[cheap.model, cheap],
[dear.model, dear],
]),
rates: [cheap, dear],
};
const compared = compareModelsOnRecords(
[
{
id: "a",
at: "2026-09-13T08:00:00.000Z",
caller: "x",
model: "something-else",
promptTokens: 1_000_000,
completionTokens: 200_000,
totalTokens: 1_200_000,
cachedTokens: 0,
},
],
["x-ai/grok-4.7", "anthropic/claude-haiku-5.5", "missing"],
catalog,
);
expect(compared.usage.calls).toBe(1);
expect(compared.rows[0].projected?.amount).toBeCloseTo(2 + 1.2);
expect(compared.rows[1].projected?.amount).toBeCloseTo(0.1 + 0.1);
expect(compared.rows[2].found).toBe(false);
});
});
+75
View File
@@ -0,0 +1,75 @@
const DISMISS_KEY = "wa-price-watch-dismissed";
function money(amount, currency) {
if (amount == null || Number.isNaN(Number(amount))) return "—";
const symbol = currency === "CNY" ? "¥" : "$";
const value = Number(amount);
const digits = value >= 1 ? 2 : 4;
const body = value.toFixed(digits).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
return `${symbol}${body.includes(".") ? body : `${body}.00`}`;
}
function itemLine(item) {
if (item.status === "changed" && item.previous && item.current) {
const currency = item.current.currency || "USD";
return `${item.model} 变了:输入 ${money(item.previous.input, currency)} → ${money(item.current.input, currency)},输出 ${money(item.previous.output, currency)} → ${money(item.current.output, currency)} / 百万 token`;
}
if (item.status === "unchanged" && item.current) {
const currency = item.current.currency || "USD";
return `${item.model} 没变(输入 ${money(item.current.input, currency)},输出 ${money(item.current.output, currency)})`;
}
if (item.status === "baseline") return `${item.model} 第一次记下单价,下次启动再比`;
return `${item.model} 这次价目里没有`;
}
function showNotice(notice) {
const host = document.getElementById("price-watch");
if (!host || !notice) return;
try {
if (sessionStorage.getItem(DISMISS_KEY) === notice.at) return;
} catch {
/* ignore */
}
if (!notice.error && !notice.items?.length) return;
const changed = notice.items?.some((item) => item.status === "changed");
host.hidden = false;
host.dataset.state = notice.error ? "error" : changed ? "changed" : "idle";
const text = notice.error
? `OpenRouter 价格没拉下来:${notice.error}。上次的价目还在。`
: notice.items.map((item) => itemLine(item)).join("\n");
host.innerHTML = `<p>${escapeHtml(text).replace(/\n/g, "<br>")}</p><button type="button">知道了</button>`;
host.querySelector("button")?.addEventListener("click", () => {
host.hidden = true;
try {
sessionStorage.setItem(DISMISS_KEY, notice.at);
} catch {
/* ignore */
}
});
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
async function poll(tries = 0) {
try {
const res = await fetch("/api/stats/pricing/notice");
if (!res.ok) return;
const data = await res.json();
if (data.status === "pulling" || data.status === "idle") {
if (tries < 12) {
window.setTimeout(() => poll(tries + 1), 1500);
return;
}
}
showNotice(data.notice);
} catch {
/* 页面离线时不打扰 */
}
}
poll();
+204
View File
@@ -258,6 +258,196 @@
color: var(--st-danger);
}
.stats-compare {
margin: 0 24px 16px;
padding: 12px 14px;
background: var(--st-surface);
border: 1px solid var(--st-border);
border-radius: var(--st-radius);
}
.stats-compare .stats-section-label {
margin: 0;
}
.stats-compare-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px 16px;
margin-bottom: 12px;
}
.stats-compare-head .btn-primary:focus-visible,
.stats-compare-head .btn-primary:disabled {
outline: none;
}
.stats-compare-head .btn-primary:focus-visible {
box-shadow: 0 0 0 3px var(--st-accent-soft);
}
.stats-compare-head .btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.stats-compare .stats-table-wrap {
margin: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.stats-compare-table thead th {
vertical-align: middle;
}
.stats-compare-model-col {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.stats-sort {
appearance: none;
margin: 0;
padding: 2px 0;
border: 0;
background: transparent;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
color: inherit;
cursor: pointer;
white-space: nowrap;
}
.stats-table th.num .stats-sort {
width: 100%;
text-align: right;
}
.stats-sort:hover {
color: var(--st-text);
}
.stats-sort:focus-visible,
.stats-compare-filter:focus-visible {
outline: none;
border-radius: 4px;
box-shadow: 0 0 0 3px var(--st-accent-soft);
}
.stats-compare-table thead th[aria-sort="ascending"] .stats-sort,
.stats-compare-table thead th[aria-sort="descending"] .stats-sort {
color: var(--st-text);
}
.stats-compare-filter {
width: 9.5rem;
max-width: 36vw;
height: 26px;
padding: 0 8px;
border: 1px solid var(--st-border-strong);
border-radius: 6px;
background: var(--st-bg);
color: var(--st-text);
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12px;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.stats-compare-filter::placeholder {
color: var(--st-muted);
font-family: "Noto Sans SC", sans-serif;
}
.stats-compare-filter:focus-visible {
border-color: var(--st-accent);
}
.stats-compare-none {
padding: 16px 12px;
color: var(--st-muted);
font-size: 13px;
text-transform: none;
letter-spacing: 0;
}
.stats-compare-table .stats-compare-name {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12px;
white-space: nowrap;
}
.stats-compare-estimate {
display: grid;
grid-template-columns: minmax(4rem, 1fr) auto auto;
gap: 8px;
align-items: center;
min-width: 11rem;
}
.stats-compare-multiple {
min-width: 2.5rem;
font-size: 12px;
color: var(--st-muted);
font-variant-numeric: tabular-nums;
}
.stats-compare-track {
height: 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--st-border) 65%, transparent);
overflow: hidden;
}
.stats-compare-bar {
display: block;
height: 100%;
border-radius: 4px;
background: var(--st-accent);
min-width: 2px;
}
.stats-compare-money {
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.stats-compare-meta {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: -4px;
font-size: 12px;
color: var(--st-muted);
}
.stats-compare-status {
margin: 8px 0 0;
font-size: 12px;
color: var(--st-muted);
}
.stats-compare-status[data-state="error"] {
color: var(--st-danger);
}
.stats-compare-empty {
margin: 0;
font-size: 13px;
color: var(--st-muted);
}
.stats-section-label {
margin: 0 24px 8px;
font-size: 13px;
@@ -476,6 +666,7 @@
.stats-main .st-main-head,
.stats-toolbar,
.stats-pricing,
.stats-compare,
.stats-summary,
.stats-status,
.stats-section-label,
@@ -512,4 +703,17 @@
width: 100%;
justify-content: space-between;
}
.stats-compare-estimate {
min-width: 9rem;
}
.stats-compare-model-col {
flex-wrap: wrap;
}
.stats-compare-filter {
width: 100%;
max-width: none;
}
}
+54 -4
View File
@@ -19,6 +19,7 @@
<a class="st-brand" href="/">Writing Agent</a>
<nav class="st-nav" aria-label="主导航">
<a href="/">对话</a>
<a href="/chat.html">直聊</a>
<a href="/modules.html">技能</a>
<a href="/libraries.html">库</a>
<a href="/stats.html" aria-current="page">Token</a>
@@ -31,7 +32,7 @@
<header class="st-main-head">
<div>
<h1>Token 统计</h1>
<p class="st-sub">按时间筛选本机调用;花销按你指定的官方价目文档估算(<code>stats/*.jsonl</code>)</p>
<p class="st-sub">按时间筛选本机调用。OpenRouter 价格在下面手动拉取,只保留 DeepSeek、Claude、GLM、Gemini、GPT、Grok 的当前档。</p>
</div>
</header>
@@ -63,7 +64,7 @@
<div class="stats-pricing-head">
<p class="stats-section-label">价格来源</p>
<p class="stats-pricing-hint">
粘贴厂商官方价目页,解析型号单价后按调用时间估算(含缓存命中与高峰/闲时,若文档有写)。
厂商官方价目(含高峰/闲时)仍可粘贴文档。OpenRouter 用下方按钮拉取。
</p>
</div>
<form class="stats-pricing-add" id="pricing-form">
@@ -83,6 +84,54 @@
<p class="stats-pricing-status" id="pricing-status" hidden aria-live="polite"></p>
</section>
<section class="stats-compare" aria-label="模型价格对比">
<div class="stats-compare-head">
<div>
<p class="stats-section-label">价格对比</p>
<p class="stats-pricing-hint">
一行一个型号。前三列是每百万 token 的通常单价,最后一列是按最近 100 次调用重估的花费。
</p>
</div>
<button type="button" class="btn-primary" id="btn-pull-openrouter">拉取 OpenRouter 价格</button>
</div>
<div class="stats-table-wrap stats-compare-wrap">
<table class="stats-table stats-compare-table" id="compare-table">
<thead>
<tr>
<th scope="col" aria-sort="none">
<div class="stats-compare-model-col">
<button type="button" class="stats-sort" data-sort="model" data-label="型号" title="按型号排序">型号</button>
<input
type="search"
id="compare-filter"
class="stats-compare-filter"
placeholder="筛选型号"
aria-label="按型号筛选"
autocomplete="off"
spellcheck="false"
/>
</div>
</th>
<th scope="col" class="num" aria-sort="none">
<button type="button" class="stats-sort" data-sort="input" data-label="输入" title="按输入单价排序">输入</button>
</th>
<th scope="col" class="num" aria-sort="none">
<button type="button" class="stats-sort" data-sort="cached" data-label="缓存" title="按缓存单价排序">缓存</button>
</th>
<th scope="col" class="num" aria-sort="none">
<button type="button" class="stats-sort" data-sort="output" data-label="输出" title="按输出单价排序">输出</button>
</th>
<th scope="col" aria-sort="ascending">
<button type="button" class="stats-sort" data-sort="projected" data-label="近 100 次" title="按近 100 次花费排序">近 100 次 ↑</button>
</th>
</tr>
</thead>
<tbody id="compare-list"></tbody>
</table>
</div>
<p class="stats-compare-status" id="compare-status" hidden aria-live="polite"></p>
</section>
<div class="stats-summary" id="summary" aria-live="polite"></div>
<p class="stats-status" id="status" data-state="idle" aria-live="polite"></p>
@@ -97,15 +146,16 @@
<th scope="col" class="num">prompt</th>
<th scope="col" class="num">completion</th>
<th scope="col" class="num">cached</th>
<th scope="col" class="num">当前单价</th>
<th scope="col" class="num">估算花销</th>
<th scope="col">单价</th>
<th scope="col">来源</th>
</tr>
</thead>
<tbody id="model-rows"></tbody>
</table>
<div class="stats-empty" id="model-empty" hidden>
<strong>没有可汇总的型号</strong>
先查出调用记录,并添加官方价目文档。
先查出调用记录;OpenRouter 会自动同步单价,其他渠道可手动添加价目。
</div>
</div>
</section>
+205 -1
View File
@@ -49,6 +49,17 @@ function formatCostTotal(total, byCurrency) {
return entries.map(([currency, amount]) => formatMoney({ amount, currency })).join(" + ");
}
function formatUnitPrice(unit) {
if (!unit || unit.input == null || unit.output == null) return "—";
const currency = unit.currency || "USD";
const inPrice = formatMoney({ amount: unit.input, currency });
const outPrice = formatMoney({ amount: unit.output, currency });
const cached =
unit.cached == null ? "" : ` · 缓存 ${formatMoney({ amount: unit.cached, currency })}`;
const per = unit.unitTokens === 1_000_000 ? "/1M" : `/${formatNum(unit.unitTokens)}`;
return `${inPrice} / ${outPrice}${per}${cached}`;
}
function setStatus(text, state = "idle") {
const status = document.getElementById("status");
status.textContent = text;
@@ -111,6 +122,7 @@ async function loadStats() {
`共 ${formatNum(totalCalls)} 次调用 · 显示 ${formatNum(records.length)} 条${extra}`,
"idle",
);
loadCompare();
} catch (err) {
renderSummary(null);
renderModels([]);
@@ -119,6 +131,7 @@ async function loadStats() {
`加载失败:${err instanceof Error ? err.message : String(err)}`,
"error",
);
loadCompare();
}
}
@@ -171,6 +184,9 @@ function renderModels(rows) {
const cost = r.unmatched || !r.cost
? `<span class="stats-unpriced">无报价</span>`
: formatMoney(r.cost);
const unit = r.unmatched
? `<span class="stats-unpriced">—</span>`
: escapeHtml(formatUnitPrice(r.unitPrice));
const source = r.unmatched
? "—"
: escapeHtml(r.sourceName || "官方文档");
@@ -180,6 +196,7 @@ function renderModels(rows) {
<td class="num">${formatNum(r.promptTokens ?? 0)}</td>
<td class="num">${formatNum(r.completionTokens ?? 0)}</td>
<td class="num">${formatNum(r.cachedTokens ?? 0)}</td>
<td class="num col-unit" title="${unit}">${unit}</td>
<td class="num col-total">${cost}</td>
<td class="col-caller" title="${source}">${source}</td>
</tr>`;
@@ -325,7 +342,7 @@ function renderSuggested(items, sources = []) {
el.innerHTML = "";
return;
}
el.innerHTML = items
el.innerHTML = remaining
.map(
(s) =>
`<button type="button" class="stats-suggest" data-url="${escapeHtml(s.url)}">${escapeHtml(s.name)}</button>`,
@@ -465,6 +482,193 @@ document.getElementById("pricing-sources").addEventListener("click", async (ev)
}
});
function setCompareStatus(text, state = "idle") {
const el = document.getElementById("compare-status");
if (!text) {
el.hidden = true;
el.textContent = "";
el.dataset.state = "idle";
return;
}
el.hidden = false;
el.textContent = text;
el.dataset.state = state;
}
function barWidth(value, max) {
if (!max || value == null || value <= 0) return 0;
return Math.max(2, Math.round((value / max) * 100));
}
function shortModelName(id) {
const slash = id.lastIndexOf("/");
return slash >= 0 ? id.slice(slash + 1) : id;
}
/** 默认按近 100 次从低到高;没有调用时这一列没有数,排序退回输入单价。 */
let comparePayload = null;
let compareSort = { key: "projected", dir: "asc" };
function compareSortValue(row, key, usageCalls) {
const unit = row.unitPrice || {};
if (key === "model") return shortModelName(row.model || "").toLowerCase();
if (key === "input") return unit.input ?? null;
if (key === "cached") return unit.cached ?? null;
if (key === "output") return unit.output ?? null;
if (usageCalls > 0) return row.projected?.amount ?? null;
return unit.input ?? null;
}
function compareNullable(av, bv, dir) {
const aMissing = av == null || av === "";
const bMissing = bv == null || bv === "";
if (aMissing && bMissing) return 0;
if (aMissing) return 1;
if (bMissing) return -1;
const cmp = typeof av === "string" ? av.localeCompare(bv, "en") : av - bv;
return dir === "desc" ? -cmp : cmp;
}
function syncCompareSortHeaders() {
for (const btn of document.querySelectorAll("#compare-table .stats-sort")) {
const key = btn.dataset.sort;
const th = btn.closest("th");
const active = compareSort.key === key;
th?.setAttribute(
"aria-sort",
active ? (compareSort.dir === "asc" ? "ascending" : "descending") : "none",
);
const label = btn.dataset.label || "";
btn.textContent = active ? `${label} ${compareSort.dir === "asc" ? "↑" : "↓"}` : label;
}
}
function renderCompare(data) {
const list = document.getElementById("compare-list");
const table = document.getElementById("compare-table");
const rows = (data?.rows || []).filter((row) => row.found && row.unitPrice);
const usageCalls = data?.usage?.calls ?? 0;
syncCompareSortHeaders();
if (!rows.length) {
table.hidden = true;
list.innerHTML = "";
return;
}
table.hidden = false;
const query = (document.getElementById("compare-filter")?.value || "").trim().toLowerCase();
const matched = query
? rows.filter((row) => {
const id = String(row.model || "").toLowerCase();
return id.includes(query) || shortModelName(id).includes(query);
})
: rows;
if (!matched.length) {
list.innerHTML = `<tr><td class="stats-compare-none" colspan="5">没有名称包含「${escapeHtml(query)}」的型号</td></tr>`;
return;
}
const ranked = [...matched].sort((a, b) =>
compareNullable(
compareSortValue(a, compareSort.key, usageCalls),
compareSortValue(b, compareSort.key, usageCalls),
compareSort.dir,
),
);
const maxAmount = Math.max(0, ...rows.map((row) => row.projected?.amount || 0));
const cheapest = Math.min(...rows.map((row) => row.projected?.amount || 0).filter((n) => n > 0));
list.innerHTML = ranked
.map((row) => {
const unit = row.unitPrice;
const currency = unit.currency;
const cached = unit.cached == null ? "—" : formatMoney({ amount: unit.cached, currency });
const amount = row.projected?.amount || 0;
const multiple =
usageCalls > 0 && cheapest > 0 && amount > 0
? `×${(amount / cheapest).toFixed(amount / cheapest >= 10 ? 0 : 1)}`
: "";
const estimate = usageCalls > 0
? `<div class="stats-compare-estimate">
<div class="stats-compare-track" aria-hidden="true"><span class="stats-compare-bar" style="width:${barWidth(amount, maxAmount)}%"></span></div>
<span class="stats-compare-money">${escapeHtml(formatMoney(row.projected))}</span>
<span class="stats-compare-multiple">${escapeHtml(multiple)}</span>
</div>`
: "—";
return `<tr>
<td class="stats-compare-name" title="${escapeHtml(row.model)}">${escapeHtml(shortModelName(row.model))}</td>
<td class="num">${escapeHtml(formatMoney({ amount: unit.input, currency }))}</td>
<td class="num">${escapeHtml(cached)}</td>
<td class="num">${escapeHtml(formatMoney({ amount: unit.output, currency }))}</td>
<td>${estimate}</td>
</tr>`;
})
.join("");
}
document.querySelector("#compare-table thead")?.addEventListener("click", (ev) => {
const btn = ev.target.closest(".stats-sort");
if (!btn) return;
const key = btn.dataset.sort;
if (!key) return;
if (compareSort.key === key) {
compareSort.dir = compareSort.dir === "asc" ? "desc" : "asc";
} else {
compareSort = { key, dir: "asc" };
}
if (comparePayload) renderCompare(comparePayload);
else syncCompareSortHeaders();
});
document.getElementById("compare-filter")?.addEventListener("input", () => {
if (comparePayload) renderCompare(comparePayload);
});
async function loadCompare() {
try {
const data = await api("/api/stats/compare");
comparePayload = data;
renderCompare(data);
const usage = data.usage || {};
const calls = usage.calls ?? 0;
if (!data.rows?.length) {
setCompareStatus("尚未拉取", "idle");
return;
}
if (!calls) {
setCompareStatus("通常单价已显示。最近没有调用,预估区为空。", "idle");
return;
}
const prompt = usage.promptTokens || 0;
const cached = usage.cachedTokens || 0;
const hit = prompt > 0 ? Math.round((cached / prompt) * 100) : 0;
setCompareStatus(
`最近 ${formatNum(calls)} 次:输入 ${formatNum(prompt)}(缓存命中 ${formatNum(cached)},${hit}%)· 输出 ${formatNum(usage.completionTokens || 0)}`,
"idle",
);
} catch (err) {
comparePayload = null;
document.getElementById("compare-list").innerHTML = "";
document.getElementById("compare-table").hidden = true;
setCompareStatus(err instanceof Error ? err.message : String(err), "error");
}
}
document.getElementById("btn-pull-openrouter").addEventListener("click", async () => {
const btn = document.getElementById("btn-pull-openrouter");
btn.disabled = true;
setCompareStatus("正在拉取 OpenRouter 价格…", "idle");
try {
const data = await api("/api/stats/pricing/openrouter", { method: "POST" });
await loadCompare();
await loadStats();
if (data.source?.fetchError) {
setCompareStatus(data.source.fetchError, "error");
}
} catch (err) {
setCompareStatus(err instanceof Error ? err.message : String(err), "error");
} finally {
btn.disabled = false;
}
});
document.getElementById("rows")?.addEventListener("click", (event) => {
const row = event.target.closest("tr.stats-call");
if (!row) return;