diff --git a/changelog/unreleased/responses-invalid-function-arguments.md b/changelog/unreleased/responses-invalid-function-arguments.md new file mode 100644 index 0000000..1c556f1 --- /dev/null +++ b/changelog/unreleased/responses-invalid-function-arguments.md @@ -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 参数。 diff --git a/internal/gateway/compat.go b/internal/gateway/compat.go index 553292a..77b7b39 100644 --- a/internal/gateway/compat.go +++ b/internal/gateway/compat.go @@ -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"} diff --git a/internal/gateway/compat_stream.go b/internal/gateway/compat_stream.go index 0688050..a859f6d 100644 --- a/internal/gateway/compat_stream.go +++ b/internal/gateway/compat_stream.go @@ -544,6 +544,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 diff --git a/internal/gateway/responses_test.go b/internal/gateway/responses_test.go index f3c60dc..f77ad1b 100644 --- a/internal/gateway/responses_test.go +++ b/internal/gateway/responses_test.go @@ -114,3 +114,36 @@ func TestParseStreamUsageLineCacheReadFallback(t *testing.T) { }) } } + +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) + } + } +} diff --git a/internal/translate/compat.go b/internal/translate/compat.go index 82ee6cf..0d28f1c 100644 --- a/internal/translate/compat.go +++ b/internal/translate/compat.go @@ -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 != "" { @@ -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) @@ -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": diff --git a/internal/translate/compat_test.go b/internal/translate/compat_test.go index a5d268e..e92b1cc 100644 --- a/internal/translate/compat_test.go +++ b/internal/translate/compat_test.go @@ -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",