Merge pull request #3776 from alfadb/fix/openai-compact-usage-sse-detection

fix(openai): 避免 JSON 响应被 SSE 文本误判导致用量丢失
This commit is contained in:
Wesley Liddick
2026-07-07 14:31:52 +08:00
committed by GitHub
2 changed files with 58 additions and 1 deletions
@@ -5668,7 +5668,13 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
if isEventStreamResponse(resp.Header) {
return s.handleSSEToJSON(resp, c, body, originalModel, mappedModel)
}
bodyLooksLikeSSE := bytes.Contains(body, []byte("data:")) || bytes.Contains(body, []byte("event:"))
// bodyLooksLikeSSE is a line-level heuristic: real SSE framing requires
// "data:"/"event:" field names at the very start of a physical line. A
// plain bytes.Contains scan would also match ordinary JSON responses
// whose string content merely echoes the literal text "data:" or
// "event:" (e.g. compact tool output), causing those JSON bodies to be
// misrouted into handleSSEToJSON and lose their usage accounting.
bodyLooksLikeSSE := bodyHasSSEFraming(body)
// For OAuth accounts, also fall back to a body-content heuristic because
// the upstream may omit the Content-Type header while still sending SSE.
@@ -5718,6 +5724,22 @@ func isEventStreamResponse(header http.Header) bool {
return strings.Contains(contentType, "text/event-stream")
}
// bodyHasSSEFraming reports whether body contains genuine SSE framing by
// scanning for physical lines that begin with the "data:" or "event:"
// field names, per the SSE spec. Unlike a raw substring scan, this does not
// match when those strings only appear embedded inside JSON string values
// (e.g. "data: foo" quoted as part of an assistant text field), since such
// occurrences never start a physical line in a valid JSON encoding.
func bodyHasSSEFraming(body []byte) bool {
for _, line := range bytes.Split(body, []byte("\n")) {
line = bytes.TrimRight(line, "\r")
if bytes.HasPrefix(line, []byte("data:")) || bytes.HasPrefix(line, []byte("event:")) {
return true
}
}
return false
}
func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
bodyText := string(body)
finalResponse, ok := extractCodexFinalResponse(bodyText)
@@ -2739,6 +2739,41 @@ func TestHandleNonStreamingResponse_APIKeyFallsBackToSSEBodyWhenContentTypeIsWro
require.Equal(t, "hello", gjson.Get(rec.Body.String(), "output.0.content.0.text").String())
}
func TestHandleNonStreamingResponse_OAuthJSONBodyWithDataEventTextKeepsJSONUsage(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", nil)
svc := &OpenAIGatewayService{cfg: &config.Config{}}
// Plain JSON compact response whose output text happens to contain the
// literal substrings "data:" and "event:" (e.g. echoing shell/log output).
// This must NOT be misdetected as SSE framing: it has a top-level usage
// object and no upstream text/event-stream Content-Type.
jsonBody := `{"id":"resp_oauth_compact","object":"response","model":"gpt-5.4","status":"completed",` +
`"output":[{"type":"message","content":[{"type":"output_text",` +
`"text":"processing data: 1,2,3 then event: click finished"}]}],` +
`"usage":{"input_tokens":11,"output_tokens":22,"total_tokens":33}}`
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
account := &Account{ID: 146, Type: AccountTypeOAuth}
result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, account, "gpt-5.4", "gpt-5.4")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 11, result.InputTokens)
require.Equal(t, 22, result.OutputTokens)
// Response must remain the original JSON body (not routed through the SSE
// path, which would rewrite/lose the body or usage).
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
require.Equal(t, "resp_oauth_compact", gjson.Get(rec.Body.String(), "id").String())
require.Equal(t, int64(33), gjson.Get(rec.Body.String(), "usage.total_tokens").Int())
require.Contains(t, rec.Body.String(), "processing data: 1,2,3 then event: click finished")
}
func TestHandleSSEToJSON_ReconstructsImageGenerationOutputItemDone(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()