fix: 非流式路径在上游终态事件output为空时从delta事件重建响应内容

上游API近期更新后,response.completed终态SSE事件的output字段可能为空,
实际内容仅通过response.output_text.delta等增量事件下发。流式路径不受影响,
但chat_completions非流式路径和responses OAuth非流式路径只依赖终态事件的
output,导致返回空响应。

新增BufferedResponseAccumulator累积器,在SSE扫描过程中收集delta事件内容
(文本、function_call、reasoning),当终态output为空时补充重建。

同时修复handleChatBufferedStreamingResponse遗漏response.done事件类型的问题。
This commit is contained in:
shaw
2026-04-07 19:30:45 +08:00
parent 08b454423b
commit b2e379cf7a
4 changed files with 345 additions and 1 deletions

View File

@@ -244,6 +244,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
var finalResponse *apicompat.ResponsesResponse
var usage OpenAIUsage
acc := apicompat.NewBufferedResponseAccumulator()
for scanner.Scan() {
line := scanner.Text()
@@ -261,7 +262,11 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
continue
}
if (event.Type == "response.completed" || event.Type == "response.incomplete" || event.Type == "response.failed") &&
// Accumulate delta content for fallback when terminal output is empty.
acc.ProcessEvent(&event)
if (event.Type == "response.completed" || event.Type == "response.done" ||
event.Type == "response.incomplete" || event.Type == "response.failed") &&
event.Response != nil {
finalResponse = event.Response
if event.Response.Usage != nil {
@@ -290,6 +295,10 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
return nil, fmt.Errorf("upstream stream ended without terminal event")
}
// When the terminal event has an empty output array, reconstruct from
// accumulated delta events so the client receives the full content.
acc.SupplementResponseOutput(finalResponse)
chatResp := apicompat.ResponsesToChatCompletions(finalResponse, originalModel)
if s.responseHeaderFilter != nil {

View File

@@ -21,6 +21,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
@@ -3901,6 +3902,16 @@ func (s *OpenAIGatewayService) handleOAuthSSEToJSON(resp *http.Response, c *gin.
if parsedUsage, parsed := extractOpenAIUsageFromJSONBytes(finalResponse); parsed {
*usage = parsedUsage
}
// When the terminal event has an empty output array, reconstruct
// output from accumulated delta events so the client gets full content.
// gjson Array() returns empty slice for null, missing, or empty arrays.
if len(gjson.GetBytes(finalResponse, "output").Array()) == 0 {
if outputJSON, reconstructed := reconstructResponseOutputFromSSE(bodyText); reconstructed {
if patched, err := sjson.SetRawBytes(finalResponse, "output", outputJSON); err == nil {
finalResponse = patched
}
}
}
body = finalResponse
if originalModel != mappedModel {
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
@@ -4002,6 +4013,34 @@ func extractCodexFinalResponse(body string) ([]byte, bool) {
return nil, false
}
// reconstructResponseOutputFromSSE scans raw SSE body text for delta events and
// returns a JSON-encoded output array reconstructed from accumulated deltas.
// Returns (nil, false) if no content was found in deltas.
func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) {
acc := apicompat.NewBufferedResponseAccumulator()
lines := strings.Split(bodyText, "\n")
for _, line := range lines {
data, ok := extractOpenAISSEDataLine(line)
if !ok || data == "" || data == "[DONE]" {
continue
}
var event apicompat.ResponsesStreamEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
acc.ProcessEvent(&event)
}
if !acc.HasContent() {
return nil, false
}
output := acc.BuildOutput()
outputJSON, err := json.Marshal(output)
if err != nil {
return nil, false
}
return outputJSON, true
}
func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage {
usage := &OpenAIUsage{}
lines := strings.Split(body, "\n")