package anthropic import ( "testing" anth "github.com/liushuangls/go-anthropic/v2" "gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider" ) // TestBuildRequest_MultiSystemUsedWhenSystemPresent verifies that after the // refactor, the Anthropic provider uses MultiSystem (multi-part) rather than // the flat System string when a system message is present. This is // behavior-preserving — the upstream client's MarshalJSON prefers // MultiSystem when both are set. func TestBuildRequest_MultiSystemUsedWhenSystemPresent(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: "hello"}, }, } anthReq := p.buildRequest(req) if len(anthReq.MultiSystem) != 1 { t.Fatalf("expected 1 MultiSystem part, got %d", len(anthReq.MultiSystem)) } if anthReq.MultiSystem[0].Text != "you are helpful" { t.Errorf("expected MultiSystem text 'you are helpful', got %q", anthReq.MultiSystem[0].Text) } if anthReq.MultiSystem[0].Type != "text" { t.Errorf("expected MultiSystem type 'text', got %q", anthReq.MultiSystem[0].Type) } if anthReq.System != "" { t.Errorf("expected System string to be empty when MultiSystem is used, got %q", anthReq.System) } } // TestBuildRequest_MultipleSystemMessagesConcatenated verifies that multiple // system messages are joined into a single MultiSystem part (preserving // existing newline-joined behavior from the old code). func TestBuildRequest_MultipleSystemMessagesConcatenated(t *testing.T) { p := New("test-key") req := provider.Request{ Model: "claude-sonnet-4-6", Messages: []provider.Message{ {Role: "system", Content: "part A"}, {Role: "system", Content: "part B"}, {Role: "user", Content: "hello"}, }, } anthReq := p.buildRequest(req) if len(anthReq.MultiSystem) != 1 { t.Fatalf("expected 1 MultiSystem part after concat, got %d", len(anthReq.MultiSystem)) } expected := "part A\npart B" if anthReq.MultiSystem[0].Text != expected { t.Errorf("expected MultiSystem text %q, got %q", expected, anthReq.MultiSystem[0].Text) } } // TestBuildRequest_NoSystemMessage verifies that when there's no system // message, both System and MultiSystem are empty. func TestBuildRequest_NoSystemMessage(t *testing.T) { p := New("test-key") req := provider.Request{ Model: "claude-sonnet-4-6", Messages: []provider.Message{ {Role: "user", Content: "hello"}, }, } anthReq := p.buildRequest(req) if len(anthReq.MultiSystem) != 0 { t.Errorf("expected empty MultiSystem when no system message, got %d parts", len(anthReq.MultiSystem)) } if 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") } }