Skip to content

fix(openai): synthesize terminal chunk for complete streams - #2294

Open
Moyucharm wants to merge 1 commit into
looplj:unstablefrom
Moyucharm:fix/openai-missing-finish-reason
Open

fix(openai): synthesize terminal chunk for complete streams#2294
Moyucharm wants to merge 1 commit into
looplj:unstablefrom
Moyucharm:fix/openai-missing-finish-reason

Conversation

@Moyucharm

@Moyucharm Moyucharm commented Aug 22, 2026

Copy link
Copy Markdown

Summary

Some OpenAI-compatible upstreams emit the complete response content and a
usage chunk, then close the SSE stream without sending finish_reason or
[DONE]. Strict OpenAI-compatible clients interpret this as an incomplete
stream.

This affects coding-agent clients such as pi when the upstream response is
otherwise complete.

Changes

  • Add a stateful finalization layer to the OpenAI Chat Completions inbound
    transformer.
  • When the source stream ends cleanly after producing output and usage, synthesize
    a final chunk with an empty delta and:
    • finish_reason: "stop" for text/reasoning output
    • finish_reason: "tool_calls" for tool-call output
  • Emit [DONE] after a synthesized terminal chunk.
  • Preserve existing finish_reason and [DONE] behavior.
  • Do not synthesize success when the source has an error, usage is absent, or no
    meaningful output was produced, so genuinely truncated streams remain
    distinguishable.

Reproduction

The affected upstream shape is:

  1. One or more normal content/tool-call chunks.
  2. A choices: [] usage chunk.
  3. Clean SSE EOF without finish_reason or [DONE].

Testing

Added coverage for:

  • text streams missing finish_reason
  • tool-call streams missing finish_reason
  • reasoning-only output
  • existing finish reasons
  • existing [DONE]
  • upstream errors
  • missing usage
  • partially finished multi-choice streams

Validation:

  • cd llm && go test ./transformer/openai/...
  • cd llm && go vet ./transformer/openai/...
  • go test ./internal/server/api/... ./internal/server/orchestrator/...
  • go vet ./internal/server/api/... ./internal/server/orchestrator/...

Follow-up to #1924; this complements #2185 by handling complete weak-provider
streams rather than merely reporting incomplete streams.

Summary by CodeRabbit

  • Bug Fixes
    • Improved OpenAI streaming reliability by ensuring completed responses include the appropriate finish status.
    • Correctly identifies whether responses finish with normal text output or tool calls.
    • Preserves existing finish information and prevents duplicate completion events.
    • Prevents incomplete or errored streams from being incorrectly marked as complete.
  • Tests
    • Added coverage for text, tool-call, reasoning-only, incomplete, and error scenarios.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 68ff0896-7654-4536-9a1b-9c980719cf81

📥 Commits

Reviewing files that changed from the base of the PR and between 29aa13e and cbec8e3.

📒 Files selected for processing (3)
  • llm/transformer/openai/inbound.go
  • llm/transformer/openai/inbound_stream.go
  • llm/transformer/openai/inbound_stream_test.go

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The OpenAI transformer now wraps inbound response streams. The wrapper tracks output and usage, synthesizes missing finish chunks after eligible clean EOF, preserves existing completion markers, and emits a terminal done response.

Changes

OpenAI inbound stream completion

Layer / File(s) Summary
Track inbound response state
llm/transformer/openai/inbound_stream.go
The wrapper records response metadata, usage, output types, tool calls, finish reasons, and [DONE] markers while reading the source stream.
Synthesize completion and wire the transformer
llm/transformer/openai/inbound_stream.go, llm/transformer/openai/inbound.go, llm/transformer/openai/inbound_stream_test.go
After clean EOF with usage, the wrapper adds missing stop or tool_calls finish chunks and a done response. Tests cover text, tool calls, reasoning, existing completion events, errors, missing usage, and multiple choices. TransformStream applies the wrapper before event conversion.

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

Merge Risk: ⚪ Minimal · up to cbec8

The PR adds terminal chunks for otherwise complete streaming responses while preserving existing completion and error behavior; no actionable merge-blocking risk remains after normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant ResponseStream as llm.Response stream
  participant InboundStream as newOpenAIInboundStream
  participant TransformStream
  participant StreamEvent as httpclient.StreamEvent
  ResponseStream-->>InboundStream: clean EOF
  InboundStream->>InboundStream: synthesize missing finish chunks
  InboundStream->>InboundStream: append llm.DoneResponse
  InboundStream->>TransformStream: finalized responses
  TransformStream->>StreamEvent: convert response chunks
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: synthesizing a terminal chunk for complete OpenAI streams.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@cjjdaq

cjjdaq commented Aug 24, 2026

Copy link
Copy Markdown

Hi, thanks for working on synthesizing terminal chunks for incomplete streams. This directly relates to a problem I'm hitting on the OpenAI Responses + pass-through + streaming path:

With passThroughBody enabled on a channel, an OpenAI Responses streaming request ends with a STREAM_ERROR event ("stream ended without terminal event or completed response") even though the upstream (e.g. tokenrhythm/DeepSeek) sends a complete, well-formed response.completed. The SSE writer's terminalSeen stays false because the raw provider stream is fanned out through captureRawProviderStream into the raw channel, and the writer never observes the terminal event — then writeSSEStreamEnd (chat.go) injects a synthetic error event to the client.

Would this PR's synthesize-terminal-chunk idea also cover the Responses protocol + pass-through streaming path (not just Chat Completions)? If not, is there a preferred way to handle the terminal event in the raw pass-through stream (e.g. skip the terminalSeen failure injection when pass-through is applied)?

@Moyucharm

Copy link
Copy Markdown
Author

Thanks for the detailed report — this PR would not cover that path.

The change here is scoped to the OpenAI Chat Completions inbound transformer. It synthesizes a missing finish_reason and [DONE] after a transformed Chat Completions stream reaches a clean EOF with positive completion evidence. With passThroughBody enabled, applyPassThroughStream replaces the transformed stream with the raw provider stream, so the synthesized chunks from this PR are intentionally bypassed.

Your case also sounds different semantically: the upstream already emitted a valid response.completed, so nothing should be synthesized. The raw fan-out should preserve that event, and the SSE writer should recognize it through IsTerminalStreamEvent, either from the SSE event: response.completed field or from type: "response.completed" in the JSON data.

I would avoid skipping the terminalSeen check for all pass-through streams, since that would also make genuinely truncated Responses streams appear successful and would weaken the incomplete-stream protection added by #2185.

The preferred fix seems to be on the raw pass-through terminal-observation path:

verify that captureRawProviderStream delivers the response.completed event to the raw channel;

verify that the event retains its Type and Data when it reaches the writer;

ensure IsTerminalStreamEvent recognizes the provider’s exact wire shape;

add an end-to-end regression test for Responses + passThroughBody where response.completed is forwarded without an injected STREAM_ERROR.

Recent unstable already recognizes response.completed both from StreamEvent.Type and JSON data.type via #2068. If you are already on a version containing that change, could you share the exact final SSE frame emitted by the provider and the AxonHub version/commit? That would help determine whether this is an unrecognized event shape or an event-delivery issue in the fan-out path.

So I think this should be handled as a separate pass-through regression rather than extending this PR’s Chat Completions synthesis logic.

ssxwcz added a commit to ssxwcz/axonhub that referenced this pull request Aug 31, 2026
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.

2 participants