Skip to content

fix(cost): record cache reads and bill them at the cached rate - #38

Merged
adham90 merged 1 commit into
mainfrom
fix/cached-token-accounting-and-pricing
Jul 27, 2026
Merged

fix(cost): record cache reads and bill them at the cached rate#38
adham90 merged 1 commit into
mainfrom
fix/cached-token-accounting-and-pricing

Conversation

@adham90

@adham90 adham90 commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Summary

Two bugs, one root: cache metrics were captured and then dropped.

  1. cached_tokens never reached the persisted execution. capture_response read metadata.cached_tokens into the context's generic metadata bag, but Pipeline::Context had no first-class accessor for it and build_result never copied it into the Result — so every attempt recorded cached_tokens: 0. Context now carries cached_tokens/cache_creation_tokens as real accessors and build_result passes them through.

  2. Cache reads were billed at the full input rate. calculate_costs charged input_tokens * input_price unconditionally. Correct on Anthropic (input is reported net of cache reads — that path is untouched), wrong on OpenAI/Gemini whose prompt_tokens/promptTokenCount include cache reads. Cached tokens were charged at ~4x their real price, overstating spend ~2.7x on cached workloads (7112 input with 6016 cached costs $0.0052, not the $0.0142 being recorded). Cache reads are now split out and billed at the provider's cached rate.

Test plan

  • New spec/agents/cached_token_pricing_spec.rb covering per-provider billing (Anthropic net vs OpenAI/Gemini gross) and cached-token propagation into the Result
  • Updated spec/agents/prompt_caching_spec.rb
  • bundle exec rspec + standardrb green

🤖 Generated with Claude Code

Two bugs, one root: cache metrics were captured and then dropped.

1. cached_tokens never reached the persisted execution.

   capture_response read metadata.cached_tokens into the context's generic
   metadata bag, but Pipeline::Context had no first-class accessor for it and
   build_result never copied it into the Result. Since the reliability
   middleware passes context.output (the Result) to
   AttemptTracker#complete_attempt, every attempt recorded cached_tokens: 0,
   which aggregated to 0 in the executions column. Across ~1M production rows
   the value was 0 every time, while the provider was in fact reporting
   substantial cache hits (a live probe returned cached=6016 of input=7112).

   Context now carries cached_tokens/cache_creation_tokens as real accessors
   and build_result passes them through.

2. Cache reads were billed at the full input rate.

   calculate_costs charged (input_tokens * input_price) unconditionally. That
   is correct on Anthropic, whose input_tokens is reported NET of
   cache_read_input_tokens — the cache is additive there and extra_token_costs
   already prices it, so that path is deliberately untouched.

   It is wrong on OpenAI and Gemini, whose prompt_tokens/promptTokenCount
   INCLUDE the cache reads. Those tokens were charged at 4x their real price,
   overstating spend ~2.7x on the workload caching exists for (a long stable
   system prompt replayed every turn): 7112 input with 6016 cached costs
   $0.0052, not the $0.0142 that was being recorded. Worse, once the provider
   also reports a cache_read cost component, extra_token_costs would add the
   same reads a second time — a real double-charge, latent only because
   RubyLLM 1.14's Message has no #cost.

   input_cost now splits uncached/cached for the include-providers and sets
   context[:cache_read_priced] so extra_token_costs skips those reads. It
   falls back to the full rate whenever the split can't be made safely (no
   cache reported, unknown cached price, non-standard pricing shape), so no
   model is ever under-billed.

The two existing prompt-caching examples asserted only that a Result came
back — their own comments noted they could not see the cache tokens. They now
assert the values that bug 1 was swallowing.
@adham90
adham90 merged commit c5a34cc into main Jul 27, 2026
4 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