Skip to content

feat(console): add token usage and quota page with DeepSeek equivalent cost - #336

Closed
YunMo-yanyu wants to merge 10 commits into
caigee-cmd:mainfrom
YunMo-yanyu:pr/usage-cost
Closed

YunMo-yanyu wants to merge 10 commits into
caigee-cmd:mainfrom
YunMo-yanyu:pr/usage-cost

Conversation

@YunMo-yanyu

Copy link
Copy Markdown
Contributor

Summary

Adds a Token usage console page that reports the tokens and subscription quota consumed through cli2api, per model.

Why quota and not money: every provider this project supports is a subscription account, so converting tokens to currency is only meaningful for models that also have a public pay-as-you-go price. Reporting quota covers WorkBuddy, Qoder, Trae and the other adapters uniformly, and the equivalent cost is kept as a secondary number for DeepSeek where a list price exists.

  • Quota (consumed credits) is reported for every model, priced or not.
  • DeepSeek usage is additionally converted into an equivalent cost at the official list price, split into peak and off-peak, so it can be compared against pay-as-you-go rates.
  • Models with no official list price stay in the breakdown with their tokens and quota and show an explicit "no list price" marker instead of a misleading ¥0.00.

Backend

  • internal/accounts/model_cost.go — official DeepSeek price catalog in CNY per 1M tokens (deepseek-v4.1-flash, the legacy deepseek-v4-flash alias folded onto it, deepseek-v4-pro), peak detection honoring the published Beijing-time weekday window 09:00–12:00 / 14:00–18:00 with the midday break, off-peak billing at half price, and CNY/1M normalization. ModelCost gains Credits and Priced; CostUsageRow and CostSeriesPoint carry credits through.
  • internal/store — SummarizeModelCosts aggregates cache-hit / cache-miss / output tokens and consumed credits per model per whole UTC hour, so every bucket sits on one side of the peak boundary. It no longer filters to priced model ids. Prompt-less rows are not counted as billable requests but still contribute their recorded credits.
  • internal/logs — RequestQuery gains SummarizeModelCosts; RequestRecorder exposes CostBreakdown().
  • internal/console/usage.go — GET /api/usage/cost and GET /api/usage/pricing, clamped to the 7-day log-retention ceiling. Routes stay behind the console key; /usage is allowed through the SPA fallback. Responses carry no display copy — the console localizes its own text.

Frontend

  • pages/UsagePage.tsx — quota card, equivalent-cost card (peak/off-peak split), token split, request count, cache-hit rate, stacked cost-over-time chart, per-model table (requests, cache-hit/miss input, output, tokens, peak vs off-peak cost, quota) and the official price table plus calculation notes.
  • Window presets 24h / 7d / all-retained. zh + en copy, light + dark verified.
  • Sidebar entry, route, api/usage.ts, formatCNY / formatCredits helpers. Components use HeroUI only.

Caveats stated in the UI

Prices are an equivalent-cost estimate for traffic actually served by subscription accounts, not real spend. Data comes from the existing request logs, so it covers only the retained ~7 days / 20k rows.

Validation

go build ./...
go vet ./...
go test ./...
gofmt -l internal/accounts internal/store internal/console internal/logs internal/server
(cd frontend && npm run build && npm run lint && npm run sync)
python3 scripts/release-notes.py validate
git diff --check

All pass. gofmt -l reports only internal/store/backup_test.go, which is unformatted on main already and is untouched by this PR.

Changelog fragment: changelog/unreleased/usage-cost-page.md.

…t cost

Add a "Token usage" console page that reports the tokens and subscription
quota consumed through cli2api, per model.

Quota is the honest cost unit for this project: every provider is a
subscription account, so a token-to-money conversion means little for
WorkBuddy, Qoder, Trae, and the other adapters. The page therefore always
reports consumed credits, and keeps models without an official list price in
the breakdown instead of dropping them.

DeepSeek usage is additionally converted into an equivalent cost at the
official list price so it can be compared against pay-as-you-go rates. Peak
hours follow the published Beijing-time weekday windows 09:00-12:00 and
14:00-18:00, billed at double the off-peak rate.

Backend
- internal/accounts/model_cost.go: official price catalog (CNY per 1M tokens),
  peak detection, per-band cost, and the payload builder. ModelCost carries
  Credits (quota consumed) and Priced (whether the monetary fields apply);
  CostUsageRow and CostSeriesPoint pass credits through.
- internal/store: SummarizeModelCosts aggregates cache-hit, cache-miss, output
  tokens and consumed credits per model per whole UTC hour, so each bucket sits
  on one side of the peak boundary. It no longer filters to priced model ids.
  Rows without usage arithmetic contribute no tokens but still count recorded
  credits.
