fix(bedrock): keep the cache breakpoint when inlining a mid-conversation system reminder - #5929
Conversation
…ion system reminder `role:"system"` inside the messages array is a supported Anthropic API feature (mid-conversation system messages, Opus 4.8+) and Claude Code uses it for `<system-reminder>` turns. Bedrock has no message-level system role, so ConvertBifrostMessagesToBedrockMessages inlines those turns as user messages — but convertBifrostSystemReminderToBedrockUserMessage never copied the block's CacheControl, so the client's third breakpoint was silently dropped: 3 cache_control breakpoints in, 2 cachePoints out. With that anchor gone the cacheable prefix is pinned at the system/tools floor and the whole conversation body is re-read uncached every turn — the exact collapse the surrounding inlineSystemReminders logic exists to prevent. Measured on Bedrock with claude-opus-5, identical warm prefix, third breakpoint on the tail: tail on role:user uncached=2 read=36,115 -> 100.0% hit tail on role:system uncached=18,116 read=18,018 -> 49.9% hit Confirmed at the wire level: the passing arm emits 3 cachePoints (2 in `system`, 1 in `messages`), the failing arm emits 2 and none inside `messages`. TestSystemReminderDoesNotCarryCachePoint pinned the previous omission, whose stated rationale was that a breakpoint at the moving conversation tail shifts every turn and defeats prefix caching. A breakpoint that advances each turn is how incremental conversation caching is meant to work: it extends the cached prefix by one turn for the cost of one write. Dropping it does not fall back to a safe state — it removes the only conversation-level breakpoint. That test is replaced by TestSystemReminderCarriesCachePoint plus the TestInlinedSystemReminder_* contract in cache_points_test.go. Only the last breakpoint within a single reminder is emitted, to stay inside Bedrock's per-request checkpoint budget, and the cachePoint is appended after the text block it terminates per Converse semantics. Reminders carrying no cache_control are unchanged, and the 1h TTL is preserved rather than silently downgraded to 5m.
|
|
📝 WalkthroughSummary by CodeRabbit
WalkthroughChangesBedrock cache-point preservation
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
core/providers/bedrock/responses.go (1)
3919-3942: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winPreserve the cache point position.
If a cache-controlled text block is followed by an unmarked text block, this code retains the earlier
CacheControlbut appends itsCachePointafter the later block. That changes the cache boundary.Insert the single retained cache point immediately after the final cache-controlled wrapped text block. Add a regression case with a cache-controlled text block followed by unmarked text.
Proposed fix
var lastCacheControl *schemas.CacheControl +lastCachePointIndex := -1 ... if block.Text != nil { wrap(*block.Text) if block.CacheControl != nil { lastCacheControl = block.CacheControl + lastCachePointIndex = len(contentBlocks) } } ... if lastCacheControl != nil { - contentBlocks = append(contentBlocks, BedrockContentBlock{ + cachePoint := BedrockContentBlock{ CachePoint: newBedrockCachePoint(lastCacheControl.TTL), - }) + } + contentBlocks = append(contentBlocks, BedrockContentBlock{}) + copy(contentBlocks[lastCachePointIndex+1:], contentBlocks[lastCachePointIndex:]) + contentBlocks[lastCachePointIndex] = cachePoint }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@core/providers/bedrock/responses.go` around lines 3919 - 3942, Update the content-block assembly around wrap and lastCacheControl so the single retained cache point is inserted immediately after the final cache-controlled wrapped text block, rather than appended after later unmarked text. Preserve the existing Bedrock checkpoint budget behavior, and add a regression case covering a cache-controlled text block followed by unmarked text.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@core/providers/bedrock/responses.go`:
- Around line 3919-3942: Update the content-block assembly around wrap and
lastCacheControl so the single retained cache point is inserted immediately
after the final cache-controlled wrapped text block, rather than appended after
later unmarked text. Preserve the existing Bedrock checkpoint budget behavior,
and add a regression case covering a cache-controlled text block followed by
unmarked text.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: e5946f39-2d57-4bbe-9803-8c9f2f938d75
📒 Files selected for processing (3)
core/providers/bedrock/bedrock_test.gocore/providers/bedrock/cache_points_test.gocore/providers/bedrock/responses.go
|
its covered in broader change #5931 |
Summary
A mid-conversation
role:"system"message loses itscache_controlwhen inlined for Bedrock, so the client's conversation-level cache breakpoint never reaches the provider: 3cache_controlbreakpoints in, 2cachePoints out. With that anchor gone the cacheable prefix is pinned at the system/tools floor and the entire conversation body is re-read uncached on every turn.role:"system"insidemessagesis a supported Anthropic API feature (mid-conversation system messages, Opus 4.8+ —SupportsMidConversationSystemreturnsfalsefor Bedrock, correctly). Claude Code uses it for<system-reminder>turns, so this fires on ordinary Claude Code traffic through Bedrock.Reproduction
claude-opus-5on Bedrock via/anthropic/v1/messages. Both arms send identical content and an identical third breakpoint; the only difference is whether the tail breakpoint rides on arole:"user"orrole:"system"turn. Each arm is called twice — once to warm the prefix, once to measure.input_tokenscache_read_input_tokensrole:"user"(control)role:"system"Roughly half the input goes uncached on an otherwise identical, already-warm prefix.
Wire-level, same pair of calls — both received
cache_control× 3:cachePointemittedsystem+ 1 inmessagessystem, none inmessagesAlso reproduces via
/openai/v1/responseswithclaude-sonnet-5(100.0% → 66.5%), so it is in the Bedrock converter rather than one inbound translation layer.The reproduction is self-contained: any Claude-Code-shaped payload with two
systembreakpoints plus a third on a trailingrole:"system"turn shows it, and the arms differ by ~18K cached tokens — far outside noise. Sampling large real requests and inspecting the emitted Converse body separated cleanly on the same marker: affected requests emit 2cachePoints and end in arole:systemitem, unaffected ones emit 3.A representative sequence of three consecutive requests over one growing conversation, seconds apart, same model and key:
Request 2 reads 8% of a prefix that was warm one second earlier and writes nothing — not a cold start, not TTL expiry, not a routing change. Its only distinguishing feature is a trailing
role:systemitem carrying the breakpoint.On the previous behavior
TestSystemReminderDoesNotCarryCachePointpinned the omission deliberately, reasoning that a breakpoint at the moving conversation tail shifts every turn and defeats prefix caching. I think the premise is right and the conclusion inverted:inlineSystemRemindersexists to prevent.The rationale would hold if the reminder were appended after the last breakpoint. Here the reminder carries the breakpoint. That test is replaced by
TestSystemReminderCarriesCachePoint; happy to revisit if there's a case I'm not seeing.Changes
convertBifrostSystemReminderToBedrockUserMessagecarries the block'sCacheControlthrough and appends acachePointafter the text block it terminates (Converse semantics).cache_controlare unchanged — no inventedcachePoint.1hTTL is preserved rather than silently downgraded to 5m.Tests
Six cases in
cache_points_test.gocovering the contract: breakpoint preserved, ordering after text, user-role control unchanged, multiple breakpoints collapse to one, absentcache_controlstays absent, 1h TTL preserved. Verified to fail against unpatcheddev(3 of 6) and pass with the change. Fullcore/providers/...andcore/schemas/...suites pass;gofmtandgo vetclean.