mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
fix(gateway): preserve Anthropic tool arguments
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user