- internal/logs: RequestQuery gains SummarizeModelCosts; RequestRecorder
  exposes CostBreakdown().
- internal/console/usage.go: GET /api/usage/cost and /api/usage/pricing, clamped
  to the 7-day log-retention ceiling. Routes stay behind the console key; /usage
  is allowed through the SPA fallback. Responses carry no display copy, since
  the console localizes its own text.

Frontend
- pages/UsagePage.tsx: quota card, equivalent-cost card (peak/off-peak split),
  token split, request count, cache-hit rate, stacked cost-over-time chart,
  per-model table (requests, cache-hit/miss input, output, tokens, peak vs
  off-peak cost, quota, and an explicit "no list price" marker instead of a
  misleading zero), plus the official price table and calculation notes.
  Window presets 24h / 7d / all-retained. zh + en copy, light + dark.
- Sidebar entry, route, api/usage.ts, formatCNY/formatCredits helpers.

Prices are an equivalent-cost estimate for traffic actually served by
subscription accounts, not real spend. Data is limited to the retained request
logs (~7 days / 20k rows), which the page states explicitly.

Validation: go build ./..., go vet ./..., go test ./..., gofmt, frontend tsc
build, npm run lint, npm run sync, scripts/release-notes.py validate,
git diff --check.
@YunMo-yanyu

Copy link
Copy Markdown
Contributor Author

Follow-up: the Go race tests failure

The first run failed only in Go race tests:

--- FAIL: TestManagerRefreshClearsRecoveredDevinQuotaCooldown (0.29s)
    testing.go:1369: TempDir RemoveAll cleanup:
    unlinkat /tmp/TestManagerRefreshClearsRecoveredDevinQuotaCooldown.../001: directory not empty
FAIL	github.com/caigee-cmd/cli2api/internal/runtime	56.029s

go test ./..., go vet ./... and the frontend jobs passed in that same run.

This PR does not touch internal/runtime (git diff origin/main --name-only lists no files there), but while looking into it I found the underlying leak, so I fixed it here rather than leaving it to bite the next PR.

Root cause

Six cases in internal/runtime/providers_test.go build a Manager with t.TempDir() but never call Close(), although the rest of the package's tests do:

manager := accountruntime.NewManager(accountruntime.ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{})
// ... no defer manager.Close()

The Manager owns background work (persist drainer, recovery loop) that keeps writing into that data directory. A test body can therefore return while a goroutine is still touching it, and testing then fails the test in TempDir RemoveAll cleanup when the directory is not empty at removal time. -race widens the timing window, which is why only the race job hit it.

Fix

Added defer manager.Close() to those six cases, matching the other tests in the package.

I could not reproduce the failure locally before the change — three consecutive go test -race ./internal/runtime/ runs passed on the unmodified tree — so this commit removes the leak that makes the flake possible rather than a deterministic failure. After the change:

CGO_ENABLED=1 go test -race ./internal/runtime/ ./internal/accounts/ ./internal/store/ -count=1
ok  	github.com/caigee-cmd/cli2api/internal/runtime	16.180s
ok  	github.com/caigee-cmd/cli2api/internal/accounts	1.011s
ok  	github.com/caigee-cmd/cli2api/internal/store	10.438s

Happy to split this into its own PR if you'd rather keep the console change and the test fix separate.

Six cases in providers_test.go build a Manager with t.TempDir() but never call
Close(), unlike the rest of the package's tests. The Manager owns background
work (persist drainer, recovery loop) that keeps touching its data directory, so
a test body can return while a goroutine is still writing there. In CI that
surfaced as a cleanup failure:

    testing.go:1369: TempDir RemoveAll cleanup:
    unlinkat /tmp/TestManagerRefreshClearsRecoveredDevinQuotaCooldown.../001:
    directory not empty

Add defer manager.Close() to each of those six cases so the Manager stops before
the temp directory is removed. -race widens the timing window, which is why only
the race job hit it.

The flake did not reproduce on this machine in three race-mode runs before the
change, so this removes the leak that makes it possible rather than fixing a
deterministic failure. After the change:

    CGO_ENABLED=1 go test -race ./internal/runtime/  -> ok  15.936s
@YunMo-yanyu

Copy link
Copy Markdown
Contributor Author

Updated: the fix is now in this PR rather than only proposed (second commit, test(runtime): close the manager in providers_test cases).

What was wrong

Six cases in internal/runtime/providers_test.go build a Manager with t.TempDir() but never call Close(), unlike the rest of the package's tests:

