Story-2082 Walkthrough

Change: This is S-5 of the agent prompt-caching build (Epic-2050, RFD docs/notes/rfd-agent-caching-strategy.md §10 D-9 — cache telemetry into the existing rails). S-2 wired the cache settings; S-3 made the converse sandwich cacheable; S-4 probed which routes actually serve a cache. S-5 makes the caching observable: the cache read/write tokens and realized discount now ride the model.usage event and the Logfire spans, so per-seat hit-rate and savings become queryable. Additive only — no caching-policy behavior changes.

The one thing being added

Before: usage capture read input_tokens/output_tokens (and, since Story-2091, the real cost_usd). The provider also returns cache_read_tokens / cache_write_tokens on the same usage object — and pydantic-ai already auto-exports them onto the model-call span — but Mira dropped them on the floor (RFD §2.4). Without them there is no way to prove a warm prefix is actually hitting, or to compute the RFD §0 "big packs, paid for by the cache" claim. S-5 threads them through, mirroring the exact Story-2091 cost_usd path.

What ships

FileRole
agents/mira/events.pyThree additive fields on the frozen agent_event_stream.v1 model.usage payload: cache_read_tokens, cache_write_tokens, cache_discount_usd (all nullable, default None — a consumer that ignores them reads the frozen token fields unchanged).
agents/mira/observability.pyTwo null-safe readers: cache_tokens_from_result(result) (off usage.cache_read_tokens/cache_write_tokens) and cache_discount_from_result(result) (off provider_details['cache_discount'], the exact cost_from_result shape one key over).
agents/mira/converse.py / panel.py / command.py / runtime.pyBoth routes emit the cache fields. The panel bucket becomes a named SeatUsage (6 fields) and the command route sums them across seats via _sum_nullable (honest partial: an unreadable seat contributes nothing, not a fabricated zero).
docs/notes/cache-telemetry-dashboard.mdThe documented Logfire SQL query set — per-seat hit rate, write-vs-read, $ saved, cold/warm turns — plus the named broker seam. No new UI.
agents/mira/tests/test_cache_telemetry.pyThe S-5 gate (7 cases): additive builder defaults to None; both routes emit real cache tokens; command sums across seats; and the Logfire span auto-carries the cache tokens.

The thread (mirrors Story-2091 cost_usd exactly)

usage.cache_read_tokens / cache_write_tokens          ┤
provider_details['cache_discount']                    │ read null-safely off the result
   ↓  cache_tokens_from_result / cache_discount_from_result
converse: emit on model.usage       panel: append to SeatUsage bucket
                                        ↓  command sums across seats (_sum_nullable)
   ↓                                    ↓
events.model_usage_event(cache_read_tokens=, cache_write_tokens=, cache_discount_usd=)
   ↓
agent_event_stream.v1  model.usage  — the broker seam (forwarded, not consumed here)

0 vs None — the honesty distinction

A costed turn that served no cache reports 0 reads (a real "no hit"), because pydantic-ai's RequestUsage defaults these counts to 0. None is reserved for genuinely-unreadable usage (older staging SDK pins, Bug-1307). This matters for the hit-rate math: a cold turn is 0/(input+0) = 0% hit, not a gap. The tests pin both: a fake warm result → 80/10; an offline cold turn → 0/0.

The Logfire verification path (ADR-124 T1 floor — zero new instrumentation)

The cache tokens ride the same RequestUsage that carries the token counts, so pydantic-ai's auto-instrumentation already exports them onto every chat <model> span — no manual span, no raw tracer call (ADR-124 forbids both outside the sanctioned helper). A test asserts the SDK surfaces them, so an SDK bump can't silently drop the dashboard's query surface:

attrs = RequestUsage(cache_read_tokens=80, cache_write_tokens=10).opentelemetry_attributes()
assert attrs["gen_ai.usage.details.cache_read_tokens"]  == 80
assert attrs["gen_ai.usage.details.cache_write_tokens"] == 10

Per seat = (service_name, model_id): the roster is one model per seat and AmbientIdentitySpanProcessor already stamps agent.key + model_id on every span, so no new seat attribute is added (additive-only). The dashboard doc groups on exactly these.

cache_discount_usd — on the wire, null today, forward-compatible

The raw OpenRouter response carries usage.cache_discount (the S-4 probe reads it directly), but the installed pydantic-ai (1.107) does not map it into provider_details. So cache_discount_from_result returns None today — the field is on the wire, additive and forward-compatible, and lights up the moment the SDK surfaces it. No estimate is fabricated from a price table (ADR-151): hit rate (Q1) is the live KPI; billed savings come from the S-4 probe until the SDK maps the discount.

The broker seam (named, not built — CE-14 / ME-7)

The broker (pramaan-functions) will forward cache_read_tokens, cache_write_tokens, cache_discount_usd additively, and should subtract cache_discount_usd from its cost_cap arithmetic — else the cap over-counts cached turns ~10× (RFD §10, OQ-8). Over-counting is the safe direction, so the cap stays conservative until that lands. This story names the field and the required behavior; the broker change is its own repo's lane.

Scope boundary

No caching-policy change (that is the sandwich / config stories); no new UI (a documented SQL set is the "dashboard"); no broker code (seam named only); no new metric instrument — the T1 span floor already carries the tokens. Additive to a frozen envelope throughout.

Verification

uv run --package mira pytest agents/mira/tests → 133 passed (incl. the 7 new cache-telemetry cases). uv run pytest tests/ → 175 passed (governance). ruff check agents/ tests/ scripts/ clean. python scripts/check_brain_egress.py clean (ADR-110 §6 / ADR-124 §D10).