Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/usage-cost-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Extend the existing **Usage** console page with a **Cost** tab: quota consumed across providers, DeepSeek list-price equivalent cost (peak / off-peak), stacked cost-over-time chart, per-model breakdown, and the official price table. Stats tab (requests / latency / tokens / credits by model · account · provider) is unchanged. Adds `deepseek-flash` to the pricing catalog. Credits reuse the `#361` `request_usage_details` fallback so numbers match the Stats tab.

### 中文

- 在现有「用量」页增加 **费用** 选项卡:跨供应商订阅额度、DeepSeek 官方列表价折合费用(高峰/闲时)、费用趋势图、按模型明细与官方价格表。统计选项卡(按模型·账号·供应商的请求/延迟/token/额度)保持不变。价格目录补上 `deepseek-flash`。额度汇总沿用 #361 的 `request_usage_details` 回退,与统计页数字一致。
73 changes: 73 additions & 0 deletions frontend/src/api/usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { api } from './client'

export type ModelPriceBand = {
cache_hit: number
cache_miss: number
output: number
}

export type ModelPrice = {
id: string
label: string
vendor: string
currency: string
off_peak: ModelPriceBand
peak: ModelPriceBand
peak_start_hour: number
peak_end_hour: number
peak_weekdays_only: boolean
peak_note?: string
source?: string
}

export type PricingCatalog = {
currency: string
models: ModelPrice[]
note?: string
}

export type ModelCost = {
id: string
label: string
vendor: string
currency: string
requests: number
cache_hit: number
cache_miss: number
output: number
total_tokens: number
cost: number
cost_off_peak: number
cost_peak: number
peak_requests: number
credits: number
priced: boolean
}

export type CostSeriesPoint = {
at: string
per_model: Record<string, number>
tokens: number
credits: number
}

export type CostBreakdown = {
window: { from: string; to: string; hours: number }
currency: string
models: ModelCost[]
totals: ModelCost
series: CostSeriesPoint[]
unpriced?: string[]
}

export function fetchUsagePricing() {
return api<PricingCatalog>('/api/usage/pricing')
}

export function fetchUsageCost(query: { from?: string; to?: string } = {}) {
const params = new URLSearchParams()
if (query.from) params.set('from', query.from)
if (query.to) params.set('to', query.to)
const suffix = params.toString() ? `?${params}` : ''
return api<CostBreakdown>(`/api/usage/cost${suffix}`)
}
184 changes: 184 additions & 0 deletions frontend/src/components/usage/CostChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { useMemo, useRef, useLayoutEffect } from 'react'
import gsap from 'gsap'
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { CostSeriesPoint, ModelCost } from '@/api/usage'
import { formatCNY } from '@/lib/format'

// Stacked bands need distinct, sober hues. Row 1 uses the console accent ink
// (charcoal / snow); deeper rows fall back to muted steel and the two status
// hues so the stack stays readable without inventing a second palette. Order is
// stable by cost (the caller sorts models), so the biggest spender is always ink.
const SERIES_COLORS = ['var(--accent)', 'var(--muted)', 'var(--success)', 'var(--warning)']

