Skip to content

fix(llm-client): do not add a fifth cache_control block - #489

Open
fedecia wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
fedecia:fix/cache-control-budget
Open

fix(llm-client): do not add a fifth cache_control block#489
fedecia wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
fedecia:fix/cache-control-budget

Conversation

@fedecia

@fedecia fedecia commented Aug 19, 2026

Copy link
Copy Markdown

What

enable_anthropic_prompt_caching counts the cache_control markers already in the
request body and abstains once four are present, instead of always inserting one.

One guard plus a small recursive counter. No behaviour change below the limit.

Why

The function marks the final message content block for every Backend::Anthropic.
Anthropic and Bedrock both cap a request at four blocks carrying cache_control,
counting tools, system blocks and message blocks together. A caller that has
already spent the budget therefore gets a fifth marker and the request fails upstream:

A maximum of 4 blocks with cache_control may be provided. Found 5.

The insert is idempotent per block, so a client that marks its own final block costs
nothing. The failing shape is a client that spends four breakpoints elsewhere — on
tools and system blocks — and leaves the last message block unmarked.

Claude Code does exactly that, on some request shapes and not others, which is why it
presents as flakiness rather than a hard failure. Observed with switchyard/* routes
to Bedrock via a PortKey gateway: 0 of 4 non-streaming calls succeeded before the
change, 31 of 31 after, then 133 calls in one live session with no failure of any
kind. Streaming turns were unaffected throughout, so a session keeps working while
some calls die.

Worth naming, because it is the symptom a user actually reports: Claude Code's
auto mode fails first. Its permission classifier is one of the affected request
shapes, so the tool-approval path breaks while ordinary conversation keeps working,
and the surfaced message is that Claude Code cannot reach a model. The real error is
only in the router log.

Introduced by #233, which enables Anthropic prompt caching by default. Its review
checklist covers the case where the final block is already marked — that case is
handled — but not the total across all blocks.

No issue filed; this PR is the report. Happy to open one if you would rather track it
that way.

How tested

The Python gates do not apply — this is a Rust-only change to crates/libsy-llm-client.

  • cargo fmt --all --check clean
  • cargo clippy -p switchyard-llm-client --all-targets -- -D warnings clean
  • cargo test -p switchyard-llm-client green — 53 tests, including three new
  • Manual smoke: a 4-marker body returns HTTP 200 against the live gateway where it
    previously returned 400; a 5-marker body still returns 400, which is correct
    because the caller, not the client, is over the limit

New tests:

  • anthropic_prompt_caching_abstains_at_the_cache_control_limit — a body at four
    markers is left untouched
  • anthropic_prompt_caching_still_marks_one_below_the_limit — three markers still get
    the fourth, so caching is not silently disabled
  • anthropic_prompt_caching_marks_final_message (existing) — unchanged

Checklist

  • Unit tests added for the bug fix
  • Commits signed off (Signed-off-by) per the DCO
  • One class per file — n/a, Rust
  • New public symbols exported — n/a, both additions are private
  • README / --help updated — n/a, no customer-facing surface change

Notes for reviewers

The limit is a constant here (MAX_CACHE_CONTROL_BLOCKS = 4). It matches what
Anthropic and Bedrock enforce today. If another Anthropic-compatible backend allows a
different number, this wants to come from the backend rather than the crate.

Abstaining is the deliberate choice over evicting. When the budget is spent, a
caller that placed four breakpoints itself needs them more than we need a fifth, and
dropping one of theirs to make room would silently change a caching decision they
made. The cost is that the final message block is not cached on those requests.

The counter walks the whole body. It is O(nodes) on a serialized request and runs
once per call. If that is not acceptable on the hot path, it could count only tools,
system and messages — but the recursive form cannot miss a marker in a shape we
have not anticipated.

Summary by CodeRabbit

  • Bug Fixes
    • Improved prompt caching behavior for Anthropic requests by respecting the four-marker limit.
    • Prevented unnecessary cache markers when the limit has already been reached.
    • Added coverage for requests at the limit and those with one available slot.

enable_anthropic_prompt_caching marks the final message block
unconditionally. Anthropic and Bedrock cap a request at four blocks
carrying cache_control, counting tools, system and message blocks
together, so a caller that already placed four breakpoints gets a fifth
and the request fails upstream with HTTP 400:

  A maximum of 4 blocks with cache_control may be provided. Found 5.

Claude Code hits this: its main turns mark the final block themselves, so
the insert is a no-op and they pass, but the request shapes that leave
that block unmarked while spending all four breakpoints elsewhere are
rejected. Count first and abstain when the budget is spent.

Signed-off-by: Federico Cia <fedecia@gmail.com>
@fedecia
fedecia requested a review from a team as a code owner August 19, 2026 21:53
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 431fbac6-f012-4d65-9214-e3ecb2805a1a

📥 Commits

Reviewing files that changed from the base of the PR and between 2107664 and ee0432a.

📒 Files selected for processing (1)
  • crates/libsy-llm-client/src/client.rs

Included review availability: Your plan provides up to 12 included reviews per hour; 11 remain after this review.


Walkthrough

The client now counts all existing Anthropic cache_control markers. It does not add a marker when four markers exist. Tests cover the full-limit and one-available-slot cases.

Changes

Anthropic prompt caching

Layer / File(s) Summary
Enforce the marker limit and validate behavior
crates/libsy-llm-client/src/client.rs
The client recursively counts cache_control markers and skips changes at the four-marker limit. Tests verify marker preservation and insertion when one slot remains.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to ee043

This localized fix prevents adding a fifth cache-control marker while preserving behavior below the limit; no actionable merge-blocking risk remains after normal checks and review.

Poem

I’m a rabbit guarding the cache,
Four bright markers in a row.
When the slots are full, I pause;
With one spare, a new one grows.
Tests hop neatly beside the code.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing the client from adding a fifth Anthropic cache_control block.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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