Skip to content

Commit a3bf895

Browse files
Fix Anthropic tool validation edge cases
1 parent 5dcdaca commit a3bf895

8 files changed

Lines changed: 269 additions & 27 deletions

File tree

internal/core/types.go

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package core
22

3-
import "encoding/json"
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
)
47

58
// StreamOptions controls streaming behavior options.
69
// This is used to request usage data in streaming responses.
@@ -53,20 +56,21 @@ func (r *ChatRequest) WithStreaming() *ChatRequest {
5356

5457
// Message represents a single message in the chat
5558
type Message struct {
56-
Role string `json:"role"`
57-
Content string `json:"content"`
58-
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
59-
ToolCallID string `json:"tool_call_id,omitempty"`
59+
Role string `json:"role"`
60+
Content string `json:"content"`
61+
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
62+
ToolCallID string `json:"tool_call_id,omitempty"`
63+
ContentNull bool `json:"-"`
6064
}
6165

6266
// UnmarshalJSON accepts content as string or null for compatibility with
6367
// tool-calling responses that omit assistant text.
6468
func (m *Message) UnmarshalJSON(data []byte) error {
6569
type rawMessage struct {
66-
Role string `json:"role"`
67-
Content *string `json:"content"`
68-
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
69-
ToolCallID string `json:"tool_call_id,omitempty"`
70+
Role string `json:"role"`
71+
Content json.RawMessage `json:"content"`
72+
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
73+
ToolCallID string `json:"tool_call_id,omitempty"`
7074
}
7175

7276
var raw rawMessage
@@ -75,17 +79,46 @@ func (m *Message) UnmarshalJSON(data []byte) error {
7579
}
7680

7781
m.Role = raw.Role
78-
if raw.Content != nil {
79-
m.Content = *raw.Content
80-
} else {
82+
m.ContentNull = false
83+
switch trimmed := bytes.TrimSpace(raw.Content); {
84+
case len(trimmed) == 0:
8185
m.Content = ""
86+
case bytes.Equal(trimmed, []byte("null")):
87+
m.Content = ""
88+
m.ContentNull = true
89+
default:
90+
if err := json.Unmarshal(trimmed, &m.Content); err != nil {
91+
return err
92+
}
8293
}
8394
m.ToolCalls = raw.ToolCalls
8495
m.ToolCallID = raw.ToolCallID
8596

8697
return nil
8798
}
8899

100+
// MarshalJSON preserves explicit null content for tool-calling assistant messages.
101+
func (m Message) MarshalJSON() ([]byte, error) {
102+
type rawMessage struct {
103+
Role string `json:"role"`
104+
Content any `json:"content"`
105+
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
106+
ToolCallID string `json:"tool_call_id,omitempty"`
107+
}
108+
109+
content := any(m.Content)
110+
if m.ContentNull {
111+
content = nil
112+
}
113+
114+
return json.Marshal(rawMessage{
115+
Role: m.Role,
116+
Content: content,
117+
ToolCalls: m.ToolCalls,
118+
ToolCallID: m.ToolCallID,
119+
})
120+
}
121+
89122
// ToolCall represents a single tool invocation emitted by a model.
90123
type ToolCall struct {
91124
ID string `json:"id"`

internal/core/types_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ func TestMessageUnmarshalJSON_AllowsNullContent(t *testing.T) {
2626
if msg.Content != "" {
2727
t.Fatalf("Content = %q, want empty string", msg.Content)
2828
}
29+
if !msg.ContentNull {
30+
t.Fatal("ContentNull = false, want true")
31+
}
2932
if len(msg.ToolCalls) != 1 {
3033
t.Fatalf("len(ToolCalls) = %d, want 1", len(msg.ToolCalls))
3134
}
@@ -45,6 +48,33 @@ func TestMessageUnmarshalJSON_PreservesStringContent(t *testing.T) {
4548
if msg.Content != "hello" {
4649
t.Fatalf("Content = %q, want hello", msg.Content)
4750
}
51+
if msg.ContentNull {
52+
t.Fatal("ContentNull = true, want false")
53+
}
54+
}
55+
56+
func TestMessageMarshalJSON_PreservesNullContent(t *testing.T) {
57+
payload, err := json.Marshal(Message{
58+
Role: "assistant",
59+
ContentNull: true,
60+
ToolCalls: []ToolCall{
61+
{
62+
ID: "call_123",
63+
Type: "function",
64+
Function: FunctionCall{
65+
Name: "lookup_weather",
66+
Arguments: `{"city":"Warsaw"}`,
67+
},
68+
},
69+
},
70+
})
71+
if err != nil {
72+
t.Fatalf("json.Marshal() error = %v, want nil", err)
73+
}
74+
75+
if string(payload) != `{"role":"assistant","content":null,"tool_calls":[{"id":"call_123","type":"function","function":{"name":"lookup_weather","arguments":"{\"city\":\"Warsaw\"}"}}]}` {
76+
t.Fatalf("json.Marshal() = %s", payload)
77+
}
4878
}
4979

5080
func TestChatRequestWithStreaming_PreservesToolFields(t *testing.T) {

internal/providers/anthropic/anthropic.go

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"bytes"
77
"context"
88
"encoding/json"
9+
"errors"
910
"fmt"
1011
"io"
1112
"log/slog"
@@ -489,6 +490,9 @@ func parseToolCallArguments(arguments string) (any, error) {
489490
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
490491
return nil, err
491492
}
493+
if _, ok := parsed.(map[string]any); !ok {
494+
return nil, fmt.Errorf("tool arguments must be a JSON object")
495+
}
492496
return parsed, nil
493497
}
494498

@@ -557,6 +561,8 @@ func convertToAnthropicRequest(req *core.ChatRequest) (*anthropicRequest, error)
557561
anthropicReq.Tools = tools
558562
if toolChoice, disableTools, err := convertOpenAIToolChoiceToAnthropic(req.ToolChoice); err != nil {
559563
return nil, err
564+
} else if err := validateAnthropicToolChoice(toolChoice, anthropicReq.Tools, disableTools); err != nil {
565+
return nil, err
560566
} else if disableTools {
561567
anthropicReq.Tools = nil
562568
} else if len(anthropicReq.Tools) > 0 {
@@ -611,19 +617,24 @@ func convertFromAnthropicResponse(resp *anthropicResponse) *core.ChatResponse {
611617
usage.RawUsage = rawUsage
612618
}
613619

620+
message := core.Message{
621+
Role: "assistant",
622+
Content: content,
623+
ToolCalls: toolCalls,
624+
}
625+
if content == "" && len(toolCalls) > 0 {
626+
message.ContentNull = true
627+
}
628+
614629
return &core.ChatResponse{
615630
ID: resp.ID,
616631
Object: "chat.completion",
617632
Model: resp.Model,
618633
Created: time.Now().Unix(),
619634
Choices: []core.Choice{
620635
{
621-
Index: 0,
622-
Message: core.Message{
623-
Role: "assistant",
624-
Content: content,
625-
ToolCalls: toolCalls,
626-
},
636+
Index: 0,
637+
Message: message,
627638
FinishReason: finishReason,
628639
},
629640
},
@@ -1002,6 +1013,8 @@ func convertResponsesRequestToAnthropic(req *core.ResponsesRequest) (*anthropicR
10021013
anthropicReq.Tools = tools
10031014
if toolChoice, disableTools, err := convertOpenAIToolChoiceToAnthropic(chatReq.ToolChoice); err != nil {
10041015
return nil, err
1016+
} else if err := validateAnthropicToolChoice(toolChoice, anthropicReq.Tools, disableTools); err != nil {
1017+
return nil, err
10051018
} else if disableTools {
10061019
anthropicReq.Tools = nil
10071020
} else if len(anthropicReq.Tools) > 0 {
@@ -1039,7 +1052,28 @@ func normalizeAnthropicRequestError(err error) error {
10391052
if gatewayErr, ok := err.(*core.GatewayError); ok {
10401053
return gatewayErr
10411054
}
1042-
return core.NewInvalidRequestError("invalid tool_call.function.arguments JSON", err)
1055+
message := "invalid tool_call.function.arguments JSON"
1056+
if err != nil && strings.TrimSpace(err.Error()) != "" {
1057+
message = err.Error()
1058+
}
1059+
return core.NewInvalidRequestError(message, err)
1060+
}
1061+
1062+
func validateAnthropicToolChoice(toolChoice *anthropicToolChoice, tools []anthropicTool, disableTools bool) error {
1063+
if disableTools || toolChoice == nil || len(tools) > 0 {
1064+
return nil
1065+
}
1066+
return core.NewInvalidRequestError("tool_choice requires at least one tool", nil)
1067+
}
1068+
1069+
func prefixAnthropicBatchItemError(index int, err error) error {
1070+
var gatewayErr *core.GatewayError
1071+
if errors.As(err, &gatewayErr) {
1072+
prefixed := *gatewayErr
1073+
prefixed.Message = fmt.Sprintf("batch item %d: %s", index, gatewayErr.Message)
1074+
return &prefixed
1075+
}
1076+
return core.NewInvalidRequestError(fmt.Sprintf("batch item %d: %v", index, err), err)
10431077
}
10441078

10451079
// extractTextContent returns the text from the last "text" content block.
@@ -1281,7 +1315,7 @@ func buildAnthropicBatchCreateRequest(req *core.BatchRequest) (*anthropicBatchCr
12811315
var err error
12821316
params, err = convertToAnthropicRequest(&chatReq)
12831317
if err != nil {
1284-
return nil, nil, core.NewInvalidRequestError(fmt.Sprintf("batch item %d: invalid tool_call.function.arguments JSON", i), err)
1318+
return nil, nil, prefixAnthropicBatchItemError(i, err)
12851319
}
12861320
params.Stream = false
12871321
case "/v1/responses":
@@ -1295,7 +1329,7 @@ func buildAnthropicBatchCreateRequest(req *core.BatchRequest) (*anthropicBatchCr
12951329
var err error
12961330
params, err = convertResponsesRequestToAnthropic(&respReq)
12971331
if err != nil {
1298-
return nil, nil, core.NewInvalidRequestError(fmt.Sprintf("batch item %d: invalid tool_call.function.arguments JSON", i), err)
1332+
return nil, nil, prefixAnthropicBatchItemError(i, err)
12991333
}
13001334
params.Stream = false
13011335
case "/v1/embeddings":

internal/providers/anthropic/anthropic_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,9 @@ func TestConvertToAnthropicRequest_InvalidToolChoice(t *testing.T) {
936936
if gatewayErr.Type != core.ErrorTypeInvalidRequest {
937937
t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type)
938938
}
939+
if gatewayErr.HTTPStatusCode() != http.StatusBadRequest {
940+
t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest)
941+
}
939942
}
940943

941944
func TestConvertToAnthropicRequest_ToolMessageRequiresToolCallID(t *testing.T) {
@@ -955,6 +958,59 @@ func TestConvertToAnthropicRequest_ToolMessageRequiresToolCallID(t *testing.T) {
955958
if gatewayErr.Type != core.ErrorTypeInvalidRequest {
956959
t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type)
957960
}
961+
if gatewayErr.HTTPStatusCode() != http.StatusBadRequest {
962+
t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest)
963+
}
964+
}
965+
966+
func TestConvertToAnthropicRequest_ToolChoiceRequiresTools(t *testing.T) {
967+
_, err := convertToAnthropicRequest(&core.ChatRequest{
968+
Model: "claude-sonnet-4-5-20250929",
969+
Messages: []core.Message{
970+
{Role: "user", Content: "Hello"},
971+
},
972+
ToolChoice: "auto",
973+
})
974+
if err == nil {
975+
t.Fatal("expected invalid request error, got nil")
976+
}
977+
var gatewayErr *core.GatewayError
978+
if !errors.As(err, &gatewayErr) {
979+
t.Fatalf("error = %T, want *core.GatewayError", err)
980+
}
981+
if gatewayErr.Type != core.ErrorTypeInvalidRequest {
982+
t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type)
983+
}
984+
if gatewayErr.HTTPStatusCode() != http.StatusBadRequest {
985+
t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest)
986+
}
987+
}
988+
989+
func TestConvertToAnthropicRequest_ToolArgumentsMustBeJSONObject(t *testing.T) {
990+
_, err := convertToAnthropicRequest(&core.ChatRequest{
991+
Model: "claude-sonnet-4-5-20250929",
992+
Messages: []core.Message{
993+
{
994+
Role: "assistant",
995+
ToolCalls: []core.ToolCall{
996+
{
997+
ID: "call_123",
998+
Type: "function",
999+
Function: core.FunctionCall{
1000+
Name: "lookup_weather",
1001+
Arguments: `["Warsaw"]`,
1002+
},
1003+
},
1004+
},
1005+
},
1006+
},
1007+
})
1008+
if err == nil {
1009+
t.Fatal("expected invalid request error, got nil")
1010+
}
1011+
if !strings.Contains(err.Error(), "tool arguments must be a JSON object") {
1012+
t.Fatalf("error = %v, want JSON object validation", err)
1013+
}
9581014
}
9591015

9601016
func TestConvertFromAnthropicResponse(t *testing.T) {
@@ -2102,6 +2158,60 @@ func TestConvertResponsesRequestToAnthropic_InvalidToolArguments(t *testing.T) {
21022158
if gatewayErr.Type != core.ErrorTypeInvalidRequest {
21032159
t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type)
21042160
}
2161+
if gatewayErr.HTTPStatusCode() != http.StatusBadRequest {
2162+
t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest)
2163+
}
2164+
}
2165+
2166+
func TestConvertResponsesRequestToAnthropic_ToolChoiceRequiresTools(t *testing.T) {
2167+
_, err := convertResponsesRequestToAnthropic(&core.ResponsesRequest{
2168+
Model: "claude-sonnet-4-5-20250929",
2169+
Input: "Hello",
2170+
ToolChoice: "auto",
2171+
})
2172+
if err == nil {
2173+
t.Fatal("expected invalid request error, got nil")
2174+
}
2175+
var gatewayErr *core.GatewayError
2176+
if !errors.As(err, &gatewayErr) {
2177+
t.Fatalf("error = %T, want *core.GatewayError", err)
2178+
}
2179+
if gatewayErr.Type != core.ErrorTypeInvalidRequest {
2180+
t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type)
2181+
}
2182+
if gatewayErr.HTTPStatusCode() != http.StatusBadRequest {
2183+
t.Fatalf("HTTPStatusCode() = %d, want %d", gatewayErr.HTTPStatusCode(), http.StatusBadRequest)
2184+
}
2185+
}
2186+
2187+
func TestBuildAnthropicBatchCreateRequest_PreservesGatewayErrorDetails(t *testing.T) {
2188+
req := &core.BatchRequest{
2189+
Requests: []core.BatchRequestItem{
2190+
{
2191+
URL: "/v1/chat/completions",
2192+
Body: json.RawMessage(`{
2193+
"model":"claude-sonnet-4-5-20250929",
2194+
"messages":[{"role":"user","content":"Hello"}],
2195+
"tool_choice":"auto"
2196+
}`),
2197+
},
2198+
},
2199+
}
2200+
2201+
_, _, err := buildAnthropicBatchCreateRequest(req)
2202+
if err == nil {
2203+
t.Fatal("expected invalid request error, got nil")
2204+
}
2205+
var gatewayErr *core.GatewayError
2206+
if !errors.As(err, &gatewayErr) {
2207+
t.Fatalf("error = %T, want *core.GatewayError", err)
2208+
}
2209+
if gatewayErr.Type != core.ErrorTypeInvalidRequest {
2210+
t.Fatalf("error type = %q, want invalid_request_error", gatewayErr.Type)
2211+
}
2212+
if gatewayErr.Message != "batch item 0: tool_choice requires at least one tool" {
2213+
t.Fatalf("error message = %q", gatewayErr.Message)
2214+
}
21052215
}
21062216

21072217
func TestConvertAnthropicResponseToResponses(t *testing.T) {

internal/providers/responses_adapter.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ func convertResponsesInputMap(item map[string]interface{}) (core.Message, bool)
143143
if name == "" {
144144
return core.Message{}, false
145145
}
146+
callID = ResponsesFunctionCallCallID(callID)
146147
return core.Message{
147148
Role: "assistant",
148149
ToolCalls: []core.ToolCall{

0 commit comments

Comments
 (0)