Conversation usage statistics for the DeepSeek Harness Web shell — a central panel (and a matching agent tool) that answers how much did my conversations cost, where did the time go, and what is trending. Per-model token accounting, an automatically fetched official price table (with peak/off-peak billing and a monthly budget), Search/Fetch per-request costs, and a per-conversation detail drawer are part of the surface.
(截图来自 v0.2;v0.3 在 Token 构成下方新增模型用量表,卡片区新增预计费用,点击任意对话行打开详情抽屉。)
Every figure is folded from the durable Session event log — the only source of truth — and deliberately mirrors the accounting the Harness itself shows, so the panel cannot disagree with a conversation's own usage pill:
| Figure | Definition |
|---|---|
| Token buckets | assistant/message / assistant/attempt usage, with a same-turn/same-step sample replacing the earlier one and llm/retry-started closing the replacement slot, so retried attempts add their own consumption. inputTokens is the uncached prompt bucket (the tokenUsage projection's convention); cache read/write are separate buckets. |
| Total tokens | uncached input + cache read + cache write + output |
| Cache hit rate | cache read ÷ prompt-side total |
| Runtime | wall-clock turn/start → turn/end spans, clipped to the selected range; an in-flight live turn counts up to now |
| Model time | step/start → assistant/message (a cancelled step contributes none) |
| Tool time | tool/call → matching tool/result by call id, as an interval union so parallel calls are not double counted |
| Human wait | approval/asked → approval/decided plus the whole duration of ask_user_question calls; approval waits that sit inside a tool interval are moved from tool time to human time |
| Other | the remainder after the three segments — queueing, retry backoff, gaps |
| Steps / turns | one per step/end; turns counted per turn/start |
| Tool ranking | per call id: execution time (minus human wait), calls, error results |
| Turn health | turn/end.reason histogram plus llm/retry count |
| Trend | turns bucketed by end time into local days (all/30d/7d) or hours (1d) |
The four runtime segments never overlap and always sum to the runtime: the fold keeps each interval list and the report takes interval unions, because tools run in parallel and an approval wait sits inside the tool call that asked for it.
Forked conversations: a fork's inherited prefix was consumed in the parent,
so it is excluded from every total by default (the row still shows it as
继承 X, and the "计入分支继承" switch includes it). A resumed (not seeded)
session folds its whole log — that prefix is its own history.
The Harness log carries token counts, not money. DeepSeek publishes list prices on its docs page, so the Host half fetches and parses that page by default — no hard-coded price table is shipped as truth:
live page (api-docs.deepseek.com)
└─ success → cached on disk for 24 h
└─ failure → last cache → bundled snapshot (official-prices.json)
user overrides ($DSH_HOME/usage-stats/prices.json) ← optional, wins per entry
- Currencies: the CNY page (
/zh-cn/..., prices in 元) is the default; setDSH_USAGE_STATS_CURRENCY=USD(or"currency": "USD"in the user file) to use the USD page. Prices are per 1M tokens, per bucket (input,cacheRead,cacheWrite,output); cache writes are billed at the cache-miss rate. - Refresh: the table is re-fetched when the 24 h TTL expires, or immediately
when the panel's Refresh button (or the footer's 刷新官网价格 / Refresh official
prices action) is used (
?force=1); the header Refresh tooltip says so too.DSH_USAGE_STATS_AUTO_PRICING=0disables the automatic fetch entirely. - Provenance is reported: the payload says whether the table came from the
live page (
auto), the TTL cache (cache), the bundled snapshot (bundled), a manual file (manual) or a mix (mixed), with its URL and fetch time. The panel footer shows that line; the agent tool reports it too. - Failure never breaks the panel: an unreachable page falls back to the cache/snapshot with a warning; a malformed user file surfaces a parse error and is ignored.
The official page publishes two tiers, and the plugin applies them per usage sample by the sample's timestamp:
| Tier | Official Chinese-time window | UTC window (Mon–Fri) |
|---|---|---|
| PEAK | weekdays 09:00–12:00 and 14:00–18:00 | 01:00–04:00 and 06:00–10:00 |
| OFF-PEAK | everything else (weekends included) | everything else |
Off-peak is half the peak price (the official page states this). Each model's token buckets are split into peak/off-peak while folding the log, so the cost follows when the work actually ran rather than when the panel was opened. Chinese statutory holidays are not modeled — the classifier only knows weekdays — which is the one deliberate approximation here.
The official page prices the standalone Search and Fetch APIs per 1,000 successful requests (currently ¥5 / $0.75 each). The plugin counts them from the session log and includes them in the cost, month-to-date and budget:
| Service | Counted from | Billed? |
|---|---|---|
| Search | web/deepseek-search-llm-request events — one outbound Search API request each (a web_search call can issue 2–4), counted only when that call's tool result succeeds |
yes, official rate per 1,000 requests |
| Fetch | web_fetch tool calls |
no by default — the shipped web-fetch-http provider performs a local HTTP fetch, not a Fetch API request |
The user file can override the counts' prices or opt Fetch into billing for a composition that uses a billable Fetch provider:
{
"services": {
"search": { "pricePerThousand": 5, "billed": true },
"fetch": { "pricePerThousand": 5, "billed": true }
}
}Requests are attributed to the most recent open web_search call and are only
billed when its tool result reports success. A failed call's requests, a call
that never produced a result (interrupted turn), and requests with no matching
call are excluded. Remaining approximation: a call that fails late may have
already issued successful requests that the fold drops (it under-counts instead
of over-counting); per-request success is not recorded in the log.
cp prices.example.json ~/.dsh/usage-stats/prices.json # optional
# or point the plugin elsewhere:
# DSH_USAGE_STATS_PRICES=/path/to/prices.json{
"auto": true,
"currency": "CNY",
"budget": { "monthly": 100 },
"prices": {
"deepseek-official/deepseek-v4-pro": { "input": 9, "cacheRead": 0.3, "cacheWrite": 9, "output": 27 }
}
}- Entries in
pricesoverride the official table per key (a flat bucket set applies to both tiers;{ "peak": {...}, "offPeak": {...} }sets them separately). Wildcard precedence isprovider/model→provider/*→*/*→*. budget.monthly(same currency) drives the 80% warning / 100% over-budget banner and the month-to-date figure, which is computed from the whole corpus regardless of the panel's selected range.- The 区间费用 / Range cost card is the estimated cost of the selected range (it changes when the range tab changes); the month-to-date figure and budget progress stay in its hint, and the budget alerts remain month-based.
- Partial cost is explicit: a model without a price still reports tokens; it
is listed in
costing.unpricedModels, the footer says how many are unpriced, and its row cost shows—instead of a fake zero.
package.json dsh.bundle.patch + dsh.client { platform: web }
cordis.patch.yml inserts the one Loader row ("usage-stats")
lib/aggregate.js pure folds: events → per-turn records → range report
lib/pricing.js official-page parser, cache, bundled fallback, user overrides
official-prices.json bundled snapshot of the official table (offline fallback)
lib/index.js Host half: scans the corpus, serves the route, registers the tool
lib/client.js browser half: sidebar rail icon + central "main" panel
test/ aggregate, route and browser-composition tests
Host half (lib/index.js):
- enumerates the logical corpus with
ctx.sessionQuery.listSessions(); - folds live sessions from memory (
ctx.sessions.get(id).snapshotEvents()), persisted sessions throughsessionQuery.readSession(id); - reuses a persisted session's per-turn analysis until its
sessionPersistencerevision changes, and serves the whole corpus from a short-lived TTL cache (15 s;DSH_USAGE_STATS_TTL_MSoverrides it) with single-flight behind it. Range/inherited switches re-assemble the report from the cached corpus without re-reading logs; - registers
GET /api/usage-stats.summarythroughctx.connection.fetch.register, inside the connection service's browser authentication fence — a same-originfetchfrom the page is already authorised; - registers the
conversation_usageagent tool on the same corpus, so the panel and the agent read identical numbers.
Failure isolation: one unreadable/corrupt session becomes an entry in errors
and a warning row in the panel; the rest of the corpus still renders.
Browser half (lib/client.js):
- two slot registrations under one id (
usage-stats):sidebar.panellist(the rail icon) andmain(the central panel the sidebar addresses by that id); - range control, auto refresh, inherited-prefix switch, token trend, four-way
runtime split, tool ranking, turn health, sortable/searchable per-conversation
table; clicking a conversation opens it through
ctx.get('uiWorkspace').openSession; - plain JS, no bundler; only
reactis required — no@deepseek-ai/dsh-client-*module import, which would blank the slot on drift; - styles are inline and use only
--dsw-alias-*theme tokens; - all visible text goes through the Client locale service (zh + en);
- if the running Host half still serves the v1 payload (it needs an app restart to load new Host code), the panel renders the v1 cards/table with a restart notice instead of crashing on missing v2 fields.
The panel folds every durable session log, so the first scan is the expensive part (measured here over 88 persisted logs: ~2.1 s CPU to read/fold, then ~12 ms to aggregate the report). Several layers keep that latency out of the way:
- Warm-up scan: ~1.5 s after the Host half activates it scans the corpus in the background, so the first panel open normally hits a warm cache.
- Short-lived corpus cache (30 s,
DSH_USAGE_STATS_TTL_MS) plus a revision-aware per-session cache: a repeat scan re-reads only sessions whosesessionPersistencerevision changed; range/inherited switches re-assemble from memory. - Parallel reads across session logs (
DSH_USAGE_STATS_CONCURRENCY, default 8). - Non-blocking price refresh: when the official price table TTL expires, the
report is served from the stale cache/bundled snapshot immediately and the
docs page is re-fetched in the background for the next report
(
pricing.source.pendingRefreshis true meanwhile). The explicit Refresh button still waits for the fresh fetch. - Loading UI: a skeleton with a first-load hint (initial load), a slim
updating bar plus the existing content during refreshes, and a footer line
with the last scan time (
timings.scanMs) and cache status. The progress row is always mounted at a fixed height and the Refresh button keeps a fixed width, so switching ranges or refreshing does not shift the panel. DSH_USAGE_STATS_WARMUP_MS=0disables the warm-up;0TTL always rescans.
GET /api/usage-stats.summary
?range=all|30d|7d|1d (default all)
&inherited=1 (include a fork's inherited prefix; default off)
&offset=<minutes east UTC> (local day/hour buckets; default 0)
&force=1 (bypass the corpus TTL cache and refresh the price table)
→ 200 { ok, version: 5, cached, cacheAgeMs, ttlMs, generatedAt, scanMs,
timings: { scanMs, summarizeMs, pricingMs, cached, cacheAgeMs },
range, includeInherited, offsetMinutes,
scope: { listed, analyzed, failed, live, subagents },
pricing: { configured, currency, budgetMonthly,
source: { kind: 'auto'|'cache'|'bundled'|'mixed'|'manual',
url, fetchedAt, ttlHours, provider, userFile, cacheFile },
services: { search: { pricePerThousand, billed },
fetch: { pricePerThousand, billed } },
peakWindows, weekdays, warnings: [], error },
costing: { configured, currency, budgetMonthly, monthTokens, monthCost,
budgetRatio, unpricedModels: [ 'provider/model', … ],
serviceCost, servicesConfigured },
totals: { sessions, liveSessions, subagentSessions,
runTimeMs, modelMs, toolMs, humanWaitMs, otherMs,
turns, steps, toolCalls, retries, userMessages,
assistantMessages,
tokens { input, output, cacheRead, cacheWrite, prompt, total },
cacheHitRate, cost, monthTokens, monthCost,
services: { search, fetch }, serviceCost,
inherited { tokens, runTimeMs, turns, steps },
firstActivityAt, lastActivityAt },
sessions: [ { id, title, createdAt, lastActiveAt, live, parentSession,
origin, delegationDepth, isSeeded, cwd, workspace,
agentPreset, eventCount, runTimeMs, modelMs, toolMs,
humanWaitMs, otherMs, turns, steps, toolCalls, retries,
userMessages, assistantMessages, tokens, cacheHitRate,
cost, unpriced, services, serviceCost, models, tools,
turnEnds, inherited } ],
trend: { unit: 'day'|'hour', buckets: [ { start, tokens, runTimeMs,
steps, toolCalls, cost, sessions } ] },
tools: [ { name, calls, ms, errors } ],
models: [ { provider, model, calls, tokens, cacheHitRate, cost } ],
services: [ { kind, calls, billed, pricePerThousand, cost } ],
turnEnds: { completed, aborted, interrupted, error, maxTokens,
blocked, forked, other, open },
errors: [ { sessionId, message } ], partial }
Per-session `tools` are ranked by execution time; `models` carry the same
token/cost shape as the global list. Unpriced routes keep `cost: null`.
→ 405 non-GET · 503 { ok: false, error } (e.g. no sessionQuery service)
conversation_usage takes
{ top?: 1–20 (default 5), range?: 'all'|'30d'|'7d'|'1d', inherited?: boolean, refresh?: boolean }
and returns the totals (with the runtime split, per-model tokens, turn health
and the estimated cost / month-to-date budget), the highest-consumption
conversations and the costliest tools, with the omitted count made explicit.
Read-only.
plugin_manager({ action: "install_bundle", target: "@echovic/dsh-usage-stats@0.2.0" })
// or, with the desktop app fully quit:
// dsh plugin --profile desktop add @echovic/dsh-usage-stats@0.2.0The browser half hot-reloads on save. A change to the Host half
(lib/index.js, lib/aggregate.js) needs one application restart, because
Host modules are not hot-replaced; until then the panel shows the restart
notice and the v1 layout. The same restart is what activates a newly installed
Host version.
cd <checkout>
npm testtest/aggregate.test.mjs(22) — the folds against synthetic event logs: the four-way split (including parallel tools and approval-inside-tool), token replacement/retry semantics, per-route token attribution, peak/off-peak token splitting, tiered cost, wildcard resolution, range-independent month-to-date cost, turn endings, open turns, range clipping, local day/hour trend buckets, per-session tool/turn detail, Search/Fetch request counting and per-1,000 service billing, and the inherited-prefix mode;test/pricing.test.mjs(9) — official-page parsing (CNY and USD fixtures), user-entry normalization, the peak-hour calendar, and the resolution ladder Search/Fetch service-price parsing and overrides, stale-while-revalidate background refresh, and the resolution ladder (live fetch → cache → bundled snapshot → manual override → malformed file);test/route.test.mjs(16) —apply()registration, the Fetch handler, range/inherited parameters, manual and auto-fetched pricing (including a mocked official page and the offline fallback), Search/Fetch billing, the corpus TTL cache, revision-aware per-session reuse, failure isolation and the agent tool;test/client.test.mjs(12) — boots the real browser module in avmwith a stateful React double, renders the full v4 dashboard in zh and en, opens the detail drawer by clicking a conversation row, renders the Search & Fetch section, checks the v4/v3/v2-payload compatibility notices, and renders the legacy v1 payload with its restart notice, asserting both slot registrations, complete locale dictionaries and theme-token-only colors.
- Token totals only count usage the provider reported in the log; sessions from providers that omit usage contribute zeros.
- Cost is an estimate, not a bill: it follows the official docs page (or your overrides), so discounts, private pricing, or a docs change between fetches can differ from the invoice. Unpriced models are excluded from money figures (and reported as unpriced); the month figure is local-calendar based.
- Peak/off-peak classification knows weekdays only — Chinese statutory holidays are billed as off-peak even though the official page excludes them from peak.
- Search cost counts Search API requests belonging to a successful
web_searchcall; a call that fails late can under-count requests it already issued, since per-request success is not recorded. Requests without a matching call are not billed. web_fetchruns through the local HTTP provider and is unbilled by default; opt into Fetch billing viaservices.fetch.billedonly for a billable Fetch provider.- When both the live page and a fresh cache are unavailable, the bundled
official-prices.jsonsnapshot is used until a refresh succeeds. - Runtime is turn wall time — it includes human waits and queueing; the split separates the human part explicitly.
tools[].msis summed per-call execution time (workload), so parallel tool calls may sum past the wall-clock tool segment.- The panel is a snapshot; a live conversation's last flush may trail by up to the cache window unless Refresh is pressed.
- Local day/hour buckets need the browser's timezone offset; a page that cannot report one gets UTC buckets.
- repository: https://github.com/echoVic/dsh-usage-stats (tags: v0.2.0)
- npm: https://www.npmjs.com/package/@echovic/dsh-usage-stats
- install from npm:
plugin_manager({ action: "install_bundle", target: "@echovic/dsh-usage-stats@0.2.0" }) - install from git:
plugin_manager({ action: "install_bundle", target: "github:owner/repo#v0.2.0" })(any https git URL works too; the repository root must be this package) - the bundle ships the Host half, the Web client half, the Loader patch, both locale files and the icon; there are no runtime dependencies and no build step.
- the Host half runs with the application's local permissions, so read a third-party bundle before installing it.
