Skip to content

feat(usage): split recorded cache usage by request shape - #939

Merged
devlikebear merged 1 commit into
mainfrom
feat/cache-shape-telemetry
Aug 24, 2026
Merged

feat(usage): split recorded cache usage by request shape#939
devlikebear merged 1 commit into
mainfrom
feat/cache-shape-telemetry

Conversation

@devlikebear

Copy link
Copy Markdown
Owner

Summary

Makes the measurement #933's test plan deferred (cache_read_input_tokens growth, still unchecked) actually readable. Instrumentation only — no placement or request-building change, so existing cache behavior is untouched.

Why the numbers were unreadable

Anthropic builds one prefix-matched cache key from toolssystemmessages. A request that omits tools therefore cannot hit an entry written by a tool-bearing request — not even inside the same turn. agent.Loop ends every turn with a tools-absent call (ToolChoice: none, internal/agent/loop.go:431), so each turn emits both shapes and their cache reads/writes were summed into the same rows.

That is exactly why the open question from the #933 review — does the tools-absent final call pay a 1.25x cache write for an entry nothing reads back? — could not be answered from recorded usage.

What changed

  • usage.Entry records ToolCount from len(opts.Tools).
  • A shape group-by keys on tool presence, so GET /v1/usage/summary?period=today&group_by=shape returns with-tools and no-tools rows. The handler already passes group_by straight through to tracker.Summary, so no API change was needed.
  • docs/usage-signals.md documents how to read the rows and what a regression looks like.

How to collect the answer

curl -s 'http://127.0.0.1:43180/v1/usage/summary?period=today&group_by=shape' | jq '.summary.rows'

Run a few multi-turn sessions that exercise the tool loop first.

Row Healthy Regression
with-tools cache_read_tokens grows with conversation length reads flat at 0 → a silent invalidator upstream
no-tools reads roughly match the previous turn's writes writes every turn with reads at 0 → paying the write premium for nothing

If no-tools shows sustained writes with near-zero reads, the fix is to keep tools on that final call and rely on tool_choice: none — already supported by toAnthropicToolChoice (internal/llm/anthropic.go:717) but never exercised, because Tools: nil means the tool_choice key is never emitted.

Honest caveats (documented, not buried)

  • tool_count only exists from this change onward. Older entries default to 0 and land in no-tools, so any comparison must start after the upgrade — otherwise the regression appears real when it is just missing data.
  • Anthropic's minimum cacheable prefix is ~1024 tokens; below it nothing caches and both rows read 0 regardless of placement.
  • Ephemeral entries expire after 5 minutes, so a slow turn misses for reasons unrelated to placement.

Test plan

  • New test pins the shape split, including the write/no-read signature of the suspected regression
  • Verified red before the change (unknown field ToolCount)
  • make test — full suite green
  • make vet, gofmt clean
  • make lint-diff — 0 issues
  • make test-cover-diff — 90.0% (9/10 changed lines), threshold 80%

🤖 Generated with Claude Code

Makes the measurement #933's test plan deferred actually readable.

Anthropic builds one prefix-matched cache key from tools -> system ->
messages, so a request that omits tools cannot hit an entry written by a
tool-bearing request even inside the same turn. agent.Loop ends every turn
with a tools-absent call (ToolChoice none), so each turn emits both shapes
and their cache reads/writes were being summed together — which is why the
question "does the tools-absent final call pay for a cache entry nothing
reads back" could not be answered from recorded usage.

- Record ToolCount on usage.Entry from len(opts.Tools).
- Add a "shape" group-by that keys on tool presence, so
  GET /v1/usage/summary?period=today&group_by=shape returns with-tools and
  no-tools rows. The handler passes group_by straight through, so no API
  change was needed.
- Document how to read the two rows, what a regression looks like, and the
  three things that produce a false negative (pre-upgrade entries defaulting
  to no-tools, the ~1024-token minimum cacheable prefix, and the 5-minute
  ephemeral TTL) in docs/usage-signals.md.

This is instrumentation only: no placement or request-building behavior
changes, so existing cache behavior is untouched.
@sonarqubecloud

Copy link
Copy Markdown

@devlikebear
devlikebear merged commit a4aede3 into main Aug 24, 2026
15 of 16 checks passed
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.

1 participant