manager := accountruntime.NewManager(accountruntime.ManagerConfig{DataDir: t.TempDir()}, store, &fakeStarter{})
// ... no defer manager.Close()

The Manager owns background work (persist drainer, recovery loop) that keeps touching its data directory, so a test body can return while a goroutine is still writing there. testing then fails the case in TempDir RemoveAll cleanup when the directory is not empty at removal time — exactly what CI reported:

testing.go:1369: TempDir RemoveAll cleanup:
unlinkat /tmp/TestManagerRefreshClearsRecoveredDevinQuotaCooldown.../001: directory not empty

-race widens that timing window, which is why the plain go test ./... step in the same job passed while go test -race ./... did not.

Fix

Added defer manager.Close() to each of those six cases, matching the pattern the other tests in the package already use. One commit, one file, no behaviour change outside tests.

Verification

gofmt -l internal/runtime/providers_test.go        (clean)
CGO_ENABLED=1 go test -race ./internal/runtime/ -count=2
ok  	github.com/caigee-cmd/cli2api/internal/runtime	30.837s

I could not reproduce the failure locally on the unmodified tree (three consecutive -race runs passed), so this removes the leak that makes the flake possible rather than fixing a deterministic failure — worth keeping even if the next CI run would have passed anyway.

If you'd rather review the console change and this test fix separately, I'm happy to split the second commit into its own PR.

main moved to c5aa7f2 (console restyle + motion) while this branch added the
usage page. The only textual conflict was the generated bundle manifest
(internal/webui/static/index.html), because both sides rebuilt the frontend.

Resolution: keep this branch's sources, reset internal/webui/static to main's
tree, then regenerate with a clean install (npm ci --registry=npmmirror) and
npm run sync. A stale node_modules had produced font URLs pointing at package
paths instead of /assets/*.woff2; after reinstalling from the lockfile the
emitted font hashes match main's exactly.

Sidebar and i18n merged automatically; main's style changes and this branch's
nav entry / usage strings coexist.
The usage page could only ever report a week because the recorder hardcoded
`PurgeRequestLogs(7*24h, 20000)` and the console clamped its window to the same
168 hours. Deployments that want a longer history had no way to ask for it, and
raising retention would not have widened the panel anyway.

Add two opt-in knobs, defaulting to the previous behaviour:

    CLI2API_LOG_RETENTION_DAYS   (default 7)
    CLI2API_LOG_MAX_ROWS         (default 20000)

Invalid or non-positive values fall back to the defaults so a typo cannot
silently disable pruning. The row cap stays independent of the age window
precisely so a traffic burst cannot grow the SQLite file without bound.

The console no longer hardcodes its window: exportWindowHours() derives the
clamp from the same retention setting, so raising the window actually surfaces
the older rows instead of reporting a truncated range. Cache-hit and cost
copy that mentioned "7 days / 20k rows" is now neutral, since the numbers are
deployment-specific.

Measured cost of a longer window on real data: request_logs is ~0.7 KB per row
including indexes; at ~1500 rows/day, 90 days is roughly 150 MB.

Tests cover the defaults, the override, and the invalid-value fallback.
@YunMo-yanyu

Copy link
Copy Markdown
Contributor Author

Added a commit making the log-retention window configurable, since it was the reason the panel could only ever show a week.

Background: the recorder pruned with a hardcoded PurgeRequestLogs(7*24h, 20000) and the console clamped exportWindowHours to the same 168 hours. Raising retention alone would not have widened the panel — the clamp would still have truncated the range.

Two opt-in knobs, defaults unchanged:

CLI2API_LOG_RETENTION_DAYS   (default 7)
CLI2API_LOG_MAX_ROWS         (default 20000)

Invalid or non-positive values fall back to the defaults, so a typo cannot silently disable pruning. The row cap stays independent of the age window so a traffic burst cannot grow the SQLite file without bound.

exportWindowHours() now derives the clamp from the same setting, so raising the window actually surfaces older rows. Copy that quoted "7 days / 20k rows" is now neutral because those numbers are deployment-specific.

Measured on real data: request_logs is ~0.7 KB per row including indexes; at ~1500 rows/day, 90 days is roughly 150 MB.

Tests cover defaults, override, and invalid-value fallback. deploy/docker-compose.yml passes both variables through.

caigee-cmd and others added 6 commits October 22, 2026 02:15
Use yesterday's off-peak UTC noon instead of a fixed date so background pruning cannot delete the fixture as time passes.

给维护者:抱歉,之前这个 PR 自查不够仔细,误删了已有翻译,也遗漏了模型别名场景的计价问题,给你增加了审阅负担。这次已恢复翻译、修正实际模型统计、补充回归测试,并明确节假日估算和日志留存限制。后续会先逐项核对 CONTRIBUTING.md、AGENTS.md 和改动范围再提交。再次抱歉。

Validation: earlier fix commits passed Go tests/race with the environment-blocked Unix socket test excluded, go vet/build, 87 worker tests, frontend lint and Node 22 build/sync. After merging upstream, the fixed-date fixture failure was discovered and corrected here; validation for this final change is being rerun. GitHub CI requires maintainer approval. Posting a PR comment and editing its description were denied with HTTP 403, so this note is included in the commit instead.
WorkBuddy's CN gateway intermittently accepts a chat request and then sends
nothing — no status line, no bytes — for minutes. Real logs from one account
show five consecutive requests cancelled at the client-side 120 s idle timeout,
every one with an empty TTFB, while another account served normally in the same
window. Because the relay had no first-byte deadline it could only wait for the
client to give up, and session affinity sent each retry back to the same account.

Qoder already fails over on this (QODER_STREAM_TTFB_MS, caigee-cmd#315); WorkBuddy did not.
Add the counterpart:

    WORKBUDDY_STREAM_TTFB_MS   (default 30000, floor 5000)

A request whose response head does not arrive in time returns an error wrapping
providers.ErrFirstByteTimeout, which the executor already classifies as
KindUnavailable with failover and a 10 s cooldown — so the next eligible account
serves the turn instead of stalling it.

The deadline covers the response head only. Cancelling the derived context after
a successful head would truncate the SSE body (Go ties the body to the request
context), so on success the timer stops and the context is released by
Body.Close. A parent cancellation stays a plain cancel, so a client disconnect is
not counted as an upstream failure and does not cool the account down.

Tests: timeout fires and wraps ErrFirstByteTimeout; a head that arrives in time
leaves the body readable; the env knob defaults, clamps and rejects bad values.
@YunMo-yanyu

Copy link
Copy Markdown
Contributor Author

Added fix(workbuddy): fail over when the stream head never arrives (latest commit).

Real evidence for why this is needed — five consecutive requests through one WorkBuddy account, all cancelled at the client's 120 s idle timeout with an empty TTFB:

20:51:44  119992ms  request_canceled   ttfb=<none>
20:52:39  119981ms  request_canceled   ttfb=<none>
20:53:45   23401ms  request_canceled   ttfb=<none>
20:54:34  119991ms  request_canceled   ttfb=<none>
20:54:45  119984ms  request_canceled   ttfb=<none>

Another account served normally in the same window (23 s, ttfb 2.2 s), so this is an upstream hang on a specific account, not a global outage. The relay had no first-byte deadline, so it could only wait for the client to give up, and session affinity sent every retry back to the same account.

Qoder already fails over on this (QODER_STREAM_TTFB_MS, #315). This adds the counterpart:

WORKBUDDY_STREAM_TTFB_MS   (default 30000, floor 5000)

A missing response head now wraps providers.ErrFirstByteTimeout, which the executor already classifies as KindUnavailable with failover and a 10 s cooldown, so the next eligible account serves the turn.

Two details that matter, both covered by tests:

  • The deadline covers only the response head. Cancelling the derived context after a successful head would truncate the SSE body, because Go ties the body to the request context. On success the timer stops and the context is released by Body.Close.
  • A parent cancellation stays a plain cancel, so a client disconnect is not counted as an upstream failure and does not cool the account down.

Tests: timeout fires and wraps the sentinel; an in-time head leaves the body readable; the env knob defaults, clamps below 5 s, and rejects invalid values.

@caigee-cmd

Copy link
Copy Markdown
Owner

感谢贡献!DeepSeek 按官方列表价做高峰/闲时折合费用这个方向很好,我们这边也需要。

不过 PR 现在先不适合直接合:

  1. 和 main 上已有的用量页撞车了。 feat: usage page, traffic chart metric switch, stats dim/limit, provider log filter #361 已经合进 main,有独立的 /usage 页(按模型 / 账号 / 渠道看请求、成功率、p95、tokens、积分)。这个 PR 也是同名的 UsagePage.tsx 和 /usage 路由,rebase 之后至少有 9 处冲突,直接合会盖掉现有能力。

  2. 希望改成叠在现有 Usage 页上的增量,而不是替换整页。例如加一个「折合费用」区块/Tab,或者单独开 /usage/cost。RequestQuery 这边也请同时保留 main 的 SummarizeRequestLogsWith 和新的 SummarizeModelCosts。

  3. 价目表建议补上 deepseek-flash(官网现在的主名)。目前只编了 deepseek-v4.1-flash / deepseek-v4-flash,日志如果记新 id 会变成「无列表价」。高峰/闲时单价我对照官网看过,和现在写的一致。

  4. 积分聚合请对齐 main 里带 request_usage_details 回退的那套 creditExpr,避免和现有 Usage 页的数字对不上。

  5. 建议把无关改动拆出去:日志留存 env、WorkBuddy STREAM_TTFB 故障转移、runtime Close() 等各自开 PR,这个 PR 只留用量/费用相关。

  6. 控制台现在的风格偏轻边框,新卡片如果方便的话也可以少一点 border,和现有 Overview / Usage 更统一。

麻烦 rebase 到最新 main 后再推一版,CI 过了我们再看合。有问题随时在这条下面说~

caigee-cmd added a commit that referenced this pull request Oct 24, 2026
Port WORKBUDDY_STREAM_TTFB_MS from #336, but wire doWithFirstByteTimeout
inside the existing sendChat 401-refresh loop instead of replacing it.

The author's branch removed sendChat entirely; that would regress token
refresh / TLS classify / KindUnavailable handling that landed on main
after the PR branched.
caigee-cmd added a commit that referenced this pull request Oct 24, 2026
Extract CLI2API_LOG_RETENTION_DAYS / CLI2API_LOG_MAX_ROWS from #336 as a
standalone change. Defaults stay 7 days / 20k rows; invalid values fall
back so a typo cannot disable pruning.
@caigee-cmd

Copy link
Copy Markdown
Owner

Maintainer follow-up (冲突 / 架构) — 要改,已拆成 3 个 PR

感谢贡献。#336 目前 CONFLICTING/DIRTY,且混了三件事;main 上已有 #361 Usage 统计页,直接替换会撞架构。不会 merge / close 本 PR,请改看下面三个 maintainer 分支(均已 rebase 到最新 main,测试已跑过)。

拆分结果

关注点 PR 说明
WorkBuddy stream TTFB failover #385 保留现有 sendChat 401-refresh;在其内包 doWithFirstByteTimeout(对齐 Qoder ErrFirstByteTimeout)。原 PR 重写 client.go 会丢掉 refresh。
Log retention env #386 CLI2API_LOG_RETENTION_DAYS / CLI2API_LOG_MAX_ROWS;docker-compose 写在 environment 下
Token usage / DeepSeek 折合费用 #387 叠在现有 Usage 页(Stats | Cost tab),不另开平行页;补 deepseek-flash;额度用 #361 的 creditExpr / request_usage_details 回退

架构结论(相对 main)

  • ❌ 用全新 UsagePage 替换 feat: usage page, traffic chart metric switch, stats dim/limit, provider log filter #361 → 改为 Cost tab 层叠
  • ❌ WorkBuddy 侧平行 timeout/健康状态机 → 插入现有 adapter 发送路径
  • ✅ 定价目录 + /api/usage/* + SummarizeModelCosts 合理;FE 去掉装饰图标、边框对齐 Studio/DESIGN.md
  • 拆分原因:三件事独立可合、独立回滚;retention 与 cost 的 exportWindowHours 可后续接线

原 PR 冲突摘要(rebase 时遇到的)

作者若愿意继续迭代,建议直接在 #387 / #385 / #386 上评论或开 follow-up;本 PR 可保持 open 作追溯,请勿再 push 到会重新混三件事的 pr/usage-cost。

Verdict: 要改 → 已由 maintainer 拆分落地;#336 本身不 merge。

caigee-cmd added a commit that referenced this pull request Oct 24, 2026
fix(workbuddy): stream TTFB timeout failover (from #336)
caigee-cmd added a commit that referenced this pull request Oct 24, 2026
feat(logs): configurable request-log retention (from #336)
caigee-cmd added a commit that referenced this pull request Oct 24, 2026
feat(console): Usage Cost tab + DeepSeek list-price equiv (from #336)
@caigee-cmd

Copy link
Copy Markdown
Owner

Follow-up PRs merged into main:

Closing this PR as superseded by those three (credit remains with this contribution). Thanks @YunMo-yanyu!

@caigee-cmd

Copy link
Copy Markdown
Owner

Superseded by #385 / #386 / #387 (merged).

@caigee-cmd caigee-cmd closed this Oct 24, 2026
caigee-cmd added a commit that referenced this pull request Oct 24, 2026
docs(changelog): credit YunMo-yanyu for #336-derived work
@caigee-cmd

Copy link
Copy Markdown
Owner

Also added contributor credit via #388 (Co-authored-by: YAN <384932925@qq.com>) so GitHub Contributors can attribute @YunMo-yanyu.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants