From b59ccf976c95bcd6c92579fea336046083481c3c Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 14 Oct 2026 08:29:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=AF=E5=8A=A8=E6=97=B6=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E8=B7=AF=E7=94=B1=E4=BB=B7=E7=9B=AE=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=E7=BB=9F=E8=AE=A1=E9=A1=B5=E7=9C=8B=E6=B8=85?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E8=B4=B9=E7=94=A8=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最近用过的模型变价时可以在页上直接核对。 Co-authored-by: Cursor --- src/server/stats-handlers.ts | 49 ++++- src/stats/pricing-cost.ts | 88 ++++++++ src/stats/pricing-parse.ts | 15 +- src/stats/pricing.ts | 383 ++++++++++++++++++++++++++++++++++- src/stats/token-store.ts | 17 ++ tests/pricing.test.ts | 194 +++++++++++++++++- web/price-watch.js | 75 +++++++ web/stats.css | 204 +++++++++++++++++++ web/stats.html | 58 +++++- web/stats.js | 206 ++++++++++++++++++- 10 files changed, 1271 insertions(+), 18 deletions(-) create mode 100644 web/price-watch.js diff --git a/src/server/stats-handlers.ts b/src/server/stats-handlers.ts index 0d5ef4d..84e47f2 100644 --- a/src/server/stats-handlers.ts +++ b/src/server/stats-handlers.ts @@ -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 { @@ -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 }; diff --git a/src/stats/pricing-cost.ts b/src/stats/pricing-cost.ts index 9b8d7e5..f9c6ad3 100644 --- a/src/stats/pricing-cost.ts +++ b/src/stats/pricing-cost.ts @@ -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; @@ -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(); + 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 }; +} diff --git a/src/stats/pricing-parse.ts b/src/stats/pricing-parse.ts index 5f89760..ee35b48 100644 --- a/src/stats/pricing-parse.ts +++ b/src/stats/pricing-parse.ts @@ -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 解析失败"] }; } diff --git a/src/stats/pricing.ts b/src/stats/pricing.ts index f76bff5..4aa528f 100644 --- a/src/stats/pricing.ts +++ b/src/stats/pricing.ts @@ -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 }>(); + 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> | 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 => + 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 { + 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 { +export async function refreshPricingSource( + id: string, + keepIds: string[] = [], +): Promise { const data = readFile(); const source = data.sources.find((s) => s.id === id); if (!source) throw new Error("价目来源不存在"); try { - const parsed = await fetchAndParsePricing(source.url); + 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 | 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 { + const map = new Map(); + 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, 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 { + if (watchInFlight) return watchInFlight; + watchStatus = "pulling"; + watchInFlight = syncOpenRouterPricesInner().finally(() => { + watchInFlight = null; + }); + return watchInFlight; +} + +async function syncOpenRouterPricesInner(): Promise { + 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; + } +} diff --git a/src/stats/token-store.ts b/src/stats/token-store.ts index 37f2f59..d7f5dd0 100644 --- a/src/stats/token-store.ts +++ b/src/stats/token-store.ts @@ -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(); + 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, caller: string, diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts index 0096eb3..c13a033 100644 --- a/tests/pricing.test.ts +++ b/tests/pricing.test.ts @@ -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); + }); +}); diff --git a/web/price-watch.js b/web/price-watch.js new file mode 100644 index 0000000..0849e91 --- /dev/null +++ b/web/price-watch.js @@ -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 = `

${escapeHtml(text).replace(/\n/g, "
")}

`; + 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, ">"); +} + +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(); diff --git a/web/stats.css b/web/stats.css index 2c01991..142ea9a 100644 --- a/web/stats.css +++ b/web/stats.css @@ -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; + } } diff --git a/web/stats.html b/web/stats.html index 59fef17..4baeafe 100644 --- a/web/stats.html +++ b/web/stats.html @@ -19,6 +19,7 @@ Writing Agent