From 290b38db9334feab3f85bda76d5d56f777e97dcb Mon Sep 17 00:00:00 2001 From: POC Farm Date: Thu, 23 Apr 2026 04:22:09 +0700 Subject: [PATCH] fix(converter): parse tool_use.input JSON string into object Anthropic's Messages API spec requires content blocks of type 'tool_use' to carry 'input' as a JSON object. OpenAI-compat upstreams return function arguments as a JSON-encoded string, and we were forwarding that string verbatim, which breaks downstream Anthropic SDKs (including Claude Code CLI) that expect an object. Parse the arguments string into interface{} so the final JSON-encoding emits a proper object; fall back to an empty object when arguments are missing, and leave the raw string only when it is not valid JSON. Extend TestConvertResponse to assert Input is a map[string]interface{} and add coverage for the empty-arguments case. --- internal/converter/converter.go | 12 ++++++- internal/converter/converter_test.go | 51 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 0b44468..c7fe23c 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -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, }) } diff --git a/internal/converter/converter_test.go b/internal/converter/converter_test.go index a3bc622..022e127 100644 --- a/internal/converter/converter_test.go +++ b/internal/converter/converter_test.go @@ -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