Skip to content

fix(claude-stream): always emit closing events when upstream sends finish_reason without usage - #5345

Open
leeyang1990 wants to merge 2 commits into
QuantumNous:mainfrom
leeyang1990:fix/claude-stream-hang-without-usage
Open

fix(claude-stream): always emit closing events when upstream sends finish_reason without usage#5345
leeyang1990 wants to merge 2 commits into
QuantumNous:mainfrom
leeyang1990:fix/claude-stream-hang-without-usage

Conversation

@leeyang1990

@leeyang1990 leeyang1990 commented Jun 6, 2026

Copy link
Copy Markdown

Problem

When converting OpenAI streaming responses to Claude format, if the OpenAI-compatible upstream sends a chunk with finish_reason but no usage field, and never follows up with a separate usage-only chunk, the Claude stream is silently truncated — message_delta and message_stop events are never emitted.

This violates the Claude Messages SSE protocol, which requires every stream to end with message_stop. As a result, Claude clients (e.g. Claude Code) hang indefinitely waiting for the stream to terminate.

Affected Upstreams

This is the protocol behavior of any OpenAI-compatible upstream that omits usage when the client doesn't pass stream_options.include_usage=true, which is fully compliant with the OpenAI spec. Confirmed reproducers:

  • LiteLLM proxy
  • Custom OpenAI-compatible gateways
  • Some Azure OpenAI deployments

Root Cause

In service/convert.go, the doneChunk branch deferred emitting closing events when usage was missing, expecting a follow-up usage-only chunk that never arrives:

if oaiUsage == nil {
    oaiUsage = info.ClaudeConvertInfo.Usage
    // Some upstreams emit finish_reason first, then send a final usage-only chunk.
    // Defer closing until usage is available so the final message_delta carries it.
    return claudeResponses  // ← stream silently truncated when no follow-up chunk
}

Fix

  1. service/convert.go: emit message_delta + message_stop immediately in the doneChunk branch, regardless of whether usage is available. The message_delta event includes usage when available, omits it otherwise — both forms are valid per Claude protocol spec.

  2. service/convert.go: export BuildClaudeUsageFromOpenAIUsage and StopReasonOpenAI2Claude (as wrappers) so the fallback path in relay/channel/openai/helper.go can reuse the conversion logic without duplication.

  3. relay/channel/openai/helper.go: add fallback closing events in HandleFinalResponse for cases where the stream is truncated without finish_reason at all (e.g. connection dropped, network timeout). Ensures message_stop is always emitted.

Why new-api Should Fix This

The Anthropic Messages SSE spec requires every stream to terminate with message_stop:

The end of the stream is indicated by a message_stop event.

new-api is a protocol converter (OpenAI ↔ Claude), and its contract is to accept any valid OpenAI input and produce valid Claude output. The OpenAI input here is fully spec-compliant — the fix belongs in new-api.

Testing

Verified locally with:

  • LiteLLM proxy → Claude Code: previously hung, now closes correctly
  • Direct OpenAI API → Claude Code: still works (no regression)
  • Tool calls / streaming with thinking blocks: still works (no regression)

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Improved reliability of Claude API integration by ensuring proper “stop/close” events are emitted during stream finalization, including fallback closing when needed.
    • Enhanced streaming conversion between OpenAI and Claude to immediately terminate messages when finish reasons arrive, with conditional attachment of usage data.
    • Improved stop-reason mapping to ensure a sensible default is used when an unmapped finish reason is encountered.

…nish_reason

## Problem

When converting OpenAI streaming responses to Claude format, if the
OpenAI-compatible upstream sends a chunk with `finish_reason` but no
`usage` field, and never follows up with a separate usage-only chunk,
the Claude stream is silently truncated — `message_delta` and
`message_stop` events are never emitted.

This violates the Claude Messages SSE protocol, which requires every
stream to end with `message_stop`. As a result, Claude clients (e.g.
Claude Code) hang indefinitely waiting for the stream to terminate.

## Affected Upstreams

This is the protocol behavior of any OpenAI-compatible upstream that
omits `usage` when the client doesn't pass `stream_options.include_usage=true`,
which is fully compliant with the OpenAI spec. Confirmed reproducers:

- LiteLLM proxy
- Custom OpenAI-compatible gateways
- Some Azure OpenAI deployments

## Root Cause

In `service/convert.go`, the `doneChunk` branch deferred emitting
closing events when `usage` was missing, expecting a follow-up
usage-only chunk that never arrives:

    if oaiUsage == nil {
        oaiUsage = info.ClaudeConvertInfo.Usage
        // Defer closing until usage is available...
        return claudeResponses  // ← stream silently truncated
    }

## Fix

1. **service/convert.go**: emit `message_delta` + `message_stop`
   immediately in the `doneChunk` branch, regardless of whether
   `usage` is available. The `message_delta` event includes usage
   when available, omits it otherwise — both forms are valid per
   Claude protocol spec.

2. **service/convert.go**: export `BuildClaudeUsageFromOpenAIUsage`
   and `StopReasonOpenAI2Claude` (as wrappers) so the fallback path
   in `relay/channel/openai/helper.go` can reuse the conversion
   logic without duplication.

3. **relay/channel/openai/helper.go**: add fallback closing events
   in `HandleFinalResponse` for cases where the stream is truncated
   without `finish_reason` at all (e.g. connection dropped, network
   timeout). Ensures `message_stop` is always emitted.

## Protocol Responsibility

The Anthropic Messages SSE spec requires every stream to terminate
with `message_stop`:

> The end of the stream is indicated by a `message_stop` event.
> https://docs.anthropic.com/en/api/messages-streaming

new-api is a protocol converter (OpenAI ↔ Claude), and its contract
is to accept any *valid* OpenAI input and produce *valid* Claude
output. The OpenAI input here is fully spec-compliant — the fix
belongs in new-api.

## Testing

Verified locally with:
- LiteLLM proxy (gpt-4) → Claude Code: previously hung, now closes correctly
- Direct OpenAI API → Claude Code: still works (no regression)
- Tool calls / streaming with thinking blocks: still works (no regression)
@coderabbitai

coderabbitai Bot commented Jun 6, 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a1c79e76-78f9-46ce-90df-92a6e9038f90

📥 Commits

Reviewing files that changed from the base of the PR and between 65ef692 and b0707ce.

📒 Files selected for processing (2)
  • relay/channel/openai/helper.go
  • service/convert.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • relay/channel/openai/helper.go
  • service/convert.go

Walkthrough

This PR updates Claude stream termination handling by exporting conversion helpers, changing streaming finish handling to emit closing events immediately, and adding a relay fallback that finalizes incomplete Claude responses.

Changes

Claude Stream Termination Safety

Layer / File(s) Summary
Export conversion helper functions
service/convert.go
GenerateClaudeStopBlocksForOpenInfo, BuildClaudeUsageFromOpenAIUsage, and StopReasonOpenAI2Claude are exported for cross-package reuse of Claude closing and mapping logic.
Update streaming converter finish-chunk handling
service/convert.go
StreamResponseOpenAI2Claude now stops open blocks and emits terminal message_delta and message_stop events immediately in both the finish-chunk and done-chunk paths.
Add fallback close-event emission in relay handler
relay/channel/openai/helper.go
HandleFinalResponse now emits Claude stop blocks, a terminal message_delta, and message_stop when conversion has not already completed.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • QuantumNous/new-api#1522: Overlaps in service/convert.go's OpenAI→Claude conversion flow, including StreamResponseOpenAI2Claude and stop-reason mapping.
  • QuantumNous/new-api#1531: Related to Claude content-block tracking and closing behavior in service/convert.go, especially LastMessagesType handling for tool blocks.
  • QuantumNous/new-api#4090: Also adjusts Claude stream finalization so terminal message_delta and message_stop events are emitted together.

Suggested reviewers

  • seefs001

Poem

🐰 A little stream went hop and glow,
With closing blocks laid just so.
The delta bowed, the stop bells rang,
And Claude’s last message safely sang.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. 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 accurately summarizes the main fix: emitting Claude closing events when finish_reason arrives without usage.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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.

@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: 2

🤖 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/helper.go`:
- Around line 173-200: The fallback path must close any open Claude content
blocks before emitting the terminal events: check
info.ClaudeConvertInfo.LastMessagesType for an active block and emit the
corresponding content_block_stop event (or call the existing stop-block helper
exported from service/convert.go) prior to sending the
message_delta/message_stop sequence via helper.ClaudeData and
dto.ClaudeResponse; reuse or expose the stop-block logic used in
service/convert.go (the logic around stopping open blocks) and invoke it here so
the event sequence remains valid.

In `@service/convert.go`:
- Around line 479-507: The fast-path that handles info.SendResponseCount == 1
currently only emits a message_delta when usage exists and thus can skip
emitting the terminal message_delta+message_stop when there's a finish_reason
but no usage; update that branch to call the same close-event logic used later
(use stopOpenBlocks(), compute stopReason via
stopReasonOpenAI2Claude(info.FinishReason) with fallback "end_turn", then append
a message_delta containing StopReason and optionally Usage via
buildClaudeUsageFromOpenAIUsage(oaiUsage), followed by a message_stop) so both
the first-chunk fast path and the later multi-chunk path produce the same
terminal message_delta and message_stop and set info.ClaudeConvertInfo.Done =
true.
🪄 Autofix (Beta)

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

Run ID: 08f8c2f6-65ab-44ee-a413-6afcf6438f3c

📥 Commits

Reviewing files that changed from the base of the PR and between adc390c and 65ef692.

📒 Files selected for processing (2)
  • relay/channel/openai/helper.go
  • service/convert.go

Comment thread relay/channel/openai/helper.go
Comment thread service/convert.go
Two issues raised by CodeRabbit review on QuantumNous#5345:

1. **fast-path missed terminal events when usage is absent**
   `service/convert.go` SendResponseCount==1 branch previously only
   emitted message_delta when usage was available, so a stream that
   started and finished in a single chunk with finish_reason but no
   usage would also hang Claude clients. Now emits message_delta
   (with or without usage) and message_stop in both paths, matching
   the multi-chunk doneChunk branch.

2. **fallback path didn't close open content blocks**
   `relay/channel/openai/helper.go` HandleFinalResponse fallback was
   sending message_delta / message_stop without first closing any
   open content_block, breaking the
   "content_block_start … content_block_stop" pairing required by
   Claude protocol.

   Added a new exported helper `service.GenerateClaudeStopBlocksForOpenInfo`
   that reuses the existing block-tracking state to emit the correct
   content_block_stop events. The fallback now closes open blocks
   before sending terminal events, keeping the stream sequence valid.

Both fixes maintain the same protocol contract: any valid OpenAI
stream input produces a fully spec-compliant Claude SSE output.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@leeyang1990

Copy link
Copy Markdown
Author

Thanks @coderabbitai for the review. Pushed b0707ce04 addressing both points:

  1. Fast-path now also emits terminal events without usageservice/convert.go SendResponseCount==1 branch was missing the same fallback. It now emits message_delta (with or without usage) + message_stop consistently with the multi-chunk path.

  2. Fallback closes open content blocks first — extracted the block-tracking logic into a new exported helper service.GenerateClaudeStopBlocksForOpenInfo to avoid duplication. The fallback in relay/channel/openai/helper.go now emits content_block_stop for any open block before terminal events, keeping the SSE sequence valid.

Compiles cleanly. Verified the same reproducer (LiteLLM → Claude Code with single-chunk finish_reason and no usage) now closes correctly.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the diff for commit b0707ce04
gh api repos/QuantumNous/new-api/commits/b0707ce04 --jq '.files[] | {filename: .filename, patch: .patch}' 2>/dev/null | head -400

Length of output: 3800



@leeyang1990 Thanks for the thorough follow-up — both fixes look correct after reviewing commit b0707ce04:

  1. GenerateClaudeStopBlocksForOpenInfo correctly handles all three block types:

    • LastMessageTypeText / LastMessageTypeThinking → single content_block_stop at info.ClaudeConvertInfo.Index
    • LastMessageTypeTools → iterates ToolCallBaseIndex … ToolCallBaseIndex + ToolCallMaxIndexOffset (inclusive), emitting one stop event per open tool call
    • LastMessageTypeNone / nil → returns empty slice (no-op, safe)
  2. The helper.go fallback properly resets LastMessagesType to None after emitting stop blocks, which prevents duplicate emission if the path is entered again — good defensive guard.

  3. The doneChunk path computing stopReason unconditionally before the oaiUsage != nil branch ensures terminal events are always emitted with a valid stop reason, satisfying the Anthropic SSE protocol.

The SSE event ordering is now correct: content_block_stop events → message_deltamessage_stop, in every termination path. Well done! 🐇 ✨

@kuangzhenfeng

Copy link
Copy Markdown

LiteLLM + new api + claude code 会无法使用,目前完成合并了吗

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