diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 19fad32501..3fdad536d0 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -3933,6 +3933,11 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( responseID = extractOpenAIResponseIDFromJSONBytes(dataBytes) } imageCounter.AddSSEData(dataBytes) + if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(dataBytes, eventType); sanitized { + dataBytes = sanitizedData + trimmedData = strings.TrimSpace(string(sanitizedData)) + line = "data: " + string(sanitizedData) + } lineStartsClientOutput = forceFlushFailedEvent || openAIStreamDataStartsClientOutput(trimmedData, eventType) if firstTokenMs == nil && lineStartsClientOutput && trimmedData != "[DONE]" { ms := int(time.Since(startTime).Milliseconds()) @@ -4895,6 +4900,11 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp line = "data: " + data eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String()) } + if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(dataBytes, eventType); sanitized { + dataBytes = sanitizedData + data = string(sanitizedData) + line = "data: " + data + } // Replace model in response if needed. // Fast path: most events do not contain model field values. if needModelReplace && mappedModel != "" && strings.Contains(line, mappedModel) { @@ -5509,6 +5519,37 @@ func extractOpenAISSEErrorMessage(payload []byte) string { return sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(payload))) } +func sanitizeOpenAIResponseFailedEventForClient(payload []byte, eventType string) ([]byte, bool) { + if eventType != "response.failed" || len(payload) == 0 || !gjson.ValidBytes(payload) { + return payload, false + } + if !gjson.GetBytes(payload, "response").Exists() { + return payload, false + } + updated := payload + for _, path := range []string{ + "response.instructions", + "response.output", + "response.usage", + "response.metadata", + "response.reasoning", + "response.tools", + "response.tool_choice", + "response.parallel_tool_calls", + "response.text", + "response.truncation", + "response.max_output_tokens", + "response.incomplete_details", + } { + next, err := sjson.DeleteBytes(updated, path) + if err != nil { + return payload, false + } + updated = next + } + return updated, !bytes.Equal(updated, payload) +} + func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.Response, c *gin.Context, message string) error { message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message)) if message == "" { diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 66cfddea6f..abcf53a269 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -1368,6 +1368,55 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai require.Empty(t, rec.Body.String()) } +func TestOpenAIStreamingResponseFailedAfterOutputSanitizesVerboseResponseForClient(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + StreamDataIntervalTimeout: 0, + StreamKeepaliveInterval: 0, + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &OpenAIGatewayService{cfg: cfg} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + longInstructions := strings.Repeat("You are GPT-5.1 running in the Codex CLI. ", 20) + failedPayload := fmt.Sprintf( + `{"type":"response.failed","response":{"id":"resp_failed","object":"response","created_at":1782446336,"status":"failed","instructions":%q,"output":[{"type":"message","content":[{"type":"output_text","text":"large"}]}],"usage":{"input_tokens":123,"output_tokens":0},"error":{"code":"context_length_exceeded","message":"Your input exceeds the context window of this model. Please adjust your input and try again."}}}`, + longInstructions, + ) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_failed"}}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"partial"}`, + "", + "event: response.failed", + "data: " + failedPayload, + "", + }, "\n"))), + Header: http.Header{"X-Request-Id": []string{"rid-failed-after-output"}}, + } + + _, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Name: "acc"}, time.Now(), "model", "model") + require.Error(t, err) + + body := rec.Body.String() + require.Contains(t, body, "event: response.failed") + require.Contains(t, body, "context_length_exceeded") + require.Contains(t, body, "Your input exceeds the context window") + require.NotContains(t, body, "You are GPT-5.1 running in the Codex CLI") + require.NotContains(t, body, `"instructions"`) + require.NotContains(t, body, `"output"`) + require.NotContains(t, body, `"usage"`) +} + func TestOpenAIStreamingPreambleOnlyMissingTerminalReturnsFailover(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{ @@ -1707,6 +1756,53 @@ func TestOpenAIStreamingPassthroughResponseFailedBeforeOutputReturnsFailover(t * require.Empty(t, rec.Body.String()) } +func TestOpenAIStreamingPassthroughResponseFailedAfterOutputSanitizesVerboseResponseForClient(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{ + Gateway: config.GatewayConfig{ + MaxLineSize: defaultMaxLineSize, + }, + } + svc := &OpenAIGatewayService{cfg: cfg} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + + longInstructions := strings.Repeat("You are GPT-5.1 running in the Codex CLI. ", 20) + failedPayload := fmt.Sprintf( + `{"type":"response.failed","response":{"id":"resp_pass_failed","object":"response","created_at":1782446336,"status":"failed","instructions":%q,"output":[{"type":"message","content":[{"type":"output_text","text":"large"}]}],"usage":{"input_tokens":123,"output_tokens":0},"error":{"code":"context_length_exceeded","message":"Your input exceeds the context window of this model. Please adjust your input and try again."}}}`, + longInstructions, + ) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_pass_failed"}}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"partial"}`, + "", + "event: response.failed", + "data: " + failedPayload, + "", + }, "\n"))), + Header: http.Header{"X-Request-Id": []string{"rid-pass-failed-after-output"}}, + } + + _, err := svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Name: "acc"}, time.Now(), "", "") + require.Error(t, err) + + body := rec.Body.String() + require.Contains(t, body, "event: response.failed") + require.Contains(t, body, "context_length_exceeded") + require.Contains(t, body, "Your input exceeds the context window") + require.NotContains(t, body, "You are GPT-5.1 running in the Codex CLI") + require.NotContains(t, body, `"instructions"`) + require.NotContains(t, body, `"output"`) + require.NotContains(t, body, `"usage"`) +} + func TestOpenAIStreamingPassthroughResponseDoneWithoutDoneMarkerStillSucceeds(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{