function bucketLabel(value: string, daily: boolean, lang: 'en' | 'zh') {
const date = new Date(value)
if (!Number.isFinite(date.getTime())) return ''
return new Intl.DateTimeFormat(lang === 'zh' ? 'zh-CN' : 'en-US', daily
? { month: 'short', day: 'numeric' }
: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
}

type ChartRow = { at: string } & Record<string, string | number>

function CostTooltip({
active,
payload,
label,
daily,
lang,
models,
totalLabel,
}: {
active?: boolean
payload?: Array<{ dataKey: string; value: number }>
label?: string
daily: boolean
lang: 'en' | 'zh'
models: ModelCost[]
totalLabel: string
}) {
if (!active || !payload?.length) return null
const labelById = new Map(models.map((model) => [model.id, model.label]))
const rows = payload.filter((item) => (item.value ?? 0) > 0).sort((a, b) => b.value - a.value)
const total = payload.reduce((sum, item) => sum + (item.value ?? 0), 0)
return (
<div className="rounded-xl bg-overlay px-3 py-2 text-caption text-overlay-foreground shadow-overlay">
<div className="mono text-muted">{bucketLabel(String(label), daily, lang)}</div>
<div className="mt-1.5 grid gap-1">
{rows.map((item) => (
<div key={item.dataKey} className="flex items-center justify-between gap-6">
<span className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full" style={{ background: SERIES_COLORS[models.findIndex((m) => m.id === item.dataKey) % SERIES_COLORS.length] }} />
{labelById.get(item.dataKey) || item.dataKey}
</span>
<span className="mono font-medium">{formatCNY(item.value)}</span>
</div>
))}
<div className="flex items-center justify-between gap-6 border-t border-separator pt-1">
<span className="text-muted">{totalLabel}</span>
<span className="mono font-medium">{formatCNY(total)}</span>
</div>
</div>
</div>
)
}

export function CostChart({
series,
models,
lang,
emptyLabel,
totalLabel,
}: {
series: CostSeriesPoint[]
models: ModelCost[]
lang: 'en' | 'zh'
emptyLabel: string
totalLabel: string
}) {
const rootRef = useRef<HTMLDivElement>(null)
const ordered = useMemo(() => models.map((model) => model.id), [models])
const data = useMemo<ChartRow[]>(() => series.map((point) => {
const row: ChartRow = { at: point.at }
for (const id of ordered) row[id] = Number((point.per_model[id] ?? 0).toFixed(4))
return row
}), [series, ordered])
const peak = Math.max(0, ...data.map((row) => ordered.reduce((sum, id) => sum + Number(row[id] ?? 0), 0)))
const hasCost = peak > 0
const span = data.length > 1 ? Date.parse(data[data.length - 1].at) - Date.parse(data[0].at) : 0
const daily = span > 48 * 60 * 60 * 1000
const signature = data.map((row) => `${row.at}:${ordered.map((id) => Number(row[id])).join(',')}`).join('|')

useLayoutEffect(() => {
const root = rootRef.current
if (!root || !hasCost) return
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: no-preference)', () => {
const fills = gsap.utils.toArray<SVGPathElement>('.recharts-area-area', root)
gsap.fromTo(fills, { autoAlpha: 0 }, {
autoAlpha: 1,
duration: 0.5,
ease: 'power2.out',
stagger: 0.05,
})
}, root)
return () => media.revert()
}, [hasCost, signature])

if (!series.length || !hasCost) {
return (
<div className="grid min-h-56 place-items-center px-6 py-12 text-center text-sm text-muted">
{emptyLabel}
</div>
)
}

return (
<div ref={rootRef} className="min-w-0">
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<defs>
{ordered.map((id, index) => (
<linearGradient key={id} id={`cost-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={SERIES_COLORS[index % SERIES_COLORS.length]} stopOpacity={0.32} />
<stop offset="100%" stopColor={SERIES_COLORS[index % SERIES_COLORS.length]} stopOpacity={0.04} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke="var(--separator)" strokeDasharray="3 6" />
<XAxis
dataKey="at"
tickLine={false}
axisLine={false}
minTickGap={28}
tick={{ fill: 'var(--muted)', fontSize: 10, fontFamily: 'var(--font-mono)' }}
tickFormatter={(value: string) => bucketLabel(value, daily, lang)}
/>
<YAxis
width={48}
tickLine={false}
axisLine={false}
tick={{ fill: 'var(--muted)', fontSize: 10, fontFamily: 'var(--font-mono)' }}
tickFormatter={(value: number) => `¥${value >= 10 ? Math.round(value) : Number(value.toFixed(1))}`}
/>
<Tooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '3 3' }}
content={<CostTooltip daily={daily} lang={lang} models={models} totalLabel={totalLabel} />}
/>
{ordered.map((id, index) => (
<Area
key={id}
type="monotone"
dataKey={id}
stackId="cost"
stroke={SERIES_COLORS[index % SERIES_COLORS.length]}
strokeWidth={1.5}
fill={`url(#cost-${id})`}
isAnimationActive={false}
dot={false}
activeDot={{ r: 3, strokeWidth: 1.2 }}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted">
{models.map((model, index) => (
<span key={model.id} className="inline-flex items-center gap-1.5">
<span className="size-1.5 rounded-full" style={{ background: SERIES_COLORS[index % SERIES_COLORS.length] }} />
{model.label}
</span>
))}
</div>
</div>
)
}
Loading
Loading