Merge pull request #4298 from wp-a/fix/openai-images-json-completion-boundary

fix(images): preserve JSON completion boundaries
This commit is contained in:
Wesley Liddick
2026-07-15 11:08:47 +08:00
committed by GitHub
3 changed files with 110 additions and 3 deletions
@@ -2209,13 +2209,23 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
return false
}
// 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c)
if compactKeepaliveCommitted {
streamStarted = true
}
if service.IsResponseCommitted(c) {
imageKeepalivePresent := service.OpenAIImagesJSONKeepalivePresent(c)
service.StopOpenAIImagesJSONKeepaliveCommitted(c)
imageKeepalivePaddingOnly := false
imageKeepaliveResponseWritten := false
if imageKeepalivePresent {
adjustedSize := service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)
imageKeepalivePaddingOnly = adjustedSize < 0
imageKeepaliveResponseWritten = adjustedSize >= 0
}
if service.IsResponseCommitted(c) || (!compactKeepaliveCommitted && imageKeepaliveResponseWritten) {
return false
}
if c.Writer.Written() {
if c.Writer.Written() && !imageKeepalivePaddingOnly {
streamStarted = true
}
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed", streamStarted)
@@ -221,6 +221,97 @@ func TestOpenAIEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsRespons
assert.Contains(t, body, "Upstream request failed")
}
func TestOpenAIEnsureForwardErrorResponse_ImageJSONKeepaliveWritesSingleJSONFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
stop := service.StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
defer stop()
before := service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)
require.Eventually(t, c.Writer.Written, time.Second, time.Millisecond)
require.Equal(t, before, service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c))
require.False(t, openAIForwardErrorAlreadyCommunicated(c, before, errors.New("read upstream response: unexpected EOF")))
h := &OpenAIGatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, false)
require.True(t, wrote)
require.Equal(t, http.StatusOK, w.Code, "heartbeat already committed the status")
require.True(t, json.Valid(w.Body.Bytes()), w.Body.String())
require.NotContains(t, w.Body.String(), "event:")
require.NotContains(t, w.Body.String(), "data:")
decoder := json.NewDecoder(strings.NewReader(w.Body.String()))
var payload map[string]any
require.NoError(t, decoder.Decode(&payload))
require.ErrorIs(t, decoder.Decode(&payload), io.EOF)
require.Equal(t, "upstream_error", gjson.Get(w.Body.String(), "error.type").String())
require.Equal(t, "Upstream request failed", gjson.Get(w.Body.String(), "error.message").String())
}
func TestOpenAIEnsureForwardErrorResponse_ImageJSONKeepalivePreservesCompletedJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
stop := service.StartOpenAIImagesJSONKeepalive(c, 5*time.Millisecond)
defer stop()
before := service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)
require.Eventually(t, c.Writer.Written, time.Second, time.Millisecond)
c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": "aW1hZ2U="}}})
completedBody := w.Body.String()
require.True(t, json.Valid([]byte(completedBody)), completedBody)
require.Greater(t, service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), before)
require.False(t, openAIForwardErrorAlreadyCommunicated(c, before, errors.New("read upstream trailer: unexpected EOF")))
h := &OpenAIGatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, false)
require.False(t, wrote, "the completed Images JSON already communicated the response")
require.Equal(t, completedBody, w.Body.String())
require.NotContains(t, w.Body.String(), "event:")
require.NotContains(t, w.Body.String(), "data:")
decoder := json.NewDecoder(strings.NewReader(w.Body.String()))
var payload map[string]any
require.NoError(t, decoder.Decode(&payload))
require.ErrorIs(t, decoder.Decode(&payload), io.EOF)
require.Equal(t, "aW1hZ2U=", gjson.Get(w.Body.String(), "data.0.b64_json").String())
}
func TestOpenAIEnsureForwardErrorResponse_FastImageJSONKeepalivePreservesCompletedJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
stop := service.StartOpenAIImagesJSONKeepalive(c, time.Hour)
defer stop()
before := service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)
c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": "ZmFzdC1pbWFnZQ=="}}})
completedBody := w.Body.String()
require.True(t, json.Valid([]byte(completedBody)), completedBody)
require.Greater(t, service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c), before)
require.False(t, openAIForwardErrorAlreadyCommunicated(c, before, errors.New("read upstream trailer: unexpected EOF")))
h := &OpenAIGatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, false)
require.False(t, wrote, "fast completed Images JSON already communicated the response")
require.Equal(t, completedBody, w.Body.String())
require.NotContains(t, w.Body.String(), "event:")
require.NotContains(t, w.Body.String(), "data:")
decoder := json.NewDecoder(strings.NewReader(w.Body.String()))
var payload map[string]any
require.NoError(t, decoder.Decode(&payload))
require.ErrorIs(t, decoder.Decode(&payload), io.EOF)
require.Equal(t, "ZmFzdC1pbWFnZQ==", gjson.Get(w.Body.String(), "data.0.b64_json").String())
}
func TestShouldLogOpenAIForwardFailureAsWarn(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -127,6 +127,12 @@ func StopOpenAIImagesJSONKeepaliveCommitted(c *gin.Context) bool {
return committed
}
// OpenAIImagesJSONKeepalivePresent reports whether the response writer belongs
// to an Images JSON request, including fast responses before the first beat.
func OpenAIImagesJSONKeepalivePresent(c *gin.Context) bool {
return openAIImagesJSONKeepaliveFromContext(c) != nil
}
// OpenAIImagesJSONKeepaliveAdjustedWrittenSize excludes heartbeat whitespace
// from response-size checks so account retry and failover remain available.
func OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c *gin.Context) int {