启动时对照开放路由价目,并在统计页看清缓存费用。
最近用过的模型变价时可以在页上直接核对。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user