Skip to content

enable Anthropic/OpenRouter prompt caching (cache_control on system prefix)#2

Open
Shooksie wants to merge 3 commits into
mainfrom
prompt-caching
Open

enable Anthropic/OpenRouter prompt caching (cache_control on system prefix)#2
Shooksie wants to merge 3 commits into
mainfrom
prompt-caching

Conversation

@Shooksie

Copy link
Copy Markdown
Contributor

Problem

The provider already reads cache usage (cache_read_input_tokens in UsageInfo, surfaced as cache_read token events) but it never set a cache breakpoint. Anthropic-via-OpenRouter caches only when a content part carries cache_control — caching is not automatic. So every agentic step re-paid the full system prompt; the read side reported cache_read: 0 forever.

Change

  • ChatRequest gains cache_system_prompt: bool plus a custom Serialize impl. When the flag is set, only the first system message is rewritten into the OpenRouter/Anthropic multipart form:
    {"role":"system","content":[{"type":"text","text":"<sys>","cache_control":{"type":"ephemeral"}}]}
    Every other message keeps its plain-string content (derived ChatMessage serialization is untouched), so the cacheable prefix stays byte-stable and no other message shape changes. ChatMessage literals (38 of them) and session storage are completely unchanged.
  • Model/endpoint gatewants_anthropic_cache_breakpoint(model, api_base) requires both an Anthropic model (anthropic/ or claude) and an OpenRouter route (base url contains openrouter, or model has the openrouter/ namespace prefix). OpenAI-native endpoints (which auto-cache and can reject an unexpected cache_control) and custom non-OpenRouter OpenAI-compatible bases stay plain strings.
  • Applied at all three request build sites (main agent turn, compaction call, schema-retry call).

Before / After

  • Before: system prompt sent as a plain string → Anthropic/OpenRouter never caches → cache_read stays 0, full system prompt billed every step.
  • After: stable system prefix carries an ephemeral breakpoint → OpenRouter/Anthropic caches it → subsequent steps bill cache reads instead. The existing read side already reports cache_read, so caching can be verified live (watch cache_read go non-zero after the first step).

Tests

  • Serialized-JSON assertions: Anthropic/OpenRouter request → system message is the multipart array with cache_control.type == "ephemeral"; OpenAI-native → system message stays a plain string with no cache_control.
  • Gate truth table (Anthropic-via-OpenRouter true; OpenAI-native, non-Anthropic-on-OpenRouter, and anthropic/ on a custom non-OpenRouter base all false).
  • "Only the first system message gets the breakpoint" and a serde round-trip of the body shape.
  • cargo fmt / cargo clippy --all-targets clean; cargo test green (159 passed).

Version

Bumped 0.1.5 -> 0.1.6 (Cargo.toml + plugin.toml) so the media team and other fleets can repin to pick up caching.

Shooksie added 3 commits June 26, 2026 20:01
…refix)

Set an ephemeral cache_control breakpoint on the first system message for
Anthropic models routed through OpenRouter, so the stable system prefix is
cached across agentic steps instead of re-paying the full prompt each turn.

- ChatRequest gains a cache_system_prompt flag and a custom Serialize that
  rewrites only the first system message into the multipart content-array
  form { type: text, text, cache_control: { type: ephemeral } }. All other
  messages keep their plain-string content, so the cacheable prefix stays
  byte-stable and nothing else churns.
- Gated via wants_anthropic_cache_breakpoint(model, api_base): requires both
  an Anthropic model (anthropic/ or claude) AND an OpenRouter route (base url
  or openrouter/ prefix). OpenAI-native and custom non-OpenRouter endpoints
  stay plain strings (they auto-cache / can reject the field).
- The read side already reports cache_read tokens, so caching is verifiable
  live with no further change.

Bump to 0.1.6 (Cargo.toml + plugin.toml).
Per OpenRouter's prompt-caching docs, exactly two families require explicit
cache_control breakpoints: Anthropic Claude AND Alibaba Qwen. OpenAI, DeepSeek,
Grok/xAI, Moonshot/Kimi, Groq, and Gemini 2.5 all cache automatically and must
stay plain strings.

- Rename wants_anthropic_cache_breakpoint -> wants_explicit_cache_breakpoint;
  keep a #[deprecated] alias forwarding to the new name for API compatibility.
- Match the model's vendor SEGMENT (anthropic/qwen/alibaba), not a bare
  substring, so auto-cache models that merely mention an explicit-cache family
  in their id (e.g. deepseek/deepseek-r1-distill-qwen-32b) correctly stay plain.
- Still require the OpenRouter route (base url contains openrouter, or model has
  the openrouter/ namespace prefix).
- Extend the test truth table: Qwen-via-OpenRouter -> multipart system with
  cache_control ephemeral; DeepSeek/Gemini-2.5/Grok/Kimi/Groq/OpenAI all stay
  plain (incl. the deepseek-distill-qwen regression case).

Version stays 0.1.6.
…onfig seam

- Multi-breakpoint prompt caching: place cache_control:{ephemeral} across the
  system message + tools (if sent) + a rolling breakpoint on the growing
  conversation tail (up to Anthropic's 4-breakpoint cap), for the explicit-cache
  families (Anthropic/Qwen) via OpenRouter — so multi-step agent loops reuse all
  prior turns from cache, not just the system prompt. Auto-cache families stay plain.
- OpenRouter response caching: opt-in X-OpenRouter-Cache (+ TTL) header; hits bill
  as 0 tokens. Logs X-OpenRouter-Cache-Status at debug.
- Config seam: provider reads an optional cache config from the runtime contract
  (+ env fallbacks) with sensible defaults; ao-cli YAML->contract plumbing is a
  separate follow-up.
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