Skip to content

fix(ai): actually enable prompt caching on both provider paths#13

Open
ndemianc wants to merge 1 commit into
developfrom
fix/prompt-caching
Open

fix(ai): actually enable prompt caching on both provider paths#13
ndemianc wants to merge 1 commit into
developfrom
fix/prompt-caching

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

The symptom

An agent run showed ~21M input tokens vs ~175k output — input an order of magnitude over output, on a metered plan. That is the exact signature of an uncached agent loop: every turn re-sends tools + system + the entire growing transcript at full input price, while output stays tiny. Cached input bills at ~0.1×; we were paying on the whole prefix, every turn.

Root cause — two latent gaps

  • providers/anthropic.js passed system as a plain string, so the cache_control the header comment advertised never attached. cache_read_input_tokens read 0 forever.
  • providers/openaiCompat.js (serves OpenRouter and the LevelCode Cloud gateway — i.e. the Pro-plan path) read only prompt_tokens (blind to caching) and sent no breakpoints.

The fix

Anthropicsystem → an ephemeral-cached block (caches tools+system); a rolling cache_control breakpoint on the last message (caches the transcript). 2 of 4 breakpoints.

OpenAI-shaped path — two parts:

  • Read (universal, safe): parse prompt_tokens_details.cached_tokens, splitting it out of prompt_tokens so the context-meter total stays exact (mirrors Anthropic's disjoint fields). Surfaces caching for OpenAI/OpenRouter/gateway alike.
  • Write (gated): place breakpoints only for Claude-family model ids via a new isAnthropicFamily() — GPT/Gemini/DeepSeek/etc. auto-cache server-side and would ignore or reject cache_control. This is the guard that keeps the change from breaking every non-Claude request.

Honest caveat (server-side follow-up)

The client can only ask for caching. BYO-key Anthropic and OpenRouter benefit immediately. For the metered gateway, the client now sends the hint, but the gateway proxy must forward cache_control upstream for the cache to land — a one-line change in the gateway service (thin.ly). Instrumentation ships regardless, so cache_read_input_tokens starts reading real values in the meter today.

Tests

node --test test/translate.test.js → all pass, incl. 4 new cases: system + last-message breakpoints under {cache:true}, the breakpoint landing on a tool_result message, legacy shape unchanged without the flag, and the isAnthropicFamily gate.

Expected impact: on any multi-turn run, cache_read_input_tokens goes 0 → ~transcript size on turn 2, cutting billed input ~60–80%.

🤖 Generated with Claude Code

The agent loop re-sends tools + system + the whole growing transcript every
turn; without cache breakpoints each turn re-pays FULL input price for the
entire prefix. Symptom: input token usage an order of magnitude above output
(e.g. 21M input vs 175k output on a run) and a bill that makes no sense given
output is the pricey side.

Two latent gaps, both fixed:

- providers/anthropic.js: `system` was passed as a plain string, so the
  advertised cache_control never attached and cache_read_input_tokens stayed 0
  forever. Now sends system as an ephemeral-cached block (caches tools+system)
  and rolls a cache_control breakpoint onto the last message (caches the
  transcript). 2 of the 4 allowed breakpoints.

- providers/openaiCompat.js + translate.js (OpenRouter + the LevelCode Cloud
  gateway): read only prompt_tokens, so caching was invisible, and sent no
  breakpoints. Now reads prompt_tokens_details.cached_tokens (splitting it out
  of prompt_tokens so the context meter total stays exact) for ALL providers,
  and writes cache_control breakpoints gated to Claude-family model ids via a
  new isAnthropicFamily() — GPT/Gemini/DeepSeek/etc. auto-cache and would
  ignore or reject the field.

Note: for the metered gateway the client now sends the hint, but the gateway
proxy must forward cache_control upstream for the cache to land (server side).
Direct-Anthropic (BYOK) and OpenRouter benefit immediately.

Adds 4 translate.test.js cases covering the breakpoints + the Claude gate.
node --test test/translate.test.js → all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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