Skip to content
Closed
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

- Add a **Token usage** console page reporting the tokens and subscription quota consumed through cli2api, per model. Quota applies to every provider, so the breakdown covers WorkBuddy, Qoder, Trae, and the other adapters even when a model has no official list price. DeepSeek usage is additionally converted into an equivalent cost at the official list price, split into peak and off-peak hours (estimated using Beijing time Monday–Friday 09:00–12:00 and 14:00–18:00 at double the off-peak rate; holidays and adjusted working days are not handled). The page shows a quota card, the equivalent cost card, a stacked cost-over-time chart, a per-model table (requests, cache-hit / cache-miss input, output, tokens, peak vs off-peak cost, quota), and the official price table. Usage is attributed to the actual mapped model, falling back to the requested name for older logs. Data covers retained request logs only; retention is configurable with CLI2API_LOG_RETENTION_DAYS and CLI2API_LOG_MAX_ROWS (defaults: 7 days / 20,000 rows).

### 中文

- 新增控制台「Token 用量」页面,按模型统计经 cli2api 消耗的 token 与订阅额度。额度对所有 provider 都成立,因此即使某个模型没有官方列表价,明细里依然会列出 WorkBuddy、Qoder、Trae 等各适配器的用量。DeepSeek 的用量还会按官方列表价折算出等效费用,并区分高峰与闲时(按北京时间周一至周五 09:00–12:00、14:00–18:00 估算为闲时的两倍;未处理节假日及调休)。页面包含额度卡片、折合费用卡片、按模型堆叠的费用趋势图、按模型明细(请求数、缓存命中/未命中的输入、输出、token 总量、高峰与闲时费用、额度)以及官方价格表。用量优先归入实际映射模型,旧日志缺少映射时回退到请求名称。数据仅覆盖留存日志,可通过 CLI2API_LOG_RETENTION_DAYS 和 CLI2API_LOG_MAX_ROWS 配置留存(默认 7 天 / 20,000 行)。
4 changes: 4 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ services:
UPDATE_AGENT_URL: "${UPDATE_AGENT_URL:-}"
UPDATE_AGENT_TOKEN: "${UPDATE_AGENT_TOKEN:-}"
UPDATE_GITHUB_TOKEN: "${UPDATE_GITHUB_TOKEN:-}"
# 请求日志保留期:默认 7 天 / 2 万行。调大可以让「Token 用量」页覆盖更长
# 历史;request_logs 约 0.7 KB/行,按每天 1500 行算,90 天大约 150 MB。
CLI2API_LOG_RETENTION_DAYS: "${CLI2API_LOG_RETENTION_DAYS:-7}"
CLI2API_LOG_MAX_ROWS: "${CLI2API_LOG_MAX_ROWS:-20000}"
volumes:
- qoder-data:/data
- "${CLI2API_UPDATER_SOCKET_DIR:-/tmp/cli2api-updater}:/run/cli2api-updater:ro"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { LoginPage } from '@/pages/LoginPage'
import { SystemPage } from '@/pages/SystemPage'
import { LogsPage } from '@/pages/LogsPage'
import { KeysPage } from '@/pages/KeysPage'
import { UsagePage } from '@/pages/UsagePage'

export default function App() {
return (
Expand All @@ -32,6 +33,7 @@ export default function App() {
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/checkins" element={<Navigate to="/accounts" replace />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/keys" element={<KeysPage />} />
<Route path="/system" element={<SystemPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
Expand Down
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}`)
}
3 changes: 2 additions & 1 deletion frontend/src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Drawer, Button, Chip, Separator, Tooltip } from '@heroui/react'
import { Cube, Gauge, GearSix, Key, Lightning, Scroll, SidebarSimple, UsersThree, X } from '@phosphor-icons/react'
import { ChartLineUp, Cube, Gauge, GearSix, Key, Lightning, Scroll, SidebarSimple, UsersThree, X } from '@phosphor-icons/react'
import { BrandMark } from '@/components/BrandMark'
import { SkeletonBlock } from '@/components/ui/PageSkeletons'
import { gsap } from 'gsap'
Expand All @@ -15,6 +15,7 @@ const nav = [
{ to: '/providers', key: 'navProviders', icon: Cube, section: 'develop' },
{ to: '/access', key: 'navAccess', icon: Lightning, section: 'develop' },
{ to: '/logs', key: 'navLogs', icon: Scroll, section: 'system' },
{ to: '/usage', key: 'navUsage', icon: ChartLineUp, section: 'system' },
{ to: '/keys', key: 'navKeys', icon: Key, section: 'system' },
{ to: '/system', key: 'navSystem', icon: GearSix, section: 'system' },
] as const
Expand Down
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 border border-border bg-overlay px-3 py-2 text-[11px] 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