Skip to content
Open
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
7 changes: 7 additions & 0 deletions changelog/unreleased/responses-invalid-function-arguments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Recover Responses requests from malformed historical function-call arguments by skipping the invalid call/output pair, and avoid emitting invalid JSON arguments in generated Responses output.

### 中文

- 当 Responses 历史记录包含格式错误的函数调用参数时,跳过对应的调用与输出以恢复请求,并避免在生成的 Responses 输出中发送无效 JSON 参数。
28 changes: 27 additions & 1 deletion internal/gateway/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,37 @@ func decodeOpenAIToolCalls(raw json.RawMessage) []proxyToolCall {
if call.Function.Name == "" {
continue
}
calls = append(calls, proxyToolCall{ID: call.ID, Name: call.Function.Name, Arguments: call.Function.Arguments})
arguments := strings.TrimSpace(call.Function.Arguments)
if arguments == "" {
arguments = "{}"
}
if _, _, custom := translate.DecodeCustomToolName(call.Function.Name); !custom && !json.Valid([]byte(arguments)) {
continue
}
calls = append(calls, proxyToolCall{ID: call.ID, Name: call.Function.Name, Arguments: arguments})
}
return calls
}

func validateProxyToolCallArguments(calls []proxyToolCall) error {
for index := range calls {
call := &calls[index]
if _, _, custom := translate.DecodeCustomToolName(call.Name); custom {
continue
}
arguments := strings.TrimSpace(call.Arguments)
if arguments == "" {
call.Arguments = "{}"
continue
}
if !json.Valid([]byte(arguments)) {
return fmt.Errorf("tool call %q (%s) arguments are invalid JSON", call.ID, call.Name)
}
call.Arguments = arguments
}
return nil
}

func (h *Handler) PrepareCompatibilityExecution(r *http.Request, request translate.ChatRequest) (Execution, error) {
if len(request.Messages) == 0 {
return Execution{}, &chatHTTPError{Status: http.StatusBadRequest, Code: "invalid_request", Message: "input messages required"}
Expand Down
3 changes: 3 additions & 0 deletions internal/gateway/compat_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,9 @@ func RelayResponsesStream(writer io.Writer, body io.Reader, requestID, model str
}
content := output.content.String()
calls := output.calls()
if err := validateProxyToolCallArguments(calls); err != nil {
return stats, err
}
if textStarted {
if err := eventWriter.write("response.output_text.done", map[string]any{"type": "response.output_text.done", "item_id": "msg_" + requestID, "output_index": textOutputIndex, "content_index": 0, "text": content}); err != nil {
return stats, err
Expand Down
40 changes: 40 additions & 0 deletions internal/gateway/responses_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package gateway

import (
"encoding/json"
"strings"
"testing"
)

func TestDecodeOpenAIToolCallsDropsInvalidArguments(t *testing.T) {
calls := decodeOpenAIToolCalls(json.RawMessage(`[
{"id":"call_bad","function":{"name":"mcp__fastctx__read","arguments":"{\"path\":\"x\",\"error_retry:: 240}"}},
{"id":"call_good","function":{"name":"mcp__fastctx__grep","arguments":"{\"pattern\":\"x\"}"}}
]`))
if len(calls) != 1 || calls[0].ID != "call_good" {
t.Fatalf("calls=%#v", calls)
}
}

func TestRelayResponsesStreamRejectsInvalidToolArgumentsBeforeFinalize(t *testing.T) {
upstream := strings.NewReader(
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_bad","function":{"name":"mcp__fastctx__read","arguments":"{\"path\":\"x\",\"error_retry:: 240}"}}]}}]}` + "\n\n" +
`data: {"choices":[{"finish_reason":"tool_calls"}]}` + "\n\n" +
"data: [DONE]\n\n",
)
var output strings.Builder
_, err := RelayResponsesStream(&output, upstream, "req", "model", nil)
if err == nil || !strings.Contains(err.Error(), "arguments are invalid JSON") {
t.Fatalf("error=%v", err)
}
body := output.String()
for _, event := range []string{
"event: response.function_call_arguments.done",
"event: response.output_item.done",
"event: response.completed",
} {
if strings.Contains(body, event) {
t.Fatalf("invalid tool call was finalized with %q:\n%s", event, body)
}
}
}
9 changes: 8 additions & 1 deletion internal/translate/compat.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) {
}
messages := make([]ChatMessage, 0, len(items))
pendingReasoning := ""
invalidFunctionCallIDs := make(map[string]struct{})
appendAssistant := func(message ChatMessage) {
if pendingReasoning != "" {
if message.ReasoningContent != "" {
Expand Down Expand Up @@ -487,6 +488,10 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) {
if callID == "" {
return nil, fmt.Errorf("input[%d].call_id required", itemIndex)
}
if _, skipped := invalidFunctionCallIDs[callID]; skipped {
pendingReasoning = ""
continue
}
content, images, err := responsesFunctionCallOutput(rawMapJSON(source, "output"))
if err != nil {
return nil, fmt.Errorf("input[%d].output: %w", itemIndex, err)
Expand Down Expand Up @@ -529,7 +534,9 @@ func translateResponsesInput(raw json.RawMessage) ([]ChatMessage, error) {
arguments = json.RawMessage(`{}`)
}
if !json.Valid(arguments) {
return nil, fmt.Errorf("input[%d].arguments must be valid JSON", itemIndex)
invalidFunctionCallIDs[callID] = struct{}{}
pendingReasoning = ""
continue
}
appendAssistant(ChatMessage{Role: "assistant", Content: "", ToolCalls: marshalToolCalls([]compatibilityToolCall{{ID: callID, Name: name, Arguments: arguments}})})
case "custom_tool_call":
Expand Down
24 changes: 24 additions & 0 deletions internal/translate/compat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ func TestTranslateResponsesUnquotesFunctionCallArguments(t *testing.T) {
}
}

func TestTranslateResponsesSkipsInvalidFunctionCallHistory(t *testing.T) {
request := ResponsesRequest{
Model: "qoder/deepseek-flash",
Input: json.RawMessage(`[
{"role":"user","content":"before"},
{"type":"function_call","call_id":"call_bad","name":"mcp__fastctx__read","arguments":"{\"path\":\"x\",\"error_retry:: 240}"},
{"type":"function_call_output","call_id":"call_bad","output":"unsupported call: mcp__fastctx__read"},
{"role":"user","content":"continue"}
]`),
}
chat, err := TranslateResponses(request)
if err != nil {
t.Fatal(err)
}
if len(chat.Messages) != 2 {
t.Fatalf("messages=%#v", chat.Messages)
}
for _, message := range chat.Messages {
if message.Role == "tool" || len(message.ToolCalls) > 0 {
t.Fatalf("invalid tool history was retained: %#v", chat.Messages)
}
}
}

func TestTranslateAnthropicToolResultLiftsImages(t *testing.T) {
request := AnthropicMessagesRequest{
Model: "qoder/glm-5.2",
Expand Down
Loading