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.
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.
| File | Role |
|---|---|
agents/mira/events.py | Three 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.py | Two 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.py | Both 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.md | The 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.py | The 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. |
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)
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 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.
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 (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.
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.
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).