diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 645b31992a..fd104db8b6 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -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) diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index c11d78e55c..b3e9889a7d 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -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()