Token/cost usage tracking end-to-end + tray usage UI - #795
Conversation
Record token usage even when pricing fails (the OpenRouter gap: unknown model slugs made litellm.completion_cost return None and the tokens were discarded with the cost). Adds OpenRouter native cost accounting (usage.include), Anthropic-native usage-shape extraction incl. cache tokens, Claude Code harness token capture, and a per-execution contextvar-scoped CostTracker whose serialized summary is attached to the execution result envelope (sync 200 body and async status callback) under the "usage" key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es API New execution_usage table (goose migration 034 for Postgres, GORM auto-migrate covers SQLite) populated by tolerant ingestion of the SDK's "usage" envelope key on both the sync-200 and async-callback paths. New GET /api/ui/v1/usage/stats endpoint with window filtering, by-model / by-provider / by-agent / by-harness aggregation, zero-filled bucket timeseries (?buckets=N) and per-model series (&series_by=model). Existing execution-details responses now carry cost/total_tokens, which the web UI already renders. Includes a cross-language golden test pinning the Python SDK's serialized payload against the Go parser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ray fork The tray's menu-bar item now shows the af badge with a lifecycle glyph (green dot running, rotating arc starting, gray ring stopped). The Usage submenu is a minimal glance surface: a 48-bucket token histogram (stacked per model), top-3 model rows and Claude 5h/7d subscription quota gauges (read-only, via the user's existing Claude Code Keychain credentials), all on a uniform leading-slot grid so every title aligns. Success row gains a live sparkline icon; response latency is humanized. Vendors fyne.io/systray v1.12.2 as third_party/systray with two additive patches (SetImage / SetStatusImage) lifting the stock 16x16 menu-image clamp — see third_party/systray/PATCHES.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Performance
⚠ Regression detected:
|
📊 Coverage gateThresholds from
✅ Gate passedNo surface regressed past the allowed threshold and the aggregate stayed above the floor. |
📐 Patch coverage gateThreshold: 80% on lines this PR touches vs
✅ Patch gate passedEvery surface whose lines were touched by this PR has patch coverage at or above the threshold. |
The go.mod replace directive for the vendored systray fork points at control-plane/third_party/systray, which was not present in the build context when Dockerfiles copied only go.mod/go.sum for the module download cache layer, failing the control-plane image and functional test builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
santoshkumarradha
left a comment
There was a problem hiding this comment.
Thanks for pushing this through end to end. I found one blocking compatibility issue inline: the sync result path now reserves a top-level usage key and relies on the control plane to strip it back out, which silently mutates user payloads that already return that key. Given the repo’s API stability bar, I think that needs to be fixed before merge.
|
@AbirAbbas also how are we planning to deal with go and tsx on this? |
will implement it in this PR |
…s survive The sync result path reserved a top-level "usage" key in dict results and relied on the control plane stripping it back out — silently mutating any agent result that legitimately returns its own "usage" key (review finding on #795). Usage now travels under the reserved "__agentfield_usage__" envelope key (USAGE_ENVELOPE_KEY / usageEnvelopeKey); the control plane extracts and strips exactly that key and never touches user data. Regression tests pin that a user-owned "usage" key — top-level or nested — passes through byte-for-byte. The async status-callback path already carried usage as a typed sibling field of "result" and is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ution
Port of the Python SDK's usage tracking to the TypeScript SDK, emitting the
same cross-language wire contract:
- CostTracker bound to the ExecutionContext (AsyncLocalStorage-isolated per
execution; local agent.call() rolls child usage into the parent tracker).
- LLM capture in AIClient generate paths (AI SDK v6 usage incl. cache token
details) and across ToolCalling loop turns; OpenRouter requests opt into
native cost accounting (usage: {include: true}) via a fetch wrapper, with
cost read from the provider's raw usage. stream() usage is intentionally
not captured — draining the usage promise would consume abandoned streams.
- Claude Code harness runs record tokens + provider-reported cost.
- Transport: "usage" field on all terminal async status reports;
"__agentfield_usage__" reserved sibling key on object-shaped sync-200
results (user payloads, including a user-owned "usage" key, untouched).
44 new vitest tests; suite 730 green, tsc + tsup clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Port of the Python SDK's usage tracking to the Go SDK, emitting the same
cross-language wire contract:
- CostTracker bound per execution via context (fresh tracker at every
handler entrypoint: execute, reasoner sync/async, skill), read back to
attach usage after the handler returns.
- LLM capture at the Agent.AI / AIWithTools / AIStream chokepoints; ai.Usage
gains cache read/creation tokens (OpenAI prompt_tokens_details and
Anthropic-native shapes) and provider-reported cost. OpenRouter requests
opt into native cost accounting (usage: {include: true}) at the request
marshal chokepoint; tool-call loops capture per-turn usage.
- Harness runs record tokens + cost: token fields threaded through
harness.Metrics/Result and populated from already-parsed provider output
(claude code, codex, opencode).
- Transport: "usage" field on async terminal status payloads;
"__agentfield_usage__" reserved sibling key on object-shaped sync-200
results (user payloads, including a user-owned "usage" key, untouched).
- Fixes a pre-existing SSE decoder bug that dropped bytes returned alongside
io.EOF — the read where a terminal usage-accounting chunk arrives.
28 new tests; go build/vet/test green, -race clean on the new paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the cross-language golden contract test to all three SDKs: the verbatim Serialize()/serialize() outputs of the Go and TypeScript trackers (generated by running each SDK's serializer) parse to the same rows as the Python fixture. Row assertions are shared; only the priced entry's cost_source differs by design (litellm for Python, provider for Go/TS). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd-tray # Conflicts: # sdk/typescript/src/agent/Agent.ts # sdk/typescript/src/context/ReasonerContext.ts
Both landed in this PR — Go in 21c4762, TypeScript in 555e0b3. Each SDK captures per-execution usage (AI-client tokens incl. cache details, OpenRouter native cost accounting via Two intentional gaps: TS |
Found by live end-to-end verification of the Go/TS SDK usage transport: on a control plane running in a non-UTC timezone, /usage/stats returned zeros for every bounded window (1h/24h/7d/30d) while window=all showed the rows. GORM stamps execution_usage.created_at with time.Now() in server-local time, SQLite stores timestamps as text, and text comparison across mixed UTC offsets is lexicographic — so "created_at >= <UTC since>" silently excluded in-range rows (and ORDER BY created_at could pick the wrong oldest/latest row). CI runners are UTC, which is why the existing window tests never caught it. All window filters and oldest/latest orderings now go through the existing dialect-portable epoch expression (SQLite strftime / PostgreSQL EXTRACT). The new regression test pins the behavior with rows whose created_at carries a fixed -04:00 offset; it fails on the old code even on UTC hosts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Makes token/cost tracking actually work end-to-end (SDK capture → control-plane storage → aggregation API → UI) across all three SDKs (Python, Go, TypeScript), and gives the macOS tray a usage glance surface plus a lifecycle status badge.
Wire contract (shared by all three SDKs)
{total_cost_usd, total_input_tokens, total_output_tokens, total_tokens, entries[]}; each entry carriessource/provider/model/harness/reasoner, token counts (incl. cache read/creation), nullablecost_usd, andcost_source.usagefield besideresultin the SDK-controlled callback envelope.__agentfield_usage__, which the control plane extracts and strips. A user result that legitimately returns its ownusagekey is payload and passes through byte-for-byte untouched (addresses the blocking review finding; regression tests pin this on both sides). Non-object sync results report via the async path.Python SDK — capture & transport
litellm.completion_cost()can't price the model (previously both were silently discarded). OpenRouter requests opt into native cost accounting (usage: {include: true}).prompt_tokens) and Anthropic-native (input_tokens) usage shapes, including cache read/creation tokens.CostTrackerbound via contextvar (concurrency-safe).Go SDK — capture & transport
CostTrackerbound per execution viacontext.Contextat every handler entrypoint (execute, reasoner sync/async, skill).Agent.AI/AIWithTools/AIStreamchokepoints;ai.Usageextended with cache read/creation tokens (OpenAIprompt_tokens_detailsand Anthropic-native shapes) and provider-reported cost; OpenRouter requests opt into native cost accounting; tool-call loops capture per-turn usage.harness.Metrics/Result, populated from already-parsed claude-code/codex/opencode output).io.EOF(exactly where a terminal usage-accounting chunk arrives).TypeScript SDK — capture & transport
CostTrackeron theExecutionContext(AsyncLocalStorage-isolated per execution; localagent.call()rolls child usage into the parent tracker).AIClientgenerate paths (AI SDK v6 usage incl. cache token details) and acrossToolCallingloop turns; OpenRouter requests opt into native cost accounting via a fetch wrapper, cost read from the provider's raw usage.stream()usage intentionally not captured (draining the usage promise would consume abandoned streams).Control plane — storage & API
execution_usagetable: goose migration034(Postgres) + GORM auto-migrate (SQLite).__agentfield_usage__key so it can't leak into stored results.GET /api/ui/v1/usage/stats?window=1h|24h|7d|30d|allwith totals and by-model / by-provider / by-agent / by-harness aggregation; optional zero-filled bucket timeseries (&buckets=N) and per-model series (&series_by=model). Dialect-portable SQL (SQLite + Postgres).cost/total_tokens— the web UI's dormant cost columns render without frontend changes.Tray (macOS)
fyne.io/systrayv1.12.2 asthird_party/systraywith two additive patches (SetImage,SetStatusImage) lifting the stock 16×16 menu-image clamp — documented inthird_party/systray/PATCHES.md.Verification
go test ./...green; golden contract tests cover all three SDK payloads plus the sync-body strip/preserve behavior;GOOS=linuxbuild clean.ruff checkclean; full suite green (includes 37 usage-transport tests + a regression test that a user-ownedusagekey survives the sync round-trip).go mod tidyno-drift,go build/go vet/go test ./...green (28 new usage tests),-raceclean on the new paths.npm ci,tsc --noEmit, 730/730 vitest tests,tsupbuild all green on Node 20 (44 new usage tests)./usage/statsverified with correct by-provider/by-agent aggregations, null-cost token counting, a user-ownedusagekey surviving the round-trip untouched, and no envelope leakage into results.1h/24h/…) returned zeros because SQLite compares timestamp text lexicographically across mixed UTC offsets. Fixed by routing all window filters through the dialect-portable epoch expression; regression test pins it with offset-zoned rows (it fails on the old code even on UTC CI runners).Notes for reviewers
__agentfield_-prefixed keys are reserved for SDK↔control-plane transport; user payloads are otherwise never inspected or modified.usage-e2edemo rows may exist in local dev DBs from testing; they are data, not code.🤖 Generated with Claude Code