mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix(openai): avoid compact usage loss from json sse heuristic
Replace the substring-based data:/event: SSE heuristic in handleNonStreamingResponse with bodyHasSSEFraming, a line-level check that only matches when a physical line starts with the SSE data:/ event: field names. This prevents OAuth compact JSON responses whose output text merely contains the literal substrings "data:" or "event:" from being misrouted into handleSSEToJSON, which was causing usage to be recorded as zero even though the JSON body itself carries a valid top-level usage object. Content-Type: text/event-stream detection and genuine SSE body handling are unaffected.
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user