Hive tracks token usage for local budgeting, dashboard cost estimates, and hub-level fleet rollups. The implementation lives in pkg/tokens, pkg/dashboard/cost.go, and pkg/hub/usage.go.
The collector starts from data.metrics_dir and rescans every 30 seconds. It merges:
- Hive JSONL session files in
data.metrics_dir(*.jsonl) using the flatSessionEntryschema. - Claude Code session JSONL under
data.claude_sessions_dirwhen configured, plus every per-agent/data/home/agents/<name>/.claude/projectsbeside it (each per-UID agent's~/.claudeis a real directory since #17031); sessions in a per-agent tree are attributed to that agent when the project directory name carries no work-dir hint. It reads assistantmessage.usageblocks from recent files (30-day mtime window). - Copilot CLI
events.jsonlunderdata.copilot_sessions_dirwhen configured. It readssession.shutdown.modelMetricsand avoids double-counting sessions already captured live by the proxy. - Bob CLI chat recordings under
data.bob_sessions_dir(default/data/home/.bob) and every per-agent/data/home/agents/<name>/.bobbeside it, when configured. It readstmp/*/chats/*.json, maps Bob project hashes back to trusted agent folders (falling back to the owning agent of a per-agent tree), and records explicittokensfields (falling back to content-size estimates only when a recording has no token data). - In the optional Claude Managed Agents hosted path,
pkg/tokens/managed_agents_ledger.gowritesmanaged-agents-*.jsonlafter deduplicating vendorspan.model_request_endIDs. Itsmanaged-agents-ledger.jsonsidecar keeps cumulative USD list cost, active seconds, and web-search counts and enforces the private-spoke USD 25/day cap.
A flat session entry can include role, agent, model, input_tokens, output_tokens, cache_read, and cache_creation. When agent is absent, Hive infers the agent from session path or configured detection keywords.
Proposed — awaiting maintainer sign-off on #6234/#6242. The recommended
sourcing path is option (c), a hybrid: use a pinned ccusage subprocess for the
backend usage formats it already covers, while Hive keeps its attribution,
budget, and price-label semantics.
The earlier coordination hazard is now moot: PR
#6142, which removed dead
scanner variants, has already merged to v5. That may require a small seam
re-add when this work lands, but it no longer needs to block the sourcing
decision.
Proposed contract:
- Hive invokes
ccusage --json --offlinethrough a narrow source interface, with the ccusage version pinned and the JSON output shape asserted by tests. - ccusage parses the nine CLI backends it covers today, closing the rotation gap for backends whose token data currently goes dark.
- Hive keeps the attribution layer: path-to-agent mapping, session de-dupe, per-agent/per-model aggregation, budget windows, dashboard labels, and hub rollups remain Hive-owned.
- Hive keeps its price table for labeling and policy decisions. Provider-native
numbers stay labeled
native; table-derived numbers stayestimated; missing prices remain explicit instead of pretending to be invoices. - Hive keeps
bob_scanner.gowhile ccusage lacks Bob coverage, so adopting ccusage does not regress an already-metered backend. - The companion RFC design doc is expected at src/docs/design/token-metering-ccusage.md; this page records the sourcing decision so implementation PRs have one target.
Sequencing, aligned with #6234:
- Source seam. Add an internal interface that can return normalized usage entries from either current scanners or the ccusage subprocess.
- ccusage implementation. Add the pinned subprocess runner using
--json --offline, fixtures for representative covered backends, and tests that fail on output-shape drift. - Parallel-run diff. Run existing scanners and ccusage side by side for overlapping sources, logging structured deltas without changing budget input.
- Per-backend cutover. Move one backend at a time to ccusage when the diff is understood; keep Bob on Hive's scanner until upstream coverage exists.
The collector keeps the latest aggregate in memory and writes /data/token-summary.json by default. The summary includes:
- total input/output/cache-read/cache-create tokens,
- per-agent and per-model totals,
- detailed per-agent/per-model token buckets,
- session count and recent session metadata.
data.metrics_dir is therefore both an input directory for Hive/inference JSONL and the durable location for per-agent inference usage files.
/api/statusincludes token/cost fields used by the dashboard./api/costreturns estimated cost from token counts × the static price table plus native spend for gateways that report it (OpenRouter/key, LiteLLM/key/info). Estimated rows are labelledestimated; rows whose model has no exact table entry are marked withunpriced_modelsand shown as tier estimates. Native gateway rows are labellednative. The estimated payload also includeswindow_start/window_endwhen session timestamps are available, so dashboard tables can state the covered usage window. Arecordingblock reports whether the running agents' models are actually being recorded:agent_models(alias-resolved models configured on running agents),unrecorded_models(those with no usage in the current token summary),recent_models(models whose usage grew in the last 24 h),last_priced_growth_at(unix ms when a model with an exact list price last grew in the persisted cost history),last_agent_model_growth_at,stale_after_msandstale(true when the history spans more than 24 h and none of the agents' models grew in that time; the Cost card then shows a warning and marks the run-rate "recording incomplete")./api/repo-activityis phase 1 of per-repo cost attribution: it reports audited output counts per repo and per(repo, agent)fromrepo=audit entries, plus an explicitunattributedbucket for output events with no repo. It reports activity only, not dollars; cost must not be smeared across repos until timestamped token joins exist./api/repo-costis phase 3: a cached interval join of the same auditedrepo=events against the per-message usage timeline, with mandatoryunattributedandbackend_unsupportedbuckets so Σ buckets == total./api/launch-costis launch attribution (#12128, RFC #12134): spend placed against the issue / PR / head SHA each kick was launched for. See Launch attribution below.- Cost estimates are not invoices. Subscription plans, self-hosted inference, negotiated rates, and provider billing semantics can differ from list prices.
The per-model list prices live in src/pkg/tokens/pricing.go (modelPrices, keyed by the normalised model id, plus priceTableDate, surfaced as price_table_date in /api/cost and as the "list prices as of" date in the Cost panel). They are kept current automatically:
- Source. OpenRouter's public model catalogue,
GET https://openrouter.ai/api/v1/models(no key; per-token USDpricing.prompt/completion, plusinput_cache_read/input_cache_writewhere published). No vendor HTML is scraped. Catalogue ids are mapped onto table keys with the sametokens.NormalizeModelIDthe dashboard uses, soanthropic/claude-opus-4.8andclaude-opus-4-8are one row;:free/:batchvariants,~vendor/model-latestrouter aliases, zero-priced and variable-priced (-1) rows are ignored, and reasoning-effort spellings (gemini-3-7-flash-high) price at their base model. - Cadence.
.github/workflows/price-table-refresh.ymlrunsgo run ./internal/tools/pricetableevery Monday (and onworkflow_dispatch). When any rate moved it rewrites only those literals, bumpspriceTableDate, adds achangelog.d/changed-price-table-<date>.mdfragment and opens or force-updates one PR onbot/price-table-refreshwhose body is the report: every change as old → new per-million input / output / cache read / cache write, and every row OpenRouter does not list (those keep their hand-set value). The PR carriesneeds-humanand is never auto-merged — compare the figures against the vendor's own price page before merging. After 30 quiet days the tool re-dates the table without changing a rate so the date keeps meaning "last reconciled";v2-ci.ymlfails any PR once the table is more than 60 days old (go run ./internal/tools/pricetable --check-age 60), so a refresh that stops running is noticed within weeks. - Cache-write convention. Anthropic/OpenAI publish a cache-write premium at or above the input rate, which the table takes as-is. Google publishes a per-hour storage rate instead; it has different semantics, so rows that followed the header convention (cache write = input) keep following it and the report notes the skipped figure.
- List price is a ceiling. The estimate multiplies persisted token history by these rates. Subscription seats (Claude Max, Copilot premium requests), self-hosted inference and negotiated rates all bill less than list, so the Cost panel's figure is an upper bound on per-token spend, not a bill.
The kick path records a LaunchRecord (pkg/tokens/launch.go) each time the governor or the review swarm delivers a kick: agent, trigger (governor-eval, review-<kind>), cadence-target repo, and the work refs the kick named. Governor kicks carry the issue refs placed in the message (owner/repo#N, or the verbatim external key for Linear/Jira work); review-swarm kicks carry one PR ref with its head SHA. Records are appended to /data/launch-attribution.jsonl (bounded ring, compacted in place) and attached to the token collector (Collector.SetLaunchLog / RecordLaunch / Launches).
pkg/dashboard/collect/launch_cost.go joins those records against the same UsageEvent timeline /api/repo-cost uses, with the same pricing table and the same honesty rules:
- A launch is an opening event: usage is owned by the most recent recorded launch for the same agent, until the next launch or
LaunchOwnershipMax(6h), whichever comes first. Usage before an agent's first retained launch, past the horizon, or outside the repo-cost window (14 days) isunattributed; backends without a per-message timeline land inbackend_unsupported. Σby_launch+unattributed+backend_unsupported== total. by_refaggregates every launch that named a ref.launchesis the repeat-spend counter (how many kicks named it, including kicks that produced no usage),head_shaslists the distinct PR heads launched against,launch_tokens/launch_usdare the undivided totals of every launch naming the ref (upper bound), andshare_tokens/share_usdsplit each launch evenly across the refs it named (exact for single-PR review kicks).- USD is
nullwhen nothing was attributed — never$0.00. ?ref=owner/repo#Nnarrowsby_refandby_launchto one work item; totals stay whole-window.
The dashboard Cost panel renders the top rows as "cost by work item". Manual send-kick and startup prompts do not record launches, so their spend stays unattributed. This is visibility only: nothing here gates or budgets a kick, and the governor window budget remains the single enforced budget.
The collector keeps a non-secret Diagnostics snapshot alongside the aggregate (pkg/tokens, Collector.Diagnostics()), so consumers can distinguish "zero tokens because nothing used a model" from "zero tokens because metering itself is unhealthy":
last_scan_error— the flat-JSONL scan ofdata.metrics_dirfailed. This aborts the whole scan cycle: the previous aggregate is kept and no other field is refreshed until the next cycle.last_claude_scan_error,last_copilot_scan_error,last_bob_scan_error— a per-source scan failed. Only that source is skipped for the cycle; the other sources still merge. Each field is rebuilt every scan, so a source error clears on the next successful pass over that source.live_capture_enabled— the Copilot proxy's live usage capture is active (set at boot viaSetCopilotLiveCapture).
The deep-health tokens check sent in spoke heartbeats (HealthSummary in pkg/dashboard/server.go) uses this to replace the bare "zero consumed" warning with a reason, evaluated in precedence order:
| Status | Detail | Meaning |
|---|---|---|
skip |
zero consumed — all agents paused |
Expected quiet: every enabled agent is paused. |
skip |
zero consumed — no agents due in the current governor mode |
Expected quiet: only on-demand or off-schedule agents remain. |
warn |
zero consumed — token parser/sink error: <err> |
Flat-JSONL scan failing (then Claude/Copilot/Bob parser errors, each named). |
warn |
zero consumed — sessions still open or live-capture has not accounted usage yet |
Live capture is on and sessions exist; usage may land shortly. |
warn |
zero consumed — sessions made no model calls |
Sessions were scanned but recorded no token usage. |
warn |
zero consumed — token sink or live metering disabled/misconfigured |
A metered agent is running with live capture off — check metering wiring. |
warn |
zero consumed — no model calls recorded |
Fallback when no better explanation applies. |
On the hub's fleet page, the tokens column tooltip reads that check's detail out of the heartbeat health checks and renders No tokens used: <reason> for hives at or below the no-token threshold (tokenHealthReason / tokenUsageTitle in pkg/hub/saas.go).
Caveat: the reasoned detail is heartbeat-path only. The local /api/health/deep tokens check still emits the plain zero tokens consumed — agents may not be working warning without consulting Diagnostics.
See health-checks.md for the operator-facing summary of the agent and token deep-health checks.
Each spoke heartbeat sends one scalar tokens_24h value. Despite the historical name, current code sends the cumulative total from the spoke's token summary. The hub stores it as totalTokens24h, samples a 7-day fleet history every 15 minutes, and serves /api/saas/usage with rollups by org/repo, owner, and cluster plus zero-consumption hives.
The hub cannot compute dollars or per-model/per-agent attribution because the heartbeat does not include model or agent token splits. Use the individual spoke's /api/cost for those details.