Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/relay/adaptor/openai/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,7 @@ func ConvertResponsesToChatCompletionResponse(
Type: relaymodel.ToolChoiceTypeFunction,
Function: relaymodel.Function{
Name: outputItem.Name,
Arguments: outputItem.Arguments,
Arguments: outputItem.Arguments.String(),
},
},
},
Expand Down
4 changes: 2 additions & 2 deletions core/relay/adaptor/openai/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
43 changes: 43 additions & 0 deletions core/relay/adaptor/openai/response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
74 changes: 53 additions & 21 deletions core/relay/model/response.go
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading