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

@@ -876,3 +876,182 @@ func TestChatCompletionsStreamRoundTrip(t *testing.T) {
assert.Equal(t, "resp_rt", c.ID)
}
}
// ---------------------------------------------------------------------------
// BufferedResponseAccumulator tests
// ---------------------------------------------------------------------------
func TestBufferedResponseAccumulator_TextOnly(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"})
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ", world!"})
assert.True(t, acc.HasContent())
output := acc.BuildOutput()
require.Len(t, output, 1)
assert.Equal(t, "message", output[0].Type)
assert.Equal(t, "assistant", output[0].Role)
require.Len(t, output[0].Content, 1)
assert.Equal(t, "output_text", output[0].Content[0].Type)
assert.Equal(t, "Hello, world!", output[0].Content[0].Text)
}
func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) {
acc := NewBufferedResponseAccumulator()
// Add function call at output_index=1
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{
Type: "function_call",
CallID: "call_abc",
Name: "get_weather",
},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 1,
Delta: `{"city":`,
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 1,
Delta: `"NYC"}`,
})
assert.True(t, acc.HasContent())
output := acc.BuildOutput()
require.Len(t, output, 1)
assert.Equal(t, "function_call", output[0].Type)
assert.Equal(t, "call_abc", output[0].CallID)
assert.Equal(t, "get_weather", output[0].Name)
assert.Equal(t, `{"city":"NYC"}`, output[0].Arguments)
}
func TestBufferedResponseAccumulator_Reasoning(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "Step 1: "})
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "think about it"})
assert.True(t, acc.HasContent())
output := acc.BuildOutput()
require.Len(t, output, 1)
assert.Equal(t, "reasoning", output[0].Type)
require.Len(t, output[0].Summary, 1)
assert.Equal(t, "summary_text", output[0].Summary[0].Type)
assert.Equal(t, "Step 1: think about it", output[0].Summary[0].Text)
}
func TestBufferedResponseAccumulator_Mixed(t *testing.T) {
acc := NewBufferedResponseAccumulator()
// Reasoning first
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.reasoning_summary_text.delta", Delta: "I thought about it."})
// Then text
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "The answer is 42."})
// Then a tool call
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 2,
Item: &ResponsesOutput{
Type: "function_call",
CallID: "call_1",
Name: "verify",
},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 2,
Delta: `{}`,
})
assert.True(t, acc.HasContent())
output := acc.BuildOutput()
// Order: reasoning → message → function_calls
require.Len(t, output, 3)
assert.Equal(t, "reasoning", output[0].Type)
assert.Equal(t, "message", output[1].Type)
assert.Equal(t, "function_call", output[2].Type)
assert.Equal(t, "The answer is 42.", output[1].Content[0].Text)
assert.Equal(t, "verify", output[2].Name)
}
func TestBufferedResponseAccumulator_SupplementEmptyOutput(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"})
resp := &ResponsesResponse{
ID: "resp_1",
Status: "completed",
Output: nil, // empty output
Usage: &ResponsesUsage{InputTokens: 10, OutputTokens: 5},
}
acc.SupplementResponseOutput(resp)
require.Len(t, resp.Output, 1)
assert.Equal(t, "message", resp.Output[0].Type)
assert.Equal(t, "Hello", resp.Output[0].Content[0].Text)
// Usage should be untouched
assert.Equal(t, 10, resp.Usage.InputTokens)
}
func TestBufferedResponseAccumulator_NoSupplementWhenOutputExists(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "from deltas"})
resp := &ResponsesResponse{
ID: "resp_2",
Status: "completed",
Output: []ResponsesOutput{
{
Type: "message",
Content: []ResponsesContentPart{
{Type: "output_text", Text: "from terminal event"},
},
},
},
}
acc.SupplementResponseOutput(resp)
// Output should NOT be overwritten
require.Len(t, resp.Output, 1)
assert.Equal(t, "from terminal event", resp.Output[0].Content[0].Text)
}
func TestBufferedResponseAccumulator_EmptyDeltas(t *testing.T) {
acc := NewBufferedResponseAccumulator()
// Process events with empty delta — should not accumulate
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ""})
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.created"})
assert.False(t, acc.HasContent())
resp := &ResponsesResponse{ID: "resp_3", Status: "completed"}
acc.SupplementResponseOutput(resp)
assert.Nil(t, resp.Output)
}
func TestBufferedResponseAccumulator_IgnoresNonFunctionCallItems(t *testing.T) {
acc := NewBufferedResponseAccumulator()
// output_item.added with type "message" should be ignored
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "message"},
})
assert.False(t, acc.HasContent())
}