ToolCall.Name and .Arguments are json:"-" (runtime-only), so after chat
history round-trips through the session store only ToolCall.Function
survives. The anthropic-messages and anthropic (SDK) providers emitted
tool_use blocks from tc.Name alone, silently skipping every historical
tool call while still emitting the matching tool_result — orphaned
tool_results 400 at the API ("unexpected tool_use_id found in
tool_result blocks"), killing every turn on agents with tool history.
Fall back to Function.Name / json-parsed Function.Arguments (the same
pattern the bedrock and openai_compat providers already use), and cover
the deserialized-history shape with table tests in both providers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
449 lines
14 KiB
Go
449 lines
14 KiB
Go
package anthropicprovider
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/anthropics/anthropic-sdk-go"
|
|
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
|
|
)
|
|
|
|
func TestBuildParams_BasicMessage(t *testing.T) {
|
|
messages := []Message{
|
|
{Role: "user", Content: "Hello"},
|
|
}
|
|
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{
|
|
"max_tokens": 1024,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildParams() error: %v", err)
|
|
}
|
|
if params.Model != "claude-sonnet-4-6" {
|
|
t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-6")
|
|
}
|
|
if params.MaxTokens != 1024 {
|
|
t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
|
|
}
|
|
if len(params.Messages) != 1 {
|
|
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
|
}
|
|
}
|
|
|
|
func TestBuildParams_SystemMessage(t *testing.T) {
|
|
messages := []Message{
|
|
{Role: "system", Content: "You are helpful"},
|
|
{Role: "user", Content: "Hi"},
|
|
}
|
|
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("buildParams() error: %v", err)
|
|
}
|
|
if len(params.System) != 1 {
|
|
t.Fatalf("len(System) = %d, want 1", len(params.System))
|
|
}
|
|
if params.System[0].Text != "You are helpful" {
|
|
t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
|
|
}
|
|
if len(params.Messages) != 1 {
|
|
t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
|
|
}
|
|
}
|
|
|
|
func TestBuildParams_ToolCallMessage(t *testing.T) {
|
|
messages := []Message{
|
|
{Role: "user", Content: "What's the weather?"},
|
|
{
|
|
Role: "assistant",
|
|
Content: "",
|
|
ToolCalls: []ToolCall{
|
|
{
|
|
ID: "call_1",
|
|
Name: "get_weather",
|
|
Arguments: map[string]any{"city": "SF"},
|
|
},
|
|
},
|
|
},
|
|
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
|
|
}
|
|
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("buildParams() error: %v", err)
|
|
}
|
|
if len(params.Messages) != 3 {
|
|
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
|
}
|
|
}
|
|
|
|
// TestBuildParams_ToolCallFunctionFallback verifies that tool calls whose
|
|
// runtime-only fields were lost in a JSON round-trip through the session store
|
|
// (ToolCall.Name/Arguments are json:"-"; only ToolCall.Function survives) fall
|
|
// back to Function.Name / Function.Arguments, so the tool_use block is still
|
|
// emitted and its tool_result pair stays intact. Without the fallback the
|
|
// tool_use is skipped and the orphaned tool_result 400s at the API
|
|
// ("unexpected tool_use_id found in tool_result blocks").
|
|
func TestBuildParams_ToolCallFunctionFallback(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
toolCall ToolCall
|
|
wantSkipped bool
|
|
wantToolName string
|
|
wantInput map[string]any
|
|
}{
|
|
{
|
|
name: "deserialized history shape falls back to Function fields",
|
|
toolCall: ToolCall{
|
|
ID: "toolu-fallback-1",
|
|
Name: "",
|
|
Arguments: nil,
|
|
Function: &FunctionCall{Name: "x", Arguments: `{"a":1}`},
|
|
},
|
|
wantToolName: "x",
|
|
wantInput: map[string]any{"a": float64(1)},
|
|
},
|
|
{
|
|
name: "runtime shape with Name set and Function nil still works",
|
|
toolCall: ToolCall{
|
|
ID: "toolu-runtime-1",
|
|
Name: "y",
|
|
Arguments: map[string]any{"b": 2},
|
|
},
|
|
wantToolName: "y",
|
|
wantInput: map[string]any{"b": 2},
|
|
},
|
|
{
|
|
name: "both Name and Function.Name empty is skipped",
|
|
toolCall: ToolCall{
|
|
ID: "toolu-empty-1",
|
|
Name: "",
|
|
Function: &FunctionCall{Name: "", Arguments: `{"c":3}`},
|
|
},
|
|
wantSkipped: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
messages := []Message{
|
|
{Role: "user", Content: "run the tool"},
|
|
{Role: "assistant", Content: "", ToolCalls: []ToolCall{tt.toolCall}},
|
|
{Role: "tool", ToolCallID: tt.toolCall.ID, Content: "result"},
|
|
}
|
|
|
|
params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("buildParams() error: %v", err)
|
|
}
|
|
if len(params.Messages) != 3 {
|
|
t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
|
|
}
|
|
|
|
assistantMsg := params.Messages[1]
|
|
var toolUses []*anthropic.ToolUseBlockParam
|
|
for _, block := range assistantMsg.Content {
|
|
if block.OfToolUse != nil {
|
|
toolUses = append(toolUses, block.OfToolUse)
|
|
}
|
|
}
|
|
|
|
// The tool_result in the following user message always carries the
|
|
// original ID; look it up once for both branches.
|
|
toolResultMsg := params.Messages[2]
|
|
if len(toolResultMsg.Content) != 1 || toolResultMsg.Content[0].OfToolResult == nil {
|
|
t.Fatalf("message after assistant = %+v, want single tool_result block", toolResultMsg.Content)
|
|
}
|
|
toolResult := toolResultMsg.Content[0].OfToolResult
|
|
|
|
if tt.wantSkipped {
|
|
if len(toolUses) != 0 {
|
|
t.Fatalf("tool_use blocks = %d, want 0 (tool call skipped)", len(toolUses))
|
|
}
|
|
// Note: matching current behavior, the orphaned tool_result is
|
|
// still emitted even though its tool_use block was skipped.
|
|
if toolResult.ToolUseID != tt.toolCall.ID {
|
|
t.Fatalf("orphaned tool_result ToolUseID = %q, want %q",
|
|
toolResult.ToolUseID, tt.toolCall.ID)
|
|
}
|
|
return
|
|
}
|
|
|
|
// (a) tool_use block emitted with resolved name, id, and parsed input.
|
|
if len(toolUses) != 1 {
|
|
t.Fatalf("tool_use blocks = %d, want 1", len(toolUses))
|
|
}
|
|
toolUse := toolUses[0]
|
|
if toolUse.Name != tt.wantToolName {
|
|
t.Errorf("tool_use Name = %q, want %q", toolUse.Name, tt.wantToolName)
|
|
}
|
|
if toolUse.ID != tt.toolCall.ID {
|
|
t.Errorf("tool_use ID = %q, want %q", toolUse.ID, tt.toolCall.ID)
|
|
}
|
|
gotInput, ok := toolUse.Input.(map[string]any)
|
|
if !ok || !reflect.DeepEqual(gotInput, tt.wantInput) {
|
|
t.Errorf("tool_use Input = %#v, want %#v", toolUse.Input, tt.wantInput)
|
|
}
|
|
|
|
// (b) the following user message's tool_result references the same
|
|
// id as the tool_use block — the pair is intact.
|
|
if toolResult.ToolUseID != toolUse.ID {
|
|
t.Errorf("tool_result ToolUseID = %q, want %q (paired with tool_use)",
|
|
toolResult.ToolUseID, toolUse.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildParams_WithTools(t *testing.T) {
|
|
tools := []ToolDefinition{
|
|
{
|
|
Type: "function",
|
|
Function: ToolFunctionDefinition{
|
|
Name: "get_weather",
|
|
Description: "Get weather for a city",
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"city": map[string]any{"type": "string"},
|
|
},
|
|
"required": []any{"city"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("buildParams() error: %v", err)
|
|
}
|
|
if len(params.Tools) != 1 {
|
|
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
|
|
}
|
|
}
|
|
|
|
func TestParseResponse_TextOnly(t *testing.T) {
|
|
resp := &anthropic.Message{
|
|
Content: []anthropic.ContentBlockUnion{},
|
|
Usage: anthropic.Usage{
|
|
InputTokens: 10,
|
|
OutputTokens: 20,
|
|
},
|
|
}
|
|
result := parseResponse(resp)
|
|
if result.Usage.PromptTokens != 10 {
|
|
t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
|
|
}
|
|
if result.Usage.CompletionTokens != 20 {
|
|
t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
|
|
}
|
|
if result.FinishReason != "stop" {
|
|
t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
|
|
}
|
|
}
|
|
|
|
func TestParseResponse_StopReasons(t *testing.T) {
|
|
tests := []struct {
|
|
stopReason anthropic.StopReason
|
|
want string
|
|
}{
|
|
{anthropic.StopReasonEndTurn, "stop"},
|
|
{anthropic.StopReasonMaxTokens, "length"},
|
|
{anthropic.StopReasonToolUse, "tool_calls"},
|
|
}
|
|
for _, tt := range tests {
|
|
resp := &anthropic.Message{
|
|
StopReason: tt.stopReason,
|
|
}
|
|
result := parseResponse(resp)
|
|
if result.FinishReason != tt.want {
|
|
t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProvider_ChatRoundTrip(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/messages" {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer test-token" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var reqBody map[string]any
|
|
json.NewDecoder(r.Body).Decode(&reqBody)
|
|
|
|
resp := map[string]any{
|
|
"id": "msg_test",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": reqBody["model"],
|
|
"stop_reason": "end_turn",
|
|
"content": []map[string]any{
|
|
{"type": "text", "text": "Hello! How can I help you?"},
|
|
},
|
|
"usage": map[string]any{
|
|
"input_tokens": 15,
|
|
"output_tokens": 8,
|
|
},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
|
|
messages := []Message{{Role: "user", Content: "Hello"}}
|
|
resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]any{"max_tokens": 1024})
|
|
if err != nil {
|
|
t.Fatalf("Chat() error: %v", err)
|
|
}
|
|
if resp.Content != "Hello! How can I help you?" {
|
|
t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?")
|
|
}
|
|
if resp.FinishReason != "stop" {
|
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
}
|
|
if resp.Usage.PromptTokens != 15 {
|
|
t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens)
|
|
}
|
|
}
|
|
|
|
func TestProvider_GetDefaultModel(t *testing.T) {
|
|
p := NewProvider("test-token")
|
|
if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" {
|
|
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6")
|
|
}
|
|
}
|
|
|
|
func TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) {
|
|
p := NewProviderWithBaseURL("token", "https://api.anthropic.com/v1/")
|
|
if got := p.BaseURL(); got != "https://api.anthropic.com" {
|
|
t.Fatalf("BaseURL() = %q, want %q", got, "https://api.anthropic.com")
|
|
}
|
|
}
|
|
|
|
func TestProvider_ChatUsesTokenSource(t *testing.T) {
|
|
var requests int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/messages" {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
atomic.AddInt32(&requests, 1)
|
|
|
|
if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var reqBody map[string]any
|
|
json.NewDecoder(r.Body).Decode(&reqBody)
|
|
|
|
resp := map[string]any{
|
|
"id": "msg_test",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": reqBody["model"],
|
|
"stop_reason": "end_turn",
|
|
"content": []map[string]any{
|
|
{"type": "text", "text": "ok"},
|
|
},
|
|
"usage": map[string]any{
|
|
"input_tokens": 1,
|
|
"output_tokens": 1,
|
|
},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) {
|
|
return "refreshed-token", nil
|
|
}, server.URL)
|
|
|
|
_, err := p.Chat(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "hello"}},
|
|
nil,
|
|
"claude-sonnet-4.6",
|
|
map[string]any{},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&requests); got != 1 {
|
|
t.Fatalf("requests = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestProvider_ChatStreamingRoundTrip(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/messages" {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" {
|
|
t.Errorf("Authorization = %q, want %q", got, "Bearer refreshed-token")
|
|
}
|
|
if got := r.Header.Get("Anthropic-Beta"); got != anthropicBetaHeader {
|
|
t.Errorf("Anthropic-Beta = %q, want %q", got, anthropicBetaHeader)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
flusher, _ := w.(http.Flusher)
|
|
|
|
events := []string{
|
|
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-sonnet-4-6\",\"stop_reason\":null,\"usage\":{\"input_tokens\":12,\"output_tokens\":0}}}\n\n",
|
|
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
|
|
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n",
|
|
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n",
|
|
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
|
|
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n",
|
|
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
|
|
}
|
|
for _, e := range events {
|
|
w.Write([]byte(e))
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) {
|
|
return "refreshed-token", nil
|
|
}, server.URL)
|
|
|
|
resp, err := p.Chat(
|
|
t.Context(),
|
|
[]Message{{Role: "user", Content: "Hello"}},
|
|
nil,
|
|
"claude-sonnet-4.6",
|
|
map[string]any{},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Chat() error: %v", err)
|
|
}
|
|
if resp.Content != "Hello world" {
|
|
t.Errorf("Content = %q, want %q", resp.Content, "Hello world")
|
|
}
|
|
if resp.FinishReason != "stop" {
|
|
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
|
}
|
|
if resp.Usage.CompletionTokens != 5 {
|
|
t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
|
|
}
|
|
}
|
|
|
|
func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
|
|
c := anthropic.NewClient(
|
|
anthropicoption.WithAuthToken(token),
|
|
anthropicoption.WithBaseURL(baseURL),
|
|
)
|
|
return &c
|
|
}
|