From 8218df841b42ab1a2281bc71e41e812eb0b383bc Mon Sep 17 00:00:00 2001 From: rezon-aki <322665796+rezon-aki@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:11:51 +0800 Subject: [PATCH] fix(client): price the session total per round The session total multiplied the session-wide token totals by the list price at Date.now(), so every round was priced at the tier in effect when the panel was opened (up to 2x off during peak hours), and on dsh >= 0.1.2 the model chip was empty so the estimate fell back to fallback pricing. The indicator line and the panel now both sum the per-round costs (sumRoundCosts in src/client/rounds/types.ts), so the total agrees with the per-turn badges; the old estimate remains as a fallback when history is unavailable. The indicator model chip falls back to the last round in history. Verified: npm run typecheck; npm test 41/41. --- src/client/UsageIndicator.tsx | 27 ++++++++++++++++++++++++--- src/client/UsagePanel.tsx | 8 +++++--- src/client/rounds/types.ts | 23 ++++++++++++++++++++++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/client/UsageIndicator.tsx b/src/client/UsageIndicator.tsx index 30c9877..a1dc5e1 100644 --- a/src/client/UsageIndicator.tsx +++ b/src/client/UsageIndicator.tsx @@ -10,7 +10,9 @@ import { currencySymbol, useBalance } from './balance.ts' import { useDisplayCurrency } from './currency.ts' import { getUiCopy, useUiLocale } from './i18n.ts' import { resolveCost, usePricing } from './pricing-api.ts' +import { useHistoryRounds } from './rounds/history.ts' import { useObservedRounds } from './rounds/observed.ts' +import { sumRoundCosts, type ChartRound } from './rounds/types.ts' import { snapshotNodes, type ConversationNode, type ConversationSnapshot } from './snapshot.ts' import type { ContextBreakdownData } from './diagnose/context.ts' import { UsagePanel } from './UsagePanel.tsx' @@ -25,6 +27,15 @@ export interface DockUsageProps { input: unknown } +/** 历史折叠的模型归因:0.1.2+ 的快照节点不带 provenance/requestConfig,用最后一轮校准(面板同口径)。 */ +function lastRoundModel(rounds: readonly ChartRound[]): string | undefined { + for (let i = rounds.length - 1; i >= 0; i--) { + const model = rounds[i].model + if (model !== null && model !== '') return model + } + return undefined +} + function deriveModel(nodes: readonly ConversationNode[]): string | undefined { for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i] @@ -65,13 +76,23 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { const pressure = useProjection('contextPressure') as { pressureTokens?: number; projectedTokens?: number; contextWindow?: number } | undefined const breakdown = useProjection('contextBreakdown') as ContextBreakdownData | undefined const nodes = useSession((s) => snapshotNodes(s)) - const model = useMemo(() => deriveModel(nodes), [nodes]) + const history = useHistoryRounds(sessionId) + const model = useMemo(() => deriveModel(nodes) ?? lastRoundModel(history.rounds), [nodes, history.rounds]) const pricing = usePricing() const observedRounds = useObservedRounds(totals, nodes, pricing.table, currency) const { status: balanceStatus, data: balanceData, load: loadBalance } = useBalance(true) const hasTokens = totals !== undefined && (billedInputTokens(totals) > 0 || totals.outputTokens > 0) - const cost = totals !== undefined && pricing.table !== null ? resolveCost(pricing.table, totals, model, Date.now(), currency) : undefined + // 会话总计:优先「Σ 各轮成本」(每轮各自的时段与模型,与每轮徽章天然自洽); + // 历史不可用时才退回「当前时段 × 会话总量」的估算。 + const historyCost = useMemo( + () => (history.status === 'ok' ? sumRoundCosts(history.rounds, currency) : null), + [history.status, history.rounds, currency], + ) + const estimate = totals !== undefined && pricing.table !== null ? resolveCost(pricing.table, totals, model, Date.now(), currency) : undefined + const cost = historyCost !== null + ? { total: historyCost.total, estimated: historyCost.estimated } + : estimate === undefined ? undefined : { total: estimate.split.total, estimated: estimate.estimated } const cacheHit = totals !== undefined ? cacheHitPercent(totals) : null const pressurePct = pressurePercent(pressure) const breakdownTotal = (breakdown?.systemTokens ?? 0) + (breakdown?.toolsTokens ?? 0) + (breakdown?.messageTokens ?? 0) @@ -136,7 +157,7 @@ export function UsageIndicator(props: DockUsageProps): JSX.Element | null { parts.push({ key: 'output', text: `${copy.output} ${formatTokens(totals.outputTokens)}` }) if (cacheHit !== null) parts.push({ key: 'cache', text: `${copy.cache} ${cacheHit}%` }) } - if (cost !== undefined) parts.push({ key: 'cost', text: `${copy.cost} ${cost.estimated ? '≈' : ''}${formatMoney(cost.split.total, currency)}`, estimated: cost.estimated }) + if (cost !== undefined) parts.push({ key: 'cost', text: `${copy.cost} ${cost.estimated ? '≈' : ''}${formatMoney(cost.total, currency)}`, estimated: cost.estimated }) if (model !== undefined) parts.push({ key: 'model', text: model.replace(/^deepseek-/, '') }) const balanceLabel = balanceStatus === 'loading' || (balanceStatus === 'ok' && balance === undefined) diff --git a/src/client/UsagePanel.tsx b/src/client/UsagePanel.tsx index bf0267d..44916b4 100644 --- a/src/client/UsagePanel.tsx +++ b/src/client/UsagePanel.tsx @@ -18,7 +18,7 @@ import { analyzeContext, type ContextBreakdownData } from './diagnose/context.ts import { getUiCopy, type UiLocale } from './i18n.ts' import { resolvePricing, usePricing } from './pricing-api.ts' import { useHistoryRounds } from './rounds/history.ts' -import type { ChartRound } from './rounds/types.ts' +import { sumRoundCosts, type ChartRound } from './rounds/types.ts' export interface ContextPressureView { pressureTokens?: number projectedTokens?: number @@ -99,9 +99,11 @@ export function UsagePanel(props: UsagePanelProps): JSX.Element { if (pricing.table === null) return null return resolvePricing(pricing.table, effectiveModel) }, [pricing.table, effectiveModel]) - // 面板汇总成本:按所选币种的官方刊例价、按高峰/空闲时段计费。时段取 - // 「最近一轮历史的开始时刻」(更贴近实际发生时段;无历史时用当前时刻)。 + // 面板汇总成本:优先「Σ 各轮成本」(每轮各自的时段与模型,与每轮徽章自洽); + // 历史不可用时退回「会话总量 × 刊例价」,时段取最近一轮开始时刻(无历史用当前时刻)。 const costSplitTotal = useMemo(() => { + const summed = historyRounds !== null ? sumRoundCosts(historyRounds, currency) : null + if (summed !== null) return summed if (costView === null) return null const last = historyRounds !== null && historyRounds.length > 0 ? historyRounds[historyRounds.length - 1] : null return costSplitAt(totals, costView.pricing, last?.startedAt ?? Date.now(), currency) diff --git a/src/client/rounds/types.ts b/src/client/rounds/types.ts index 37b06c7..97751b0 100644 --- a/src/client/rounds/types.ts +++ b/src/client/rounds/types.ts @@ -2,7 +2,7 @@ * client 侧轮次数据形状:host `/usage` 的 wire 格式(权威历史) * 与本页观测增量共用同一 ChartRound 形状,图表只认这一个类型。 */ -import type { CostSplit, TokenUsageBuckets } from '../../pricing/calc.ts' +import type { CostCurrency, CostSplit, TokenUsageBuckets } from '../../pricing/calc.ts' import type { CompactionRecord } from '../../usage/compactions.ts' /** 每轮成本(来自 host 折叠,官方 CNY/USD 双币种各一份;观测增量路径为 null)。 */ @@ -45,3 +45,24 @@ export interface UsageResponse { } export type UsageStatus = 'idle' | 'loading' | 'ok' | 'error' + +/** + * Σ 各轮成本(权威历史口径:每轮各自的时段与模型),用于会话汇总—— + * 与每轮徽章天然自洽。任一轮缺成本(观测增量路径)或无轮次时返回 null,调用方回退估算。 + */ +export function sumRoundCosts(rounds: readonly ChartRound[], currency: CostCurrency): (CostSplit & { estimated: boolean }) | null { + if (rounds.length === 0) return null + const sum: CostSplit = { input: 0, cacheRead: 0, output: 0, total: 0 } + let estimated = false + for (const round of rounds) { + const cost = round.cost + if (cost === null) return null + const split = cost[currency] + sum.input += split.input + sum.cacheRead += split.cacheRead + sum.output += split.output + sum.total += split.total + estimated ||= cost.estimated + } + return { ...sum, estimated } +}