Skip to content

fix(openai): normalize chat stream termination - #6769

Open
ZHOUKAILIAN wants to merge 3 commits into
QuantumNous:mainfrom
ZHOUKAILIAN:fix/openai-abnormal-stream-finalization-6239
Open

fix(openai): normalize chat stream termination#6769
ZHOUKAILIAN wants to merge 3 commits into
QuantumNous:mainfrom
ZHOUKAILIAN:fix/openai-abnormal-stream-finalization-6239

Conversation

@ZHOUKAILIAN

@ZHOUKAILIAN ZHOUKAILIAN commented Aug 11, 2026

Copy link
Copy Markdown

变更描述 / Description

修复 OpenAI Chat Completions 流结束信号不完整时的兼容处理。

  • 上游明确发送 [DONE]、但没有非空 finish_reason 时,补发终止 chunk:普通文本使用 stop,工具调用使用 tool_calls,再发送 usage 和 [DONE]
  • 上游已有非空 finish_reason、但省略 [DONE] 时,保留兼容收尾行为。
  • 上游仅 EOF、timeout 或 scanner error,且没有 finish_reason[DONE] 时,不伪造成功结尾,不发送潜在残缺末块、usage 或 [DONE],并记录流异常状态。

这样可以兼容严格要求 finish_reason 的客户端(包括 Pi),同时避免把真正截断的响应误判成完整对话。

本 PR 由 AI 辅助完成,并已由提交者人工检查代码、测试和变更范围。

Type of change

  • Bug fix
  • New feature
  • Performance optimization / Refactor
  • Documentation

Related Issue

Checklist

  • 人工确认:已理解并人工检查本次实现。
  • 非重复提交:已核对相关 Issues 与 PRs;fix: finalize Claude streams after upstream EOF #6714/fix: finalize Claude streams after upstream EOF #6721 主要处理 Claude 流结束事件,本 PR 针对 OpenAI Chat 流的异常终止信号。
  • Bug fix 说明:已关联对应 Issue。
  • 变更理解:仅在真实 [DONE] 存在时补终止原因,EOF/timeout 不合成成功信号。
  • 范围聚焦:仅修改 OpenAI Chat 流处理和回归测试。
  • 本地验证:已完成测试、race、vet 和 build 验证。
  • 安全合规:未加入敏感凭据。

Proof of Work

  • make test 通过
  • go test ./relay/... -count=1 通过
  • go test -race ./relay/channel/openai -run TestOaiStreamHandler -count=1 通过
  • GOWORK=off go vet ./... 通过
  • GOWORK=off go build ./... 通过
  • relaykit 独立 GOWORK=off go vet ./...GOWORK=off go build ./... 通过

新增回归覆盖:

  • EOF/timeout 且没有终止信号时不收尾
  • 正常 finish_reason 收尾
  • [DONE] 缺少 finish_reason 时补 stop
  • [DONE] 后存在 usage-only chunk 时先补终止 chunk
  • 工具调用缺少 finish_reason 时补 tool_calls

Summary by CodeRabbit

  • Bug Fixes

    • Improved streaming completion detection using explicit completion signals.
    • Incomplete streams now report an error instead of being treated as successfully completed.
    • Final responses and completion metadata are only sent for completed streams.
    • Preserved usage information when streams end unexpectedly.
    • Added missing completion metadata for completed streams, including standard stops and tool calls.
    • Improved ordering of completion metadata and trailing usage information.
  • Tests

    • Added coverage for normal completion, explicit termination, unexpected EOF, timeout scenarios, multi-choice streams, tool calls, and metadata ordering.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The OpenAI streaming handler now tracks per-choice finish reasons and classifies stream termination. It finalizes responses only for completed streams. Incomplete EOF and timeout cases preserve partial output, record errors, and omit usage and [DONE].

Changes

OpenAI stream completion

Layer / File(s) Summary
Terminal signal detection and termination tests
relay/channel/openai/relay-openai.go, relay/channel/openai/relay_openai_stream_test.go
The handler tracks non-empty finish_reason values and tool calls. It classifies [DONE], EOF, missing status, and abnormal endings. Tests cover incomplete EOF, timeout, explicit finish reasons, multi-choice completion, and EOF after a finish reason.
Completion-gated finalization
relay/channel/openai/relay-openai.go, relay/channel/openai/relay_openai_stream_test.go
Completed streams synthesize stop or tool_calls metadata when needed and order terminal data around trailing usage. Incomplete streams record errors and skip final-response processing, usage, and [DONE]. Tests verify terminal metadata and ordering.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: calcium-ion

Poem

A rabbit checks each stream-bound sign,
And keeps partial output in line.
When endings prove the stream is done,
It sends the final chunk and [DONE].
When signals fail, errors stay clear—
No false usage follows near.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the OpenAI chat stream termination fix, which is the primary change.
Linked Issues check ✅ Passed The changes prevent abnormal stream endings from producing successful finalization, usage, or [DONE], while preserving valid completion handling for issue #6239.
Out of Scope Changes check ✅ Passed The implementation and regression tests directly support stream termination handling and the requirements of issue #6239.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ZHOUKAILIAN ZHOUKAILIAN changed the title fix(openai): do not finalize incomplete chat streams fix(openai): normalize chat stream termination Aug 11, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
relay/channel/openai/relay-openai.go (1)

137-139: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Track completion for each choice.

hasOpenAIStreamFinishReason sets sawFinishReason when any choice has a finish reason. EOF then becomes a successful stream even if another choice has no finish reason.

Track started and finished choice indexes. Treat EOF as complete only when every observed choice has a non-empty finish reason. Add a deterministic two-choice EOF regression test where one choice is finished and the other is partial.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/channel/openai/relay-openai.go` around lines 137 - 139, Update the
OpenAI stream completion logic around hasOpenAIStreamFinishReason to track
observed choice indexes and which of them have non-empty finish reasons, rather
than using the single sawFinishReason flag. Treat EOF as successful only when
every started choice is finished, and add a deterministic two-choice EOF
regression test covering one finished choice and one partial choice.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@relay/channel/openai/relay-openai.go`:
- Around line 137-139: Update the OpenAI stream completion logic around
hasOpenAIStreamFinishReason to track observed choice indexes and which of them
have non-empty finish reasons, rather than using the single sawFinishReason
flag. Treat EOF as successful only when every started choice is finished, and
add a deterministic two-choice EOF regression test covering one finished choice
and one partial choice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d2aaff35-01e3-40ec-a741-d1d7d2198f5e

📥 Commits

Reviewing files that changed from the base of the PR and between 9b51e85 and 5b53301.

📒 Files selected for processing (2)
  • relay/channel/openai/relay-openai.go
  • relay/channel/openai/relay_openai_stream_test.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
relay/channel/openai/relay-openai.go (1)

259-264: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Flush the buffered choices payload on incomplete streams.

The callback forwards a payload only when the next SSE event arrives. If EOF or timeout follows the final choices payload, Lines 259-264 return without forwarding it. This drops final partial content, including the "partial" content in the multi-choice test.

  • relay/channel/openai/relay-openai.go#L259-L264: Before returning, forward the buffered payload when it contains choices. Do not forward usage, call HandleFinalResponse, or emit [DONE].
  • relay/channel/openai/relay_openai_stream_test.go#L262-L264: Assert that "partial" is present. Do not reject the existing "finish_reason":"stop" for choice 0. Assert that no additional synthesized terminal choice is emitted and that [DONE] is absent.

As per coding guidelines, backend tests must protect real behavior, API contracts, billing/accounting invariants, compatibility, or regression paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/channel/openai/relay-openai.go` around lines 259 - 264, The
incomplete-stream return path in relay/channel/openai/relay-openai.go lines
259-264 must flush the buffered choices payload before returning: forward it
only when choices are present, without forwarding usage, calling
HandleFinalResponse, or emitting [DONE]. Update
relay/channel/openai/relay_openai_stream_test.go lines 262-264 to assert partial
content is preserved, retain choice 0’s existing finish_reason stop assertion,
and verify no synthesized terminal choice or [DONE] is emitted.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@relay/channel/openai/relay-openai.go`:
- Around line 198-204: Update the synthesizedFinishResponse construction so it
occurs after handleLastResponse has parsed the final payload, or populate the
required metadata beforehand. Ensure Id, Created, Model, and SystemFingerprint
on the synthesized terminal chunk match the preceding completion chunks rather
than using zero values.

---

Outside diff comments:
In `@relay/channel/openai/relay-openai.go`:
- Around line 259-264: The incomplete-stream return path in
relay/channel/openai/relay-openai.go lines 259-264 must flush the buffered
choices payload before returning: forward it only when choices are present,
without forwarding usage, calling HandleFinalResponse, or emitting [DONE].
Update relay/channel/openai/relay_openai_stream_test.go lines 262-264 to assert
partial content is preserved, retain choice 0’s existing finish_reason stop
assertion, and verify no synthesized terminal choice or [DONE] is emitted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 145e614e-0432-403b-838a-0844976c9742

📥 Commits

Reviewing files that changed from the base of the PR and between 5b53301 and 7ca5c6d.

📒 Files selected for processing (2)
  • relay/channel/openai/relay-openai.go
  • relay/channel/openai/relay_openai_stream_test.go

Comment on lines +198 to +204
synthesizedFinishResponse = &dto.ChatCompletionsStreamResponse{
Id: responseId,
Object: "chat.completion.chunk",
Created: createAt,
Model: model,
}
synthesizedFinishResponse.SetSystemFingerprint(systemFingerprint)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Populate synthesized chunk metadata after parsing the last payload.

Lines 198-204 construct synthesizedFinishResponse before Lines 221-225 call handleLastResponse. Therefore, Id, Created, and SystemFingerprint use zero values in the synthesized terminal chunk.

Move the synthesis block after handleLastResponse, or parse this metadata before constructing the response. The synthesized chunk must identify the same completion as preceding chunks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/channel/openai/relay-openai.go` around lines 198 - 204, Update the
synthesizedFinishResponse construction so it occurs after handleLastResponse has
parsed the final payload, or populate the required metadata beforehand. Ensure
Id, Created, Model, and SystemFingerprint on the synthesized terminal chunk
match the preceding completion chunks rather than using zero values.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] OpenAI Chat 流已有部分输出后超时仍补 usage + [DONE],调用方无法感知异常

1 participant