Skip to content

feat(anthropic): auto-inject prompt-cache breakpoints when enabled on a model - #810

Merged
moonming merged 2 commits into
mainfrom
feat/anthropic-cache-injection
Jul 23, 2026
Merged

feat(anthropic): auto-inject prompt-cache breakpoints when enabled on a model#810
moonming merged 2 commits into
mainfrom
feat/anthropic-cache-injection

Conversation

@moonming

@moonming moonming commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What

When a direct Anthropic model has auto_prompt_caching enabled, the gateway now injects prompt-cache breakpoints into requests that carry none of their own — one on the last system block, one on the last content block of the final message. Callers get provider-side prompt-cache discounts (0.1× input on cache reads) and faster TTFT with zero client changes. Callers that set their own cache_control markers are forwarded unchanged (stand-down).

Phase 1 (DP half) of AISIX-Cloud#1110. Builds on #804 (Phase 0 — marker preservation), which this PR is stacked on.

Why two breakpoints, why these positions

Per the Anthropic prompt-cache prefix hierarchy (tools → system → messages, docs):

  • Last system block → caches the stable tools+system prefix.
  • Last content block of the final message → caches the whole conversation prefix; because it rides the final turn, it advances as the conversation grows, so each turn re-caches incrementally.

This is the convergent strategy across the mainstream gateways that auto-inject (a system+trailing pair, trailing marker advancing with the dialogue). We inject at most two markers and only into a request that had zero, so Anthropic's 4-breakpoint cap can never be breached.

Scope — direct Anthropic only

Injection is applied only in the Anthropic bridge (chat + chat_stream), not in the shared build_request — so the Bedrock-invoke and Vertex :rawPredict paths (which also call build_request) do not inject. That's deliberate: gateway-authored markers on Bedrock/Vertex carry provider-specific hazards (per-model 5m-only TTL tiers on Bedrock; Vertex's per-project "explicit caching disabled → reject" mode) that pass-through fidelity doesn't. Bedrock injection is AISIX-Cloud#1110 Phase 2; Vertex is deferred there. Marker preservation (Phase 0, #804) already covers all three.

Changes

DP config (aisix-core)

  • Model.auto_prompt_caching: Option<AutoPromptCaching> — nested { enabled: bool, ttl: Option<CacheTtl> }, following the CooldownConfig house pattern. CacheTtl = 5m | 1h (default 5m). Regenerated schemas/resources/model.schema.json.

DP injection (aisix-provider-anthropic)

  • inject_cache_breakpoints(req, ttl) in wire.rs: stand-down scan (any cache_control on system/messages/tools → inject nothing), else mark the last system block (promoting a string-form system to a one-block array so the marker has somewhere to live) and the last content block of the final message. 5m emits the bare {"type":"ephemeral"} Anthropic treats as the 5-minute default; 1h carries the explicit ttl.
  • maybe_inject_cache_breakpoints(body, ctx) in bridge.rs: reads ctx.model.auto_prompt_caching, gates on enabled, shared by the streaming and non-streaming paths so they can't drift.

No min-token gate: below-minimum-length prompts are a documented silent no-op upstream (no error, normal billing, both cache counters 0), so speculative injection on short prompts is harmless. An empty text block is a different case — Anthropic 400s on cache_control attached to one — so injection skips empty/whitespace-only blocks (reachable via the image-only-turn degrade shape and a blank system prompt); see is_empty_text_block.

Billing / observability — already wired

No usage-plumbing change needed: the response path already parses cache_creation_input_tokens / cache_read_input_tokens (wire.rs), and the control plane already prices them at distinct cache_read / cache_write rates — so budgets, spend reports, and the dashboard reflect injected-cache economics for free.

Paired CP work (required before this is user-reachable) — AISIX-Cloud#1110

Per the repo's config-knob rule, this DP PR is one half. The auto_prompt_caching knob is not reachable until the control plane exposes it: cp-admin.yaml schema + Go model/validation/kine projection + dashboard form + en/zh i18n. That is a paired CP PR, tracked in AISIX-Cloud#1110.

Rollout ordering (blocking constraint): DP Model is #[serde(deny_unknown_fields)], so this DP PR — which teaches the DP to read auto_prompt_cachingmust deploy before the CP PR that writes it. If the CP shipped the field first, any model carrying it would be silently dropped from the DP snapshot and 404. This PR is the safe-to-land-first half by construction.

Testing

  • wire.rs unit tests (9): system+trailing marking, 1h ttl, no-system case, stand-down on a client message marker, stand-down on a client tool marker, in-place marking of a client block-form system, empty-text final block skipped, blank system skipped, only-last-of-multi-block marked.
  • bridge.rs tests (3): non-streaming injects when enabled, streaming injects when enabled (captures the outbound body, closing the chat_stream coverage gap), does-not-inject when enabled:false.
  • e2e (anthropic-upstream-e2e.test.ts, 2 new): black-box wire-shape — an auto_prompt_caching-enabled model injects markers on system (block-array form) + final message at the configured 1h ttl with earlier turns clean and a message-count pin; and a stand-down case where a caller-marked request comes out with exactly its own single marker.
  • Full workspace cargo test + Phase-0 e2e green (injection e2e run 3×, stable); cargo fmt + clippy clean.

Audit round

Independent 6-angle cold audit (5 confirmed / 2 rejected), all addressed in the follow-up commit:

  • HIGH (spec): injection stamped cache_control onto an empty text block (image-only/caption-less final turn, or blank system) → Anthropic 400. Fixed with is_empty_text_block guard on both injection sites + 2 unit tests.
  • MEDIUM ×2 (reliability + e2e): chat_stream injection had zero test coverage. Added a bridge-level streaming test capturing the outbound body.
  • MEDIUM (e2e): only-last-block-marked was asserted only against single-block final messages. Added a 3-block unit test.
  • LOW (e2e): enabled:false gate untested. Added a bridge test.
  • Rejected: an "empty block is a silent no-op not a 400" LOW (superseded — the guard is correct either way) and a default-on cache-tenancy side-channel LOW (feature is opt-in default-off; the provider-credential cache boundary is already noted in AISIX-Cloud#1110 for the customer docs).

Deliberately out of scope

  • CP knob exposure — paired PR, AISIX-Cloud#1110 (see above).
  • Bedrock (Phase 2) / Vertex (deferred) injection — AISIX-Cloud#1110.
  • Native /v1/messages passthrough injection: those callers are cache-aware and send their own markers (→ stand-down), so injection targets the OpenAI-shape callers who don't; tracked in AISIX-Cloud#1110 if a gap emerges.
  • The newer usage.cache_creation.{ephemeral_5m,ephemeral_1h}_input_tokens breakdown (verifies 5m-vs-1h write billing) — AISIX-Cloud#1110 follow-up.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@moonming, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fc5c183e-ed91-4dea-8cbb-bd4225882326

📥 Commits

Reviewing files that changed from the base of the PR and between f1c00c8 and a764fb8.

📒 Files selected for processing (5)
  • crates/aisix-core/src/models/model.rs
  • crates/aisix-provider-anthropic/src/bridge.rs
  • crates/aisix-provider-anthropic/src/wire.rs
  • schemas/resources/model.schema.json
  • tests/e2e/src/cases/anthropic-upstream-e2e.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/anthropic-cache-injection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Base automatically changed from fix/anthropic-cache-control-preserve to main July 23, 2026 09:12
moonming added 2 commits July 23, 2026 17:13
… a model

Phase 1 (DP half) of the prompt-caching work. A direct Anthropic model
with auto_prompt_caching enabled makes the gateway inject cache_control
breakpoints into requests carrying none of their own — one on the last
system block, one on the last content block of the final message — so
callers get provider-side prompt-cache discounts with no client change.
Callers that set their own markers win (full stand-down, which also keeps
the request within Anthropic's 4-breakpoint cap).

Injection lives only in the Anthropic bridge (chat + chat_stream), not in
the shared build_request, so the Bedrock-invoke and Vertex rawPredict
paths do not inject — gateway-authored markers there carry provider
hazards (Bedrock per-model 5m-only tiers, Vertex per-project reject mode)
deferred to Phase 2. Marker preservation (#804) already covers all three.

Model gains auto_prompt_caching: {enabled, ttl(5m|1h)}. DP reads it here;
the paired CP knob (cp-admin.yaml + dashboard) is tracked in the umbrella
issue and MUST deploy after this, since Model is deny_unknown_fields.
Cold-audit HIGH: inject_cache_breakpoints stamped cache_control onto the
final message's last block (and the promoted system block) with no
empty-text guard. When that block is the empty-text degrade shape — an
image-only or caption-less final turn flattens to {type:text,text:""},
a blank system prompt promotes to the same — Anthropic 400s with
"cache_control cannot be set for empty text blocks" (distinct from the
below-minimum-length case, which is a documented silent no-op). Guard
both injection sites with is_empty_text_block.

Also closes audit coverage gaps: bridge-level streaming injection test
(chat_stream had zero coverage — handler-families lockstep), an
enabled:false gate test (present-but-disabled was untested), and a
multi-block final message test (only-last-block-marked was asserted only
against single-block messages).
@moonming
moonming force-pushed the feat/anthropic-cache-injection branch from 22e3b3b to a764fb8 Compare July 23, 2026 09:15
@moonming

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming

Copy link
Copy Markdown
Collaborator Author

Merging: full CI green (build, e2e, rust unit, coverage ≥90%, lint, schema drift, generate-openapi). The independent 6-angle cold audit (see the triage above) is the substantive review gate here and is fully satisfied — it caught and fixed a HIGH (cache_control on an empty text block → Anthropic 400). CodeRabbit could not run (plan rate-limited; its check is a false-green, not a review), so it is not standing in as the review — the cold audit is.

@moonming
moonming merged commit 9166cf6 into main Jul 23, 2026
12 checks passed
@moonming
moonming deleted the feat/anthropic-cache-injection branch July 23, 2026 09:24
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