diff --git a/core/relay/adaptor/openai/chat.go b/core/relay/adaptor/openai/chat.go index fcf22bba..129df91d 100644 --- a/core/relay/adaptor/openai/chat.go +++ b/core/relay/adaptor/openai/chat.go @@ -1397,7 +1397,7 @@ func ConvertResponsesToChatCompletionResponse( Type: relaymodel.ToolChoiceTypeFunction, Function: relaymodel.Function{ Name: outputItem.Name, - Arguments: outputItem.Arguments, + Arguments: outputItem.Arguments.String(), }, }, }, diff --git a/core/relay/adaptor/openai/gemini.go b/core/relay/adaptor/openai/gemini.go index 32c091d7..d29f7d89 100644 --- a/core/relay/adaptor/openai/gemini.go +++ b/core/relay/adaptor/openai/gemini.go @@ -1010,7 +1010,7 @@ func ConvertResponsesToGeminiResponse( if outputItem.Name != "" { var args map[string]any if outputItem.Arguments != "" { - err := sonic.Unmarshal([]byte(outputItem.Arguments), &args) + err := sonic.UnmarshalString(outputItem.Arguments.String(), &args) if err == nil { candidate.Content.Parts = append( candidate.Content.Parts, @@ -1257,7 +1257,7 @@ func (s *geminiStreamState) handleFunctionCallArgumentsDone( // Parse arguments var args map[string]any - if err := sonic.UnmarshalString(event.Arguments, &args); err != nil { + if err := sonic.UnmarshalString(event.Arguments.String(), &args); err != nil { return false } diff --git a/core/relay/adaptor/openai/response_test.go b/core/relay/adaptor/openai/response_test.go index 82bc9f43..b8638e45 100644 --- a/core/relay/adaptor/openai/response_test.go +++ b/core/relay/adaptor/openai/response_test.go @@ -309,6 +309,49 @@ func TestResponseStreamHandlerFlushesLifecycleEventsOnOfficialTextStreamOrder(t assert.Contains(t, output, "response.completed") } +func TestResponseStreamHandlerAcceptsObjectFunctionCallArguments(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequestWithContext( + t.Context(), + http.MethodPost, + "/v1/responses", + nil, + ) + + body := strings.Join([]string{ + "event: response.output_item.added", + `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"search","arguments":{},"status":"in_progress"}}`, + "", + "event: response.function_call_arguments.done", + `data: {"type":"response.function_call_arguments.done","item_id":"fc_123","output_index":0,"arguments":{"query":"spawn tool"},"sequence_number":1}`, + "", + "event: response.output_item.done", + `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"search","arguments":{"query":"spawn tool"},"status":"completed"}}`, + "", + "event: response.completed", + `data: {"type":"response.completed","response":{"id":"resp_tool","object":"response","created_at":1,"status":"completed","model":"gpt-5.5","output":[{"id":"fc_123","type":"function_call","call_id":"call_123","name":"search","arguments":{"query":"spawn tool"},"status":"completed"}],"parallel_tool_calls":true,"store":false,"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`, + "", + }, "\n") + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: make(http.Header), + } + + result, err := ResponseStreamHandler(&meta.Meta{}, &responseTestStore{}, c, resp) + require.Nil(t, err) + assert.Equal(t, "resp_tool", result.UpstreamID) + assert.Equal(t, model.ZeroNullInt64(2), result.Usage.TotalTokens) + + output := recorder.Body.String() + assert.Contains(t, output, `"arguments":{"query":"spawn tool"}`) + assert.Contains(t, output, `"type":"response.completed"`) +} + func TestResponseStreamHandlerStartsBufferTimeoutFromFirstDelayedEvent(t *testing.T) { responseStreamInitialBufferTimeoutTestMu.Lock() defer responseStreamInitialBufferTimeoutTestMu.Unlock() diff --git a/core/relay/model/response.go b/core/relay/model/response.go index 4687cc1b..0820d801 100644 --- a/core/relay/model/response.go +++ b/core/relay/model/response.go @@ -1,9 +1,41 @@ package model import ( + "slices" + + "github.com/bytedance/sonic" "github.com/labring/aiproxy/core/model" ) +var nullBytes = []byte("null") + +type ResponseArguments string + +func (a *ResponseArguments) UnmarshalJSON(data []byte) error { + var s string + if err := sonic.ConfigDefault.Unmarshal(data, &s); err == nil { + *a = ResponseArguments(s) + return nil + } + + if slices.Equal(data, nullBytes) { + *a = "" + return nil + } + + *a = ResponseArguments(data) + + return nil +} + +func (a ResponseArguments) MarshalJSON() ([]byte, error) { + return sonic.ConfigDefault.Marshal(string(a)) +} + +func (a ResponseArguments) String() string { + return string(a) +} + // InputItemType represents the type of an input item type InputItemType = string @@ -179,15 +211,15 @@ type OutputContent struct { // OutputItem represents an output item in a response type OutputItem struct { - ID string `json:"id"` - Type string `json:"type"` - Status ResponseStatus `json:"status,omitempty"` - Role string `json:"role,omitempty"` - Content []OutputContent `json:"content,omitempty"` - Arguments string `json:"arguments,omitempty"` // For function_call type - CallID string `json:"call_id,omitempty"` // For function_call type - Name string `json:"name,omitempty"` // For function_call type - Summary any `json:"summary,omitempty"` // For reasoning type: []SummaryPart or string + ID string `json:"id"` + Type string `json:"type"` + Status ResponseStatus `json:"status,omitempty"` + Role string `json:"role,omitempty"` + Content []OutputContent `json:"content,omitempty"` + Arguments ResponseArguments `json:"arguments,omitempty"` // For function_call type + CallID string `json:"call_id,omitempty"` // For function_call type + Name string `json:"name,omitempty"` // For function_call type + Summary any `json:"summary,omitempty"` // For reasoning type: []SummaryPart or string } // InputContent represents content in an input item @@ -333,18 +365,18 @@ type InputItemList struct { // ResponseStreamEvent represents a server-sent event for response streaming type ResponseStreamEvent struct { - Type string `json:"type"` - Response *Response `json:"response,omitempty"` - Error *OpenAIError `json:"error,omitempty"` - OutputIndex *int `json:"output_index,omitempty"` - Item *OutputItem `json:"item,omitempty"` - ItemID string `json:"item_id,omitempty"` - ContentIndex *int `json:"content_index,omitempty"` - Part *OutputContent `json:"part,omitempty"` // For content_part events - Delta string `json:"delta,omitempty"` // For text.delta, function_call_arguments.delta - Text string `json:"text,omitempty"` // For text content - Arguments string `json:"arguments,omitempty"` // For function_call_arguments.done - SequenceNumber int `json:"sequence_number,omitempty"` + Type string `json:"type"` + Response *Response `json:"response,omitempty"` + Error *OpenAIError `json:"error,omitempty"` + OutputIndex *int `json:"output_index,omitempty"` + Item *OutputItem `json:"item,omitempty"` + ItemID string `json:"item_id,omitempty"` + ContentIndex *int `json:"content_index,omitempty"` + Part *OutputContent `json:"part,omitempty"` // For content_part events + Delta string `json:"delta,omitempty"` // For text.delta, function_call_arguments.delta + Text string `json:"text,omitempty"` // For text content + Arguments ResponseArguments `json:"arguments,omitempty"` // For function_call_arguments.done + SequenceNumber int `json:"sequence_number,omitempty"` } func (r *Response) ToolUsageWebSearchCallCount() int64 {