fix(gateway): preserve Anthropic tool arguments

This commit is contained in:
wucm667
2026-08-28 19:08:26 +08:00
parent 7b693ae429
commit da10822d7d
4 changed files with 171 additions and 1 deletions
@@ -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()
@@ -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)
@@ -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)
@@ -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)
}
}