Skip to content

Token/cost usage tracking end-to-end + tray usage UI - #795

Merged
AbirAbbas merged 10 commits into
mainfrom
feat/usage-tracking-and-tray
Jul 20, 2026
Merged

Token/cost usage tracking end-to-end + tray usage UI#795
AbirAbbas merged 10 commits into
mainfrom
feat/usage-tracking-and-tray

Conversation

@AbirAbbas

@AbirAbbas AbirAbbas commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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)

  • Per-execution tracker serialized as {total_cost_usd, total_input_tokens, total_output_tokens, total_tokens, entries[]}; each entry carries source/provider/model/harness/reasoner, token counts (incl. cache read/creation), nullable cost_usd, and cost_source.
  • Async status callback: usage travels as a typed top-level usage field beside result in the SDK-controlled callback envelope.
  • Sync-200 results: usage is merged into object-shaped results under the reserved namespaced key __agentfield_usage__, which the control plane extracts and strips. A user result that legitimately returns its own usage key 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.
  • Cross-language golden tests pin the verbatim serialized payload of each SDK against the Go parser.

Python SDK — capture & transport

  • Fixes the OpenRouter tracking bug: tokens are now recorded even when litellm.completion_cost() can't price the model (previously both were silently discarded). OpenRouter requests opt into native cost accounting (usage: {include: true}).
  • Handles both OpenAI-style (prompt_tokens) and Anthropic-native (input_tokens) usage shapes, including cache read/creation tokens.
  • Claude Code harness runs capture full token usage (previously cost only).
  • Per-execution CostTracker bound via contextvar (concurrency-safe).

Go SDK — capture & transport

  • CostTracker bound per execution via context.Context at every handler entrypoint (execute, reasoner sync/async, skill).
  • LLM capture at the Agent.AI / AIWithTools / AIStream chokepoints; ai.Usage extended with cache read/creation tokens (OpenAI prompt_tokens_details and Anthropic-native shapes) and provider-reported cost; OpenRouter requests opt into native cost accounting; tool-call loops capture per-turn usage.
  • Harness runs record tokens + cost (token fields threaded through harness.Metrics/Result, populated from already-parsed claude-code/codex/opencode output).
  • Fixes a pre-existing SSE decoder bug that dropped bytes returned alongside io.EOF (exactly where a terminal usage-accounting chunk arrives).

TypeScript SDK — capture & transport

  • CostTracker on 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 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).
  • Claude Code harness runs record tokens + provider-reported cost.

Control plane — storage & API

  • New execution_usage table: goose migration 034 (Postgres) + GORM auto-migrate (SQLite).
  • Tolerant ingestion of the usage envelope on both result paths; never fails the execution; strips only the reserved __agentfield_usage__ key so it can't leak into stored results.
  • New GET /api/ui/v1/usage/stats?window=1h|24h|7d|30d|all with 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).
  • Existing execution-details responses now include cost / total_tokens — the web UI's dormant cost columns render without frontend changes.

Tray (macOS)

  • Menu-bar item: af badge + lifecycle glyph (green dot running / rotating orange arc starting / gray ring stopped), driven by health checks + process detection.
  • Usage submenu (glance-only): 48-bucket token histogram stacked per model, top-3 model rows with proportional bars on a uniform-slot grid, Claude 5h/7d subscription quota gauges (read-only, via the user's existing Claude Code Keychain OAuth token; degrades silently), last-updated footer. Full detail intentionally lives in the dashboard.
  • Success row gains a live sparkline icon; avg response latency is humanized ("2m 43s" not "162867 ms").
  • Vendors fyne.io/systray v1.12.2 as third_party/systray with two additive patches (SetImage, SetStatusImage) lifting the stock 16×16 menu-image clamp — documented in third_party/systray/PATCHES.md.

Verification

  • Control plane: full go test ./... green; golden contract tests cover all three SDK payloads plus the sync-body strip/preserve behavior; GOOS=linux build clean.
  • Python: ruff check clean; full suite green (includes 37 usage-transport tests + a regression test that a user-owned usage key survives the sync round-trip).
  • Go SDK: go mod tidy no-drift, go build/go vet/go test ./... green (28 new usage tests), -race clean on the new paths.
  • TypeScript SDK: npm ci, tsc --noEmit, 730/730 vitest tests, tsup build all green on Node 20 (44 new usage tests).
  • Live end-to-end (all three SDKs): real Python, Go, and TypeScript agents → control plane → /usage/stats verified with correct by-provider/by-agent aggregations, null-cost token counting, a user-owned usage key surviving the round-trip untouched, and no envelope leakage into results.
  • The live Go/TS run surfaced a real bug in the stats API: on a non-UTC control plane, every bounded window (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).
  • All tray graphics visually verified via offscreen renders (dark + light appearances); menu geometry (uniform slot widths) is unit-test enforced.

Notes for reviewers

  • The sync-200 path can only carry usage for object-shaped results; non-object results report via the async callback (the production path).
  • __agentfield_-prefixed keys are reserved for SDK↔control-plane transport; user payloads are otherwise never inspected or modified.
  • Gemini harness runs don't report tokens (its providers parse no events today); codex/opencode report what their parsed events already expose.
  • usage-e2e demo rows may exist in local dev DBs from testing; they are data, not code.

🤖 Generated with Claude Code

AbirAbbas and others added 3 commits July 17, 2026 16:56
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>
@AbirAbbas
AbirAbbas requested a review from a team as a code owner July 17, 2026 20:57
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.5 KB +5% 0.32 µs -9%
Go 207 B -26% 0.54 µs -46%
TS 435 B +24% 1.42 µs -29%

Regression detected:

  • TypeScript memory: 350 B → 435 B (+24%)

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 86.90% 87.40% ↓ -0.50 pp 🟡
sdk-go 92.40% 92.00% ↑ +0.40 pp 🟢
sdk-python 93.82% 93.73% ↑ +0.09 pp 🟢
sdk-typescript 91.00% 90.42% ↑ +0.58 pp 🟢
web-ui 84.75% 84.79% ↓ -0.04 pp 🟡
aggregate 85.54% 85.75% ↓ -0.21 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 1481 83.00%
sdk-go 445 90.00%
sdk-python 0 ➖ no changes
sdk-typescript 137 100.00%
web-ui 0 ➖ no changes

✅ Patch gate passed

Every 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>
Comment thread sdk/python/agentfield/agent.py Outdated

@santoshkumarradha santoshkumarradha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@santoshkumarradha

Copy link
Copy Markdown
Member

@AbirAbbas also how are we planning to deal with go and tsx on this?

@AbirAbbas

Copy link
Copy Markdown
Contributor Author

ound one blocking compatibility issue inline: the sync result path now reserves a top-level usage key and

will implement it in this PR

AbirAbbas and others added 5 commits July 20, 2026 08:52
…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
@AbirAbbas

Copy link
Copy Markdown
Contributor Author

also how are we planning to deal with go and tsx on this?

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 usage: {include: true}, tool-loop turns, and coding-agent harness runs) and transports it over the same wire contract as Python: a typed usage field on the async status callback, and the reserved __agentfield_usage__ sibling key on object-shaped sync-200 results. The control-plane golden test now pins all three SDKs' verbatim serialized payloads against the parser (e91337c), so the contract can't drift silently.

Two intentional gaps: TS stream() usage isn't captured (awaiting the AI SDK's usage promise force-consumes streams the caller may abandon), and gemini harness runs report no tokens yet (that provider doesn't parse events). Also picked up along the way: the Go SDK's SSE decoder was dropping bytes returned alongside io.EOF — exactly where a terminal usage chunk arrives — fixed in the same commit.

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>
@AbirAbbas
AbirAbbas merged commit 22e8cc0 into main Jul 20, 2026
44 checks passed
@AbirAbbas
AbirAbbas deleted the feat/usage-tracking-and-tray branch July 20, 2026 14:40
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