diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index efa048199a..8f177f6dcf 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -438,6 +438,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( openai_compat.NormalizeResponsesSupportMode(responsesMode) == openai_compat.ResponsesSupportModeAuto && openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportYes && !isResponsesShape && + (ctx == nil || ctx.Err() == nil) && + (c.Request == nil || c.Request.Context().Err() == nil) && + !IsResponseCommitted(c) && + !c.Writer.Written() && completeErrorBody && isConvertedResponsesInputStringRejection(resp.StatusCode, respBody) { logger.L().Info("openai chat_completions: structured Responses input rejected, falling back to raw chat completions", @@ -597,6 +601,39 @@ func isConvertedResponsesInputStringRejection(status int, responseBody []byte) b if status != http.StatusBadRequest || !json.Valid(responseBody) { return false } + envelope := gjson.ParseBytes(responseBody) + for _, object := range []gjson.Result{envelope, envelope.Get("error")} { + if !object.IsObject() { + return false + } + keys := make(map[string]bool) + unambiguous := true + object.ForEach(func(key, value gjson.Result) bool { + name := key.String() + if keys[name] { + unambiguous = false + return false + } + keys[name] = true + switch name { + case "usage", "output", "output_text", "choices", "tool_calls", "response", "status", "incomplete_details": + unambiguous = false + case "code": + code := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = code == "" || code == "invalid_type" || code == "invalid_request_error" + case "param": + param := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = param == "" || param == "input" + case "type": + errorType := strings.ToLower(strings.TrimSpace(value.String())) + unambiguous = errorType == "" || errorType == "invalid_type" || errorType == "invalid_request_error" + } + return unambiguous + }) + if !unambiguous { + return false + } + } message := gjson.GetBytes(responseBody, "error.message").String() if isResponsesInputStringValidationMessage(message) { return true diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 198edc0645..5865f9d765 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -98,6 +98,93 @@ func TestForwardAsChatCompletions_ResponsesSupportedFallsBackWhenStructuredInput } } +type openAIChatFallbackBoundaryReader struct { + io.Reader + beforeRead func() +} + +func (reader *openAIChatFallbackBoundaryReader) Read(buffer []byte) (int, error) { + if reader.beforeRead != nil { + reader.beforeRead() + reader.beforeRead = nil + } + return reader.Reader.Read(buffer) +} + +func TestForwardAsChatCompletions_StructuredInputRetrySafetyBoundaries(t *testing.T) { + rejection := `{"error":{"code":"invalid_type","param":"input","message":"Expected a string, but got an array instead."}}` + validation := `"message":"1 validation error: [{'type':'string_type','loc':('body','input','str'),'msg':'Input should be a valid string'}]"` + for _, test := range []struct { + name string + body string + }{ + {name: "duplicate error", body: strings.TrimSuffix(rejection, "}") + `,"error":{"code":"rate_limit_exceeded"}}`}, + {name: "duplicate code", body: strings.Replace(rejection, `"code":"invalid_type"`, `"code":"invalid_type","code":"rate_limit_exceeded"`, 1)}, + {name: "duplicate escaped param", body: strings.Replace(rejection, `"param":"input"`, `"param":"input","p\u0061ram":"tools"`, 1)}, + {name: "duplicate message", body: strings.Replace(rejection, `"message":"Expected a string, but got an array instead."`, `"message":"Expected a string, but got an array instead.","message":"Input is too long."`, 1)}, + {name: "contradictory error type", body: strings.Replace(rejection, `"error":{`, `"error":{"type":"authentication_error",`, 1)}, + {name: "contradictory envelope type", body: strings.TrimSuffix(rejection, "}") + `,"type":"rate_limit_error"}`}, + {name: "validation contradictory type", body: `{"error":{"type":"rate_limit_error",` + validation + `}}`}, + {name: "validation contradictory envelope type", body: `{"type":"authentication_error","error":{` + validation + `}}`}, + {name: "validation contradictory code", body: `{"error":{"code":"rate_limit_exceeded",` + validation + `}}`}, + {name: "validation contradictory param", body: `{"error":{"param":"tools",` + validation + `}}`}, + {name: "contradictory envelope code", body: strings.TrimSuffix(rejection, "}") + `,"code":"rate_limit_exceeded"}`}, + {name: "contradictory envelope param", body: strings.TrimSuffix(rejection, "}") + `,"param":"tools"}`}, + {name: "type cannot override code", body: strings.Replace(rejection, `"code":"invalid_type"`, `"code":"rate_limit_exceeded","type":"invalid_type"`, 1)}, + {name: "usage", body: strings.TrimSuffix(rejection, "}") + `,"usage":{"input_tokens":12,"output_tokens":3}}`}, + {name: "error usage", body: strings.Replace(rejection, `"error":{`, `"error":{"usage":{"input_tokens":12},`, 1)}, + {name: "output", body: strings.TrimSuffix(rejection, "}") + `,"output":[{"type":"message","content":[{"type":"output_text","text":"partial"}]}]}`}, + {name: "output text", body: strings.TrimSuffix(rejection, "}") + `,"output_text":"partial"}`}, + {name: "tool calls", body: strings.TrimSuffix(rejection, "}") + `,"tool_calls":[{"id":"call_partial","type":"function","function":{"name":"lookup","arguments":"{}"}}]}`}, + {name: "choices", body: strings.TrimSuffix(rejection, "}") + `,"choices":[{"delta":{"content":"partial"}}]}`}, + {name: "nested response", body: strings.TrimSuffix(rejection, "}") + `,"response":{"status":"incomplete","usage":{"input_tokens":12},"output":[]}}`}, + {name: "partial status", body: strings.TrimSuffix(rejection, "}") + `,"status":"in_progress"}`}, + {name: "incomplete details", body: strings.TrimSuffix(rejection, "}") + `,"incomplete_details":{"reason":"max_output_tokens"}}`}, + {name: "partial event", body: strings.TrimSuffix(rejection, "}") + `,"type":"response.output_text.delta","delta":"partial"}`}, + {name: "caller canceled"}, + {name: "request canceled"}, + {name: "response committed"}, + {name: "writer committed"}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + requestCtx, cancelRequest := context.WithCancel(context.Background()) + defer cancelRequest() + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)).WithContext(requestCtx) + errorBody := test.body + if errorBody == "" { + errorBody = rejection + } + reader := &openAIChatFallbackBoundaryReader{Reader: strings.NewReader(errorBody), beforeRead: func() { + switch test.name { + case "caller canceled": + cancel() + case "request canceled": + cancelRequest() + case "response committed": + MarkResponseCommitted(c) + case "writer committed": + c.Writer.WriteHeaderNow() + } + }} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(reader), + }} + svc := &OpenAIGatewayService{cfg: structuredInputRetryTestConfig(), httpUpstream: upstream} + result, err := svc.ForwardAsChatCompletions(ctx, c, structuredInputRetryTestAccount(), body, "", "") + require.Error(t, err) + require.Nil(t, result) + require.Len(t, upstream.requests, 1) + require.Equal(t, "/v1/responses", upstream.requests[0].URL.Path) + }) + } +} + func TestConvertedResponsesInputStringRejectionIsNarrow(t *testing.T) { matching := []byte(`{"error":{"code":"invalid_type","param":"input","message":"Expected a string for input, but got an object instead."}}`) require.True(t, isConvertedResponsesInputStringRejection(http.StatusBadRequest, matching))