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