diff --git a/backend/internal/service/gateway_forward_as_chat_completions_test.go b/backend/internal/service/gateway_forward_as_chat_completions_test.go index a5a5cbf913..f7cb42c69c 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions_test.go +++ b/backend/internal/service/gateway_forward_as_chat_completions_test.go @@ -3,6 +3,7 @@ package service import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -14,6 +15,51 @@ import ( "github.com/stretchr/testify/require" ) +func TestHandleCCBufferedFromAnthropic_ToolArgumentsAreValidJSON(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + resp := &http.Response{Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `event: message_start`, + `data: {"type":"message_start","message":{"id":"msg_tool","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4.5","usage":{"input_tokens":10}}}`, + ``, + `event: content_block_start`, + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{}}}`, + ``, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}`, + ``, + `event: content_block_delta`, + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"Paris\"}"}}`, + ``, + `event: message_delta`, + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}`, + ``, + }, "\n")))} + + _, err := (&GatewayService{}).handleCCBufferedFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", nil, time.Now()) + require.NoError(t, err) + + var body struct { + Choices []struct { + Message struct { + ToolCalls []struct { + Function struct { + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Len(t, body.Choices, 1) + require.Len(t, body.Choices[0].Message.ToolCalls, 1) + args := body.Choices[0].Message.ToolCalls[0].Function.Arguments + require.JSONEq(t, `{"city":"Paris"}`, args) +} + func TestExtractCCReasoningEffortFromBody(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index a46996d04b..7c6cde8328 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -639,7 +639,12 @@ func (s *GatewayService) handleResponsesStreamingResponse( // appendRawJSON appends a JSON fragment string to existing raw JSON. func appendRawJSON(existing json.RawMessage, fragment string) json.RawMessage { - if len(existing) == 0 { + // Anthropic initializes tool_use.input to {} in content_block_start, then + // streams the actual input through input_json_delta events. Treat that empty + // object as a placeholder instead of prefixing it to the streamed JSON. + var existingObject map[string]json.RawMessage + isEmptyObject := json.Unmarshal(existing, &existingObject) == nil && existingObject != nil && len(existingObject) == 0 + if len(existing) == 0 || isEmptyObject { return json.RawMessage(fragment) } return json.RawMessage(string(existing) + fragment) diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go index 262d588629..e62a330a83 100644 --- a/backend/internal/service/gateway_forward_as_responses_test.go +++ b/backend/internal/service/gateway_forward_as_responses_test.go @@ -117,6 +117,37 @@ func TestHandleResponsesBufferedStreamingResponse_RestoresNamespaceTool(t *testi require.NotContains(t, rec.Body.String(), `"name":"codex_app__read_thread"`) } +func TestHandleResponsesBufferedStreamingResponse_ToolArgumentsAreValidJSON(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + resp := &http.Response{Body: io.NopCloser(strings.NewReader(toolAnthropicSSEStream()))} + + _, err := (&GatewayService{}).handleResponsesBufferedStreamingResponse(resp, c, "claude-fable-5", "claude-fable-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + + var body struct { + Output []struct { + Type string `json:"type"` + Arguments string `json:"arguments"` + } `json:"output"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.Len(t, body.Output, 1) + require.Equal(t, "function_call", body.Output[0].Type) + require.JSONEq(t, `{"query":"status"}`, body.Output[0].Arguments) +} + +func TestAppendRawJSON_EmptyObjectPlaceholder(t *testing.T) { + t.Parallel() + + fragment := `{"query":"status"}` + require.JSONEq(t, fragment, string(appendRawJSON(json.RawMessage("{ \n\t }"), fragment))) + require.Equal(t, `{"existing":true}{"query":"status"}`, string(appendRawJSON(json.RawMessage(`{"existing":true}`), fragment))) +} + func TestHandleResponsesStreamingResponse_RestoresNamespaceTool(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go index 1e9e1b39a6..83bbd4eeff 100644 --- a/backend/internal/service/openai_gateway_anthropic_native_pump_test.go +++ b/backend/internal/service/openai_gateway_anthropic_native_pump_test.go @@ -6,6 +6,7 @@ package service import ( "bufio" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -59,6 +60,29 @@ func miniAnthropicSSEStream() string { }, "\n") } +func toolAnthropicSSEStream() string { + return strings.Join([]string{ + "event: message_start", + `data: {"type":"message_start","message":{"id":"msg_tool","type":"message","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10}}}`, + "", + "event: content_block_start", + `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}}`, + "", + "event: content_block_delta", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`, + "", + "event: content_block_stop", + `data: {"type":"content_block_stop","index":0}`, + "", + "event: message_delta", + `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}`, + "", + "event: message_stop", + `data: {"type":"message_stop"}`, + "", + }, "\n") +} + func TestAnthropicNativeLinePump_TimesOutWithoutData(t *testing.T) { pr, _ := io.Pipe() scanner := bufio.NewScanner(pr) @@ -242,3 +266,67 @@ func TestCCBufferedFromNativeAnthropic_HappyPathStillConverts(t *testing.T) { t.Fatalf("expected usage 10/5, got %+v", res.Usage) } } + +func TestCCBufferedFromNativeAnthropic_ToolArgumentsAreValidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(5) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(toolAnthropicSSEStream())), Header: http.Header{}} + + _, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body struct { + Choices []struct { + Message struct { + ToolCalls []struct { + Function struct { + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(body.Choices) != 1 || len(body.Choices[0].Message.ToolCalls) != 1 { + t.Fatalf("expected one tool call, got %s", rec.Body.String()) + } + args := body.Choices[0].Message.ToolCalls[0].Function.Arguments + if !json.Valid([]byte(args)) || args != `{"query":"status"}` { + t.Fatalf("expected valid tool arguments, got %q", args) + } +} + +func TestResponsesBufferedFromNativeAnthropic_ToolArgumentsAreValidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newNativeAnthropicHangTestService(5) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(toolAnthropicSSEStream())), Header: http.Header{}} + + _, err := svc.handleResponsesBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var body struct { + Output []struct { + Type string `json:"type"` + Arguments string `json:"arguments"` + } `json:"output"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(body.Output) != 1 || body.Output[0].Type != "function_call" { + t.Fatalf("expected one function call, got %s", rec.Body.String()) + } + if args := body.Output[0].Arguments; !json.Valid([]byte(args)) || args != `{"query":"status"}` { + t.Fatalf("expected valid tool arguments, got %q", args) + } +}