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 msgs []anth.Message
var systemText string 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 msg.Role == "system" {
if len(systemText) > 0 { if len(systemText) > 0 {
systemText += "\n" systemText += "\n"
@@ -98,7 +107,7 @@ func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
toolUseID := msg.ToolCallID toolUseID := msg.ToolCallID
content := msg.Content content := msg.Content
isError := false isError := false
msgs = append(msgs, anth.Message{ newMsg := anth.Message{
Role: anth.RoleUser, Role: anth.RoleUser,
Content: []anth.MessageContent{ 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 continue
} }
@@ -207,11 +223,23 @@ func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
// Audio is not supported by Anthropic — skip silently. // 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 { 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...) 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 { } else {
msgs = append(msgs, m) 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 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 return anthReq
} }

View File

@@ -3,6 +3,8 @@ package anthropic
import ( import (
"testing" "testing"
anth "github.com/liushuangls/go-anthropic/v2"
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider" "gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
) )
@@ -79,3 +81,269 @@ func TestBuildRequest_NoSystemMessage(t *testing.T) {
t.Errorf("expected empty System string, got %q", anthReq.System) t.Errorf("expected empty System string, got %q", anthReq.System)
} }
} }
// TestBuildRequest_CacheHints_Tools verifies that the last tool definition
// gets a cache_control marker when CacheHints.CacheTools is set.
func TestBuildRequest_CacheHints_Tools(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Tools: []provider.ToolDef{
{Name: "a", Description: "tool a", Schema: map[string]any{}},
{Name: "b", Description: "tool b", Schema: map[string]any{}},
},
Messages: []provider.Message{{Role: "user", Content: "hi"}},
CacheHints: &provider.CacheHints{
CacheTools: true,
LastCacheableMessageIndex: -1,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.Tools) != 2 {
t.Fatalf("expected 2 tools, got %d", len(anthReq.Tools))
}
if anthReq.Tools[0].CacheControl != nil {
t.Error("expected first tool to have no CacheControl")
}
if anthReq.Tools[1].CacheControl == nil {
t.Fatal("expected last tool to have CacheControl")
}
if anthReq.Tools[1].CacheControl.Type != anth.CacheControlTypeEphemeral {
t.Errorf("expected last tool CacheControl type ephemeral, got %q", anthReq.Tools[1].CacheControl.Type)
}
}
// TestBuildRequest_CacheHints_System verifies that the final system part
// gets a cache_control marker when CacheHints.CacheSystem is set.
func TestBuildRequest_CacheHints_System(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "system", Content: "you are helpful"},
{Role: "user", Content: "hi"},
},
CacheHints: &provider.CacheHints{
CacheSystem: true,
LastCacheableMessageIndex: -1,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.MultiSystem) != 1 {
t.Fatalf("expected 1 MultiSystem part, got %d", len(anthReq.MultiSystem))
}
if anthReq.MultiSystem[0].CacheControl == nil {
t.Fatal("expected system part to have CacheControl")
}
if anthReq.MultiSystem[0].CacheControl.Type != anth.CacheControlTypeEphemeral {
t.Errorf("expected system CacheControl type ephemeral, got %q", anthReq.MultiSystem[0].CacheControl.Type)
}
}
// TestBuildRequest_CacheHints_LastMessage verifies that the last content
// block of the target message receives a cache_control marker.
func TestBuildRequest_CacheHints_LastMessage(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "first turn"},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "second turn"},
},
CacheHints: &provider.CacheHints{
LastCacheableMessageIndex: 2,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.Messages) != 3 {
t.Fatalf("expected 3 messages, got %d", len(anthReq.Messages))
}
last := anthReq.Messages[2]
if len(last.Content) != 1 {
t.Fatalf("expected 1 content block on last message, got %d", len(last.Content))
}
if last.Content[0].CacheControl == nil {
t.Fatal("expected CacheControl on last content block of last message")
}
if anthReq.Messages[0].Content[0].CacheControl != nil {
t.Error("expected no CacheControl on first message")
}
}
// TestBuildRequest_CacheHints_IndexDriftFromMerge verifies that when the
// provider merges consecutive same-role messages, the cache breakpoint
// lands on the correct merged output message.
//
// Scenario: source [user:"a", user:"b", assistant:"c", user:"d"].
// After merging: msgs[0]=user[a,b], msgs[1]=assistant[c], msgs[2]=user[d].
// With LastCacheableMessageIndex=3 (source idx of "d"), marker should land on
// msgs[2].Content[0].
func TestBuildRequest_CacheHints_IndexDriftFromMerge(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "a"},
{Role: "user", Content: "b"},
{Role: "assistant", Content: "c"},
{Role: "user", Content: "d"},
},
CacheHints: &provider.CacheHints{
LastCacheableMessageIndex: 3,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.Messages) != 3 {
t.Fatalf("expected 3 merged messages, got %d", len(anthReq.Messages))
}
if len(anthReq.Messages[2].Content) != 1 {
t.Fatalf("expected 1 content block on merged user, got %d", len(anthReq.Messages[2].Content))
}
if anthReq.Messages[2].Content[0].CacheControl == nil {
t.Fatal("expected CacheControl on last content block of merged user message")
}
for i, cb := range anthReq.Messages[0].Content {
if cb.CacheControl != nil {
t.Errorf("expected no CacheControl on anthReq.Messages[0].Content[%d]", i)
}
}
}
// TestBuildRequest_CacheHints_IndexDriftTargetIsMerged: scenario where
// LastCacheableMessageIndex points into a message that was merged.
// Source [user:"a", user:"b"] → msgs[0]=user[a,b]. Index 1 should land on
// msgs[0].Content[1] ("b").
func TestBuildRequest_CacheHints_IndexDriftTargetIsMerged(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "a"},
{Role: "user", Content: "b"},
},
CacheHints: &provider.CacheHints{
LastCacheableMessageIndex: 1,
},
}
anthReq := p.buildRequest(req)
if len(anthReq.Messages) != 1 {
t.Fatalf("expected 1 merged message, got %d", len(anthReq.Messages))
}
if len(anthReq.Messages[0].Content) != 2 {
t.Fatalf("expected 2 content blocks after merge, got %d", len(anthReq.Messages[0].Content))
}
if anthReq.Messages[0].Content[0].CacheControl != nil {
t.Error("expected no CacheControl on first content block (source index 0)")
}
if anthReq.Messages[0].Content[1].CacheControl == nil {
t.Fatal("expected CacheControl on second content block (source index 1)")
}
}
// TestBuildRequest_CacheHints_AllThree: tools + system + last-message all
// receive markers in a single request.
func TestBuildRequest_CacheHints_AllThree(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Tools: []provider.ToolDef{
{Name: "t1", Description: "tool", Schema: map[string]any{}},
},
Messages: []provider.Message{
{Role: "system", Content: "be helpful"},
{Role: "user", Content: "hi"},
},
CacheHints: &provider.CacheHints{
CacheTools: true,
CacheSystem: true,
LastCacheableMessageIndex: 1,
},
}
anthReq := p.buildRequest(req)
if anthReq.Tools[0].CacheControl == nil {
t.Error("expected tool CacheControl")
}
if anthReq.MultiSystem[0].CacheControl == nil {
t.Error("expected system CacheControl")
}
if len(anthReq.Messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(anthReq.Messages))
}
if anthReq.Messages[0].Content[0].CacheControl == nil {
t.Error("expected user-message CacheControl")
}
}
// TestBuildRequest_CacheHints_Nil: nil CacheHints → no markers.
func TestBuildRequest_CacheHints_Nil(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Tools: []provider.ToolDef{
{Name: "t", Description: "d", Schema: map[string]any{}},
},
Messages: []provider.Message{
{Role: "system", Content: "s"},
{Role: "user", Content: "hi"},
},
}
anthReq := p.buildRequest(req)
if anthReq.Tools[0].CacheControl != nil {
t.Error("expected no tool CacheControl with nil hints")
}
if len(anthReq.MultiSystem) == 1 && anthReq.MultiSystem[0].CacheControl != nil {
t.Error("expected no system CacheControl with nil hints")
}
for i, m := range anthReq.Messages {
for j, cb := range m.Content {
if cb.CacheControl != nil {
t.Errorf("expected no CacheControl on anthReq.Messages[%d].Content[%d]", i, j)
}
}
}
}
// TestBuildRequest_CacheHints_ToolResultMessage: marker lands on the right
// tool-result message.
func TestBuildRequest_CacheHints_ToolResultMessage(t *testing.T) {
p := New("test-key")
req := provider.Request{
Model: "claude-sonnet-4-6",
Messages: []provider.Message{
{Role: "user", Content: "use the tool"},
{Role: "assistant", Content: "", ToolCalls: []provider.ToolCall{
{ID: "tc1", Name: "x", Arguments: "{}"},
}},
{Role: "tool", Content: "result data", ToolCallID: "tc1"},
},
CacheHints: &provider.CacheHints{
LastCacheableMessageIndex: 2,
},
}
anthReq := p.buildRequest(req)
var toolMsg *anth.Message
for i := range anthReq.Messages {
for _, cb := range anthReq.Messages[i].Content {
if cb.MessageContentToolResult != nil {
toolMsg = &anthReq.Messages[i]
break
}
}
}
if toolMsg == nil {
t.Fatal("expected to find tool-result message")
}
lastBlock := toolMsg.Content[len(toolMsg.Content)-1]
if lastBlock.CacheControl == nil {
t.Error("expected CacheControl on last content block of tool-result message")
}
}