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:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -372,3 +373,119 @@ func generateChatCmplID() string {
|
||||
_, _ = rand.Read(b)
|
||||
return "chatcmpl-" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BufferedResponseAccumulator: accumulates SSE delta events for non-streaming
|
||||
// paths where the terminal event may have empty output.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type bufferedFuncCall struct {
|
||||
CallID string
|
||||
Name string
|
||||
Args strings.Builder
|
||||
}
|
||||
|
||||
// BufferedResponseAccumulator collects content from Responses SSE delta events
|
||||
// so that non-streaming handlers can reconstruct output when the terminal event
|
||||
// (response.completed / response.done) carries an empty output array.
|
||||
type BufferedResponseAccumulator struct {
|
||||
text strings.Builder
|
||||
reasoning strings.Builder
|
||||
funcCalls []bufferedFuncCall
|
||||
outputIndexToFuncIdx map[int]int
|
||||
}
|
||||
|
||||
// NewBufferedResponseAccumulator returns an initialised accumulator.
|
||||
func NewBufferedResponseAccumulator() *BufferedResponseAccumulator {
|
||||
return &BufferedResponseAccumulator{
|
||||
outputIndexToFuncIdx: make(map[int]int),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessEvent inspects a single Responses SSE event and accumulates any
|
||||
// content it carries. Only delta events that contribute to the final output
|
||||
// are handled; all other event types are silently ignored.
|
||||
func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) {
|
||||
switch event.Type {
|
||||
case "response.output_text.delta":
|
||||
if event.Delta != "" {
|
||||
_, _ = a.text.WriteString(event.Delta)
|
||||
}
|
||||
case "response.output_item.added":
|
||||
if event.Item != nil && event.Item.Type == "function_call" {
|
||||
idx := len(a.funcCalls)
|
||||
a.outputIndexToFuncIdx[event.OutputIndex] = idx
|
||||
a.funcCalls = append(a.funcCalls, bufferedFuncCall{
|
||||
CallID: event.Item.CallID,
|
||||
Name: event.Item.Name,
|
||||
})
|
||||
}
|
||||
case "response.function_call_arguments.delta":
|
||||
if event.Delta != "" {
|
||||
if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok {
|
||||
_, _ = a.funcCalls[idx].Args.WriteString(event.Delta)
|
||||
}
|
||||
}
|
||||
case "response.reasoning_summary_text.delta":
|
||||
if event.Delta != "" {
|
||||
_, _ = a.reasoning.WriteString(event.Delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HasContent reports whether any content has been accumulated.
|
||||
func (a *BufferedResponseAccumulator) HasContent() bool {
|
||||
return a.text.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0
|
||||
}
|
||||
|
||||
// BuildOutput constructs a []ResponsesOutput from the accumulated delta
|
||||
// content. The order matches what ResponsesToChatCompletions expects:
|
||||
// reasoning → message → function_calls.
|
||||
func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput {
|
||||
var out []ResponsesOutput
|
||||
|
||||
if a.reasoning.Len() > 0 {
|
||||
out = append(out, ResponsesOutput{
|
||||
Type: "reasoning",
|
||||
Summary: []ResponsesSummary{{
|
||||
Type: "summary_text",
|
||||
Text: a.reasoning.String(),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
if a.text.Len() > 0 {
|
||||
out = append(out, ResponsesOutput{
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: []ResponsesContentPart{{
|
||||
Type: "output_text",
|
||||
Text: a.text.String(),
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
for i := range a.funcCalls {
|
||||
out = append(out, ResponsesOutput{
|
||||
Type: "function_call",
|
||||
CallID: a.funcCalls[i].CallID,
|
||||
Name: a.funcCalls[i].Name,
|
||||
Arguments: a.funcCalls[i].Args.String(),
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// SupplementResponseOutput fills resp.Output from accumulated delta content
|
||||
// when the terminal event delivered an empty output array. If resp.Output is
|
||||
// already populated, this is a no-op (preserves backward compatibility).
|
||||
func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesResponse) {
|
||||
if resp == nil || len(resp.Output) > 0 {
|
||||
return
|
||||
}
|
||||
if !a.HasContent() {
|
||||
return
|
||||
}
|
||||
resp.Output = a.BuildOutput()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user