feat(v2/anthropic): apply cache_control markers from CacheHints

buildRequest now tracks a source-index → built-message-index mapping
during the role-merge pass, then uses the mapping to attach
cache_control: {type: ephemeral} markers at the positions indicated by
Request.CacheHints. The last tool, the last system part, and the last
non-system message each get a marker when the corresponding hint is set.

Covers the merge-induced index drift that would otherwise cause the
breakpoint to land on the wrong content block when consecutive same-role
source messages are combined into a single Anthropic message with
multiple content blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 19:33:25 +00:00
parent 4c6dfb9058
commit 34b2e29019
2 changed files with 328 additions and 4 deletions

View File

@@ -84,7 +84,16 @@ func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
var msgs []anth.Message
var systemText string
for _, msg := range req.Messages {
// sourceLanding[srcIdx] = (msgIndex, blockIndex) of the LAST content block
// corresponding to that source message, after merging. System-role source
// messages map to {-1, -1} since they don't appear in msgs.
type landing struct{ msg, block int }
sourceLanding := make([]landing, len(req.Messages))
for i := range sourceLanding {
sourceLanding[i] = landing{-1, -1}
}
for srcIdx, msg := range req.Messages {
if msg.Role == "system" {
if len(systemText) > 0 {
systemText += "\n"
@@ -98,7 +107,7 @@ func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
toolUseID := msg.ToolCallID
content := msg.Content
isError := false
msgs = append(msgs, anth.Message{
newMsg := anth.Message{
Role: anth.RoleUser,
Content: []anth.MessageContent{
{
@@ -115,7 +124,14 @@ func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
},
},
},
})
}
// Tool-result messages bypass the role-merge logic — they always
// create a new msgs entry. Preserve that.
msgs = append(msgs, newMsg)
sourceLanding[srcIdx] = landing{
msg: len(msgs) - 1,
block: len(newMsg.Content) - 1,
}
continue
}
@@ -207,11 +223,23 @@ func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
// Audio is not supported by Anthropic — skip silently.
// Merge consecutive same-role messages (Anthropic requires alternating)
// Merge consecutive same-role messages (Anthropic requires alternating).
if len(msgs) > 0 && msgs[len(msgs)-1].Role == role {
// Track the landing BEFORE mutating: the source message lands in
// the existing last msgs entry, and its last block is at the
// current end-of-content plus len(m.Content)-1.
existingEnd := len(msgs[len(msgs)-1].Content)
msgs[len(msgs)-1].Content = append(msgs[len(msgs)-1].Content, m.Content...)
sourceLanding[srcIdx] = landing{
msg: len(msgs) - 1,
block: existingEnd + len(m.Content) - 1,
}
} else {
msgs = append(msgs, m)
sourceLanding[srcIdx] = landing{
msg: len(msgs) - 1,
block: len(m.Content) - 1,
}
}
}
@@ -245,6 +273,34 @@ func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
anthReq.StopSequences = req.Stop
}
// Apply cache_control markers from hints.
if req.CacheHints != nil {
h := req.CacheHints
if h.CacheTools && len(anthReq.Tools) > 0 {
anthReq.Tools[len(anthReq.Tools)-1].CacheControl = &anth.MessageCacheControl{
Type: anth.CacheControlTypeEphemeral,
}
}
if h.CacheSystem && len(anthReq.MultiSystem) > 0 {
anthReq.MultiSystem[len(anthReq.MultiSystem)-1].CacheControl = &anth.MessageCacheControl{
Type: anth.CacheControlTypeEphemeral,
}
}
if h.LastCacheableMessageIndex >= 0 && h.LastCacheableMessageIndex < len(sourceLanding) {
land := sourceLanding[h.LastCacheableMessageIndex]
if land.msg >= 0 && land.msg < len(anthReq.Messages) {
blocks := anthReq.Messages[land.msg].Content
if land.block >= 0 && land.block < len(blocks) {
blocks[land.block].CacheControl = &anth.MessageCacheControl{
Type: anth.CacheControlTypeEphemeral,
}
anthReq.Messages[land.msg].Content = blocks
}
}
}
}
return anthReq
}