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-all-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Usage Cost tab leads with quota and tokens for every model; DeepSeek list-price CNY is optional and collapsed by default.

### 中文

- 用量「额度」页以各模型的订阅额度与 token 为主;DeepSeek 官方列表价折算改为可选,默认折叠。
5 changes: 5 additions & 0 deletions frontend/src/api/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ export type ModelCost = {

export type CostSeriesPoint = {
at: string
/** DeepSeek list-price CNY by model (priced models only; optional estimate). */
per_model: Record<string, number>
/** Subscription credits by model — every provider. */
per_model_credits: Record<string, number>
/** Token totals by model — every provider. */
per_model_tokens: Record<string, number>
tokens: number
credits: number
}
Expand Down
80 changes: 44 additions & 36 deletions frontend/src/components/usage/CostChart.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useMemo, useRef, useLayoutEffect } from 'react'
import gsap from 'gsap'
import { useMemo } from 'react'
import {
Area,
AreaChart,
Expand All @@ -10,12 +9,13 @@ import {
YAxis,
} from 'recharts'
import type { CostSeriesPoint, ModelCost } from '@/api/usage'
import { formatCNY } from '@/lib/format'
import { formatCNY, formatCompact, formatCredits } from '@/lib/format'

export type CostChartMetric = 'credits' | 'tokens' | 'cost'

// 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.
// hues so the stack stays readable without inventing a second palette.
const SERIES_COLORS = ['var(--accent)', 'var(--muted)', 'var(--success)', 'var(--warning)']

function bucketLabel(value: string, daily: boolean, lang: 'en' | 'zh') {
Expand All @@ -26,6 +26,24 @@ function bucketLabel(value: string, daily: boolean, lang: 'en' | 'zh') {
: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
}

function seriesValues(point: CostSeriesPoint, metric: CostChartMetric): Record<string, number> {
if (metric === 'credits') return point.per_model_credits ?? {}
if (metric === 'tokens') return point.per_model_tokens ?? {}
return point.per_model ?? {}
}

function formatMetric(value: number, metric: CostChartMetric) {
if (metric === 'cost') return formatCNY(value)
if (metric === 'tokens') return formatCompact(value)
return formatCredits(value)
}

function formatAxis(value: number, metric: CostChartMetric) {
if (metric === 'cost') return `¥${value >= 10 ? Math.round(value) : Number(value.toFixed(1))}`
if (metric === 'tokens') return formatCompact(value)
return formatCredits(value)
}

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

function CostTooltip({
Expand All @@ -36,6 +54,7 @@ function CostTooltip({
lang,
models,
totalLabel,
metric,
}: {
active?: boolean
payload?: Array<{ dataKey: string; value: number }>
Expand All @@ -44,6 +63,7 @@ function CostTooltip({
lang: 'en' | 'zh'
models: ModelCost[]
totalLabel: string
metric: CostChartMetric
}) {
if (!active || !payload?.length) return null
const labelById = new Map(models.map((model) => [model.id, model.label]))
Expand All @@ -59,12 +79,12 @@ function CostTooltip({
<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>
<span className="mono font-medium">{formatMetric(item.value, metric)}</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>
<span className="mono font-medium">{formatMetric(total, metric)}</span>
</div>
</div>
</div>
Expand All @@ -77,43 +97,31 @@ export function CostChart({
lang,
emptyLabel,
totalLabel,
metric = 'credits',
}: {
series: CostSeriesPoint[]
models: ModelCost[]
lang: 'en' | 'zh'
emptyLabel: string
totalLabel: string
metric?: CostChartMetric
}) {
const rootRef = useRef<HTMLDivElement>(null)
const ordered = useMemo(() => models.map((model) => model.id), [models])
const data = useMemo<ChartRow[]>(() => series.map((point) => {
const values = seriesValues(point, metric)
const row: ChartRow = { at: point.at }
for (const id of ordered) row[id] = Number((point.per_model[id] ?? 0).toFixed(4))
for (const id of ordered) {
const raw = Number(values[id] ?? 0)
row[id] = metric === 'tokens' ? raw : Number(raw.toFixed(4))
}
return row
}), [series, ordered])
}), [series, ordered, metric])
const peak = Math.max(0, ...data.map((row) => ordered.reduce((sum, id) => sum + Number(row[id] ?? 0), 0)))
const hasCost = peak > 0
const hasValues = 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) {
if (!series.length || !hasValues) {
return (
<div className="grid min-h-56 place-items-center px-6 py-12 text-center text-sm text-muted">
{emptyLabel}
Expand All @@ -122,13 +130,13 @@ export function CostChart({
}

return (
<div ref={rootRef} className="min-w-0">
<div 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">
<linearGradient key={id} id={`cost-${metric}-${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>
Expand All @@ -144,25 +152,25 @@ export function CostChart({
tickFormatter={(value: string) => bucketLabel(value, daily, lang)}
/>
<YAxis
width={48}
width={52}
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))}`}
tickFormatter={(value: number) => formatAxis(value, metric)}
/>
<Tooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '3 3' }}
content={<CostTooltip daily={daily} lang={lang} models={models} totalLabel={totalLabel} />}
content={<CostTooltip daily={daily} lang={lang} models={models} totalLabel={totalLabel} metric={metric} />}
/>
{ordered.map((id, index) => (
<Area
key={id}
type="monotone"
dataKey={id}
stackId="cost"
stackId="usage"
stroke={SERIES_COLORS[index % SERIES_COLORS.length]}
strokeWidth={1.5}
fill={`url(#cost-${id})`}
fill={`url(#cost-${metric}-${id})`}
isAnimationActive={false}
dot={false}
activeDot={{ r: 3, strokeWidth: 1.2 }}
Expand Down
Loading
Loading