Repository navigation
feat(console): add token usage and quota page with DeepSeek equivalent cost - #336
YunMo-yanyu wants to merge 10 commits into
Conversation
…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.
744298a to
322340e
Compare
Follow-up: the
|
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
|
Updated: the fix is now in this PR rather than only proposed (second commit, What was wrongSix cases in 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.
FixAdded VerificationI could not reproduce the failure locally on the unmodified tree (three consecutive 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.
c5ceb96 to
be4c056
Compare
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.
|
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 Two opt-in knobs, defaults unchanged: 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.
Measured on real data: Tests cover defaults, override, and invalid-value fallback. |
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.
|
Added 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: 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 ( A missing response head now wraps Two details that matter, both covered by tests:
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. |
|
感谢贡献!DeepSeek 按官方列表价做高峰/闲时折合费用这个方向很好,我们这边也需要。 不过 PR 现在先不适合直接合:
麻烦 rebase 到最新 main 后再推一版,CI 过了我们再看合。有问题随时在这条下面说~ |
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.
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.
Maintainer follow-up (冲突 / 架构) — 要改,已拆成 3 个 PR感谢贡献。#336 目前 拆分结果
架构结论(相对 main)
原 PR 冲突摘要(rebase 时遇到的)
作者若愿意继续迭代,建议直接在 #387 / #385 / #386 上评论或开 follow-up;本 PR 可保持 open 作追溯,请勿再 push 到会重新混三件事的 Verdict: 要改 → 已由 maintainer 拆分落地;#336 本身不 merge。 |
fix(workbuddy): stream TTFB timeout failover (from #336)
feat(logs): configurable request-log retention (from #336)
feat(console): Usage Cost tab + DeepSeek list-price equiv (from #336)
|
Follow-up PRs merged into
Closing this PR as superseded by those three (credit remains with this contribution). Thanks @YunMo-yanyu! |
docs(changelog): credit YunMo-yanyu for #336-derived work
|
Also added contributor credit via #388 ( |
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.
¥0.00.Backend
internal/accounts/model_cost.go— official DeepSeek price catalog in CNY per 1M tokens (deepseek-v4.1-flash, the legacydeepseek-v4-flashalias folded onto it,deepseek-v4-pro), peak detection honoring the published Beijing-time weekday window09:00–12:00/14:00–18:00with the midday break, off-peak billing at half price, and CNY/1M normalization.ModelCostgainsCreditsandPriced;CostUsageRowandCostSeriesPointcarry credits through.internal/store—SummarizeModelCostsaggregates 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—RequestQuerygainsSummarizeModelCosts;RequestRecorderexposesCostBreakdown().internal/console/usage.go—GET /api/usage/costandGET /api/usage/pricing, clamped to the 7-day log-retention ceiling. Routes stay behind the console key;/usageis 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.api/usage.ts,formatCNY/formatCreditshelpers. 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
All pass.
gofmt -lreports onlyinternal/store/backup_test.go, which is unformatted onmainalready and is untouched by this PR.Changelog fragment:
changelog/unreleased/usage-cost-page.md.