Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion internal/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,12 +391,22 @@ func ConvertResponse(openaiResp *models.OpenAIResponse, requestedModel string) (
}

// Handle tool calls (convert to tool_use blocks)
// OpenAI returns Function.Arguments as a JSON-encoded string; Anthropic's
// tool_use.input must be an object. Parse into interface{} so it serializes
// as an object (or {} when missing/unparseable) per Anthropic spec.
for _, toolCall := range choice.Message.ToolCalls {
var input interface{} = map[string]interface{}{}
if args := strings.TrimSpace(toolCall.Function.Arguments); args != "" {
var parsed interface{}
if err := json.Unmarshal([]byte(args), &parsed); err == nil {
input = parsed
}
}
contentBlocks = append(contentBlocks, models.ContentBlock{
Type: "tool_use",
ID: toolCall.ID,
Name: toolCall.Function.Name,
Input: toolCall.Function.Arguments, // OpenAI sends as JSON string
Input: input,
})
}

Expand Down
51 changes: 51 additions & 0 deletions internal/converter/converter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,61 @@ func TestConvertResponse(t *testing.T) {
t.Errorf("Tool name = %q, want %q", claudeResp.Content[0].Name, "get_weather")
}

// Anthropic spec requires tool_use.input to be an object, not a JSON-encoded string.
inputMap, ok := claudeResp.Content[0].Input.(map[string]interface{})
if !ok {
t.Fatalf("Input should be map[string]interface{}, got %T: %v",
claudeResp.Content[0].Input, claudeResp.Content[0].Input)
}
if loc, _ := inputMap["location"].(string); loc != "San Francisco" {
t.Errorf("Input.location = %q, want %q", loc, "San Francisco")
}

if *claudeResp.StopReason != "tool_use" {
t.Errorf("StopReason = %q, want %q", *claudeResp.StopReason, "tool_use")
}
})

t.Run("tool call with empty arguments returns empty object", func(t *testing.T) {
finishReason := "tool_calls"
openaiResp := &models.OpenAIResponse{
ID: "chatcmpl-789",
Choices: []models.OpenAIChoice{
{
Index: 0,
Message: models.OpenAIMessage{
Role: "assistant",
ToolCalls: []models.OpenAIToolCall{
{
ID: "call_empty",
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{
Name: "no_args",
Arguments: "",
},
},
},
},
FinishReason: &finishReason,
},
},
}
claudeResp, err := ConvertResponse(openaiResp, "claude-sonnet-4-6")
if err != nil {
t.Fatalf("ConvertResponse() error = %v", err)
}
inputMap, ok := claudeResp.Content[0].Input.(map[string]interface{})
if !ok {
t.Fatalf("Input should be map[string]interface{} even when empty, got %T",
claudeResp.Content[0].Input)
}
if len(inputMap) != 0 {
t.Errorf("Input should be empty object, got %v", inputMap)
}
})
}

// TestConvertFinishReason tests finish reason mapping
Expand Down