fix(gateway): guard structured-input fallback against unsafe retries

This commit is contained in:
wucm667
2026-10-07 08:50:13 +08:00
parent f7f1112cda
commit 63800c50d4
2 changed files with 124 additions and 0 deletions
@@ -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
@@ -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))