支持导入定价文档并在统计页估算费用。
把用量与单价对齐,方便对照各模型实际开销。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import type { TokenUsageRecord } from "./token-store.js";
|
||||
import {
|
||||
findModelRate,
|
||||
resolveRateCatalog,
|
||||
type ResolvedRate,
|
||||
} from "./pricing.js";
|
||||
import type { PeakWindow, RateBand } from "./pricing-parse.js";
|
||||
|
||||
export type Money = {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type RecordCost = {
|
||||
cost: Money | null;
|
||||
unmatched: boolean;
|
||||
sourceName?: string;
|
||||
};
|
||||
|
||||
export type ModelCostRow = {
|
||||
model: string;
|
||||
calls: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cachedTokens: number;
|
||||
cacheMissTokens: number;
|
||||
cost: Money | null;
|
||||
unmatched: boolean;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
|
||||
export type CostReport = {
|
||||
total: Money | null;
|
||||
byCurrency: Record<string, number>;
|
||||
unpricedCalls: number;
|
||||
pricedCalls: number;
|
||||
byModel: ModelCostRow[];
|
||||
};
|
||||
|
||||
export function isPeakAt(
|
||||
at: Date,
|
||||
windows: PeakWindow[] | undefined,
|
||||
weekdays: number[] | undefined,
|
||||
): boolean {
|
||||
if (!windows?.length) return false;
|
||||
if (weekdays?.length && !weekdays.includes(at.getUTCDay())) return false;
|
||||
const minutes = at.getUTCHours() * 60 + at.getUTCMinutes();
|
||||
return windows.some((w) => minutes >= w.startMinutes && minutes < w.endMinutes);
|
||||
}
|
||||
|
||||
export function splitPromptTokens(record: TokenUsageRecord): {
|
||||
cached: number;
|
||||
miss: number;
|
||||
output: number;
|
||||
} {
|
||||
const cached = Math.max(0, record.cachedTokens ?? 0);
|
||||
let miss = record.cacheMissTokens;
|
||||
if (miss == null) {
|
||||
miss = Math.max(0, record.promptTokens - cached);
|
||||
}
|
||||
const covered = cached + miss;
|
||||
if (covered < record.promptTokens) {
|
||||
miss += record.promptTokens - covered;
|
||||
}
|
||||
return { cached, miss, output: Math.max(0, record.completionTokens) };
|
||||
}
|
||||
|
||||
function bandPrice(band: RateBand | undefined, peak: boolean): number {
|
||||
if (!band) return 0;
|
||||
if (peak && band.peak != null) return band.peak;
|
||||
return band.offPeak;
|
||||
}
|
||||
|
||||
export function costForRecord(
|
||||
record: TokenUsageRecord,
|
||||
rate: ResolvedRate,
|
||||
): Money {
|
||||
const at = new Date(record.at);
|
||||
const peak = isPeakAt(at, rate.peakWindows, rate.peakWeekdays);
|
||||
const parts = splitPromptTokens(record);
|
||||
const hitPrice = rate.inputCacheHit
|
||||
? bandPrice(rate.inputCacheHit, peak)
|
||||
: bandPrice(rate.inputCacheMiss, peak);
|
||||
const missPrice = bandPrice(rate.inputCacheMiss, peak);
|
||||
const outPrice = bandPrice(rate.output, peak);
|
||||
const unit = rate.unitTokens || 1_000_000;
|
||||
const amount =
|
||||
(parts.cached * hitPrice + parts.miss * missPrice + parts.output * outPrice) /
|
||||
unit;
|
||||
return { amount: Math.round(amount * 1e10) / 1e10, currency: rate.currency };
|
||||
}
|
||||
|
||||
export function attachRecordCost(
|
||||
record: TokenUsageRecord,
|
||||
catalog = resolveRateCatalog(),
|
||||
): RecordCost {
|
||||
const rate = findModelRate(record.model || "", catalog);
|
||||
if (!rate) return { cost: null, unmatched: true };
|
||||
return {
|
||||
cost: costForRecord(record, rate),
|
||||
unmatched: false,
|
||||
sourceName: rate.sourceName,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCostReport(
|
||||
records: TokenUsageRecord[],
|
||||
catalog = resolveRateCatalog(),
|
||||
): CostReport {
|
||||
const byModel = new Map<string, ModelCostRow>();
|
||||
const byCurrency: Record<string, number> = {};
|
||||
let unpricedCalls = 0;
|
||||
let pricedCalls = 0;
|
||||
|
||||
for (const record of records) {
|
||||
const model = record.model?.trim() || "(未填型号)";
|
||||
const rate = findModelRate(record.model || "", catalog);
|
||||
const priced = rate ? costForRecord(record, rate) : null;
|
||||
if (priced) {
|
||||
pricedCalls += 1;
|
||||
byCurrency[priced.currency] = (byCurrency[priced.currency] ?? 0) + priced.amount;
|
||||
} else {
|
||||
unpricedCalls += 1;
|
||||
}
|
||||
|
||||
const prev = byModel.get(model) ?? {
|
||||
model,
|
||||
calls: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
cost: priced ? { amount: 0, currency: priced.currency } : null,
|
||||
unmatched: !rate,
|
||||
sourceName: rate?.sourceName,
|
||||
sourceUrl: rate?.sourceUrl,
|
||||
};
|
||||
prev.calls += 1;
|
||||
prev.promptTokens += record.promptTokens;
|
||||
prev.completionTokens += record.completionTokens;
|
||||
prev.totalTokens += record.totalTokens;
|
||||
prev.cachedTokens += record.cachedTokens ?? 0;
|
||||
prev.cacheMissTokens += record.cacheMissTokens ?? 0;
|
||||
if (priced) {
|
||||
if (!prev.cost) prev.cost = { amount: 0, currency: priced.currency };
|
||||
if (prev.cost.currency === priced.currency) {
|
||||
prev.cost.amount += priced.amount;
|
||||
}
|
||||
prev.unmatched = false;
|
||||
}
|
||||
byModel.set(model, prev);
|
||||
}
|
||||
|
||||
const currencies = Object.keys(byCurrency);
|
||||
const total =
|
||||
currencies.length === 1
|
||||
? { amount: byCurrency[currencies[0]], currency: currencies[0] }
|
||||
: null;
|
||||
|
||||
const rows = [...byModel.values()].sort((a, b) => {
|
||||
const ac = a.cost?.amount ?? -1;
|
||||
const bc = b.cost?.amount ?? -1;
|
||||
return bc - ac;
|
||||
});
|
||||
|
||||
return {
|
||||
total,
|
||||
byCurrency,
|
||||
unpricedCalls,
|
||||
pricedCalls,
|
||||
byModel: rows,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user