Skip to content

fix(relay): handle DeepSeek V4 streaming edge cases in OpenAI→Claude … - #6629

Open
PengJunchen wants to merge 1 commit into
QuantumNous:mainfrom
PengJunchen:fix/deepseek-v4-streaming-convert-bugs
Open

fix(relay): handle DeepSeek V4 streaming edge cases in OpenAI→Claude …#6629
PengJunchen wants to merge 1 commit into
QuantumNous:mainfrom
PengJunchen:fix/deepseek-v4-streaming-convert-bugs

Conversation

@PengJunchen

@PengJunchen PengJunchen commented Aug 3, 2026

Copy link
Copy Markdown

Important

  • This PR was AI-assisted. The code was generated with AI tooling and reviewed/curated by a human developer.

📝 变更描述 / Description

DeepSeek V4-Flash (released 2026-07-31) introduced two streaming edge cases that break Claude Code when responses are converted from OpenAI format to Anthropic /v1/messages format via StreamResponseOpenAI2Claude.

Bug #1: Content block not found (tool-call scenario)

Root cause: The sglang dsv4 parser emits a trailing content='\n' chunk after tool_use completes. The converter mechanically created a new text block from this trailing content, violating Anthropic's streaming block lifecycle (tool_use must be the final content block). Claude Code's content-block index tracker rejected the invalid sequence.

Fix: Discard text-only chunks when LastMessagesType == Tools. Reasoning content is still allowed (switches to thinking block). Applied in both the first-chunk and subsequent-chunk paths.

Bug #2: Empty or malformed response (HTTP 200) (max reasoning effort scenario)

Root cause: DeepSeek V4-Flash shares a single max_tokens budget between thinking and content (unlike V3's separate CoT budget). With reasoning_effort=max (automatically applied to Claude Code by DeepSeek's API), the entire budget can be consumed by thinking, leaving content empty (finish_reason=length, content empty). The converted stream had only thinking blocks with no text/tool_use block, which Claude Code rejects as malformed.

Fix: Added a HasContentBlock flag to ClaudeConvertInfo that tracks whether any non-thinking content block (text or tool_use) has been emitted. At all four stream termination paths (first-chunk-done, usage-only, doneChunk, Finalize), if no content block was emitted, an empty text block (content_block_start + content_block_stop) is appended as fallback.

Why HasContentBlock over LastMessagesType == Thinking: The cumulative flag avoids false positives (e.g. text → thinking → done would incorrectly trigger fallback with the state-based check) and false negatives (completely empty streams).

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix)
  • ✨ 新功能 (New feature)
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

  • No existing issue found.

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 我已搜索现有的 Issues 与 PRs,确认不是重复提交。
  • Bug fix 说明: Bug 你好,大佬,普通用户登录后多了个 用户管理界面 ,点击显示权限不足 #1 and 增加自製造聯網渠道 #2 are real protocol-conversion defects triggered by DeepSeek V4-Flash's new streaming behavior, not design trade-offs.
  • 变更理解: 我已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 本 PR 未包含任何与当前任务无关的代码改动。
  • 本地验证: 已在本地运行并通过测试,维护者可以据此复核结果。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / Proof of Work

Tests (all passing)

=== RUN   TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks
--- PASS: TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks (0.00s)
=== RUN   TestStreamResponseOpenAI2ClaudeDiscardsTrailingTextAfterToolUse
--- PASS: TestStreamResponseOpenAI2ClaudeDiscardsTrailingTextAfterToolUse (0.00s)
=== RUN   TestStreamResponseOpenAI2ClaudeAppendsEmptyTextForThinkingOnlyStream
--- PASS: TestStreamResponseOpenAI2ClaudeAppendsEmptyTextForThinkingOnlyStream (0.00s)
=== RUN   TestFinalizeStreamResponseOpenAI2ClaudeAppendsEmptyTextForThinkingOnly
--- PASS: TestFinalizeStreamResponseOpenAI2ClaudeAppendsEmptyTextForThinkingOnly (0.00s)
=== RUN   TestStreamResponseOpenAI2ClaudeThinkingThenTextDoesNotGetFallback
--- PASS: TestStreamResponseOpenAI2ClaudeThinkingThenTextDoesNotGetFallback (0.00s)
PASS
ok      github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat  0.009s

Build verification

  • relaykit independent build: cd relaykit && GOWORK=off go build ./... OK
  • Root module build: go build ./service/... ./relay/... OK
  • Full test suite: go test ./relayconvert/... ./service/... ./relay/common/... OK

Regression test coverage

Test Bug Scenario
TestStreamResponseOpenAI2ClaudeDiscardsTrailingTextAfterToolUse #1 text → tool_use → trailing '\n' → finish: trailing text discarded
TestStreamResponseOpenAI2ClaudeAppendsEmptyTextForThinkingOnlyStream #2 thinking → thinking → finish(length): empty text fallback appended
TestFinalizeStreamResponseOpenAI2ClaudeAppendsEmptyTextForThinkingOnly #2 thinking → Finalize (no finish_reason): fallback appended
TestStreamResponseOpenAI2ClaudeThinkingThenTextDoesNotGetFallback #2 negative thinking → text → finish: NO fallback (prevents false positive)

Summary by CodeRabbit

  • Bug Fixes

    • Improved streaming responses when messages contain only reasoning or tool-use content.
    • Added an empty text block when required for thinking-only responses.
    • Prevented trailing text from appearing after tool-use blocks.
    • Preserved valid text when reasoning is followed by regular content.
  • Tests

    • Added coverage for streaming and finalization edge cases.

…convert

DeepSeek V4-Flash (released 2026-07-31) introduced two streaming edge
cases that break Claude Code via the OpenAI→Claude stream converter:

1. "Content block not found": sglang dsv4 parser emits trailing
   content='\n' after tool_use completes. The converter opened a new
   text block, violating Anthropic's block lifecycle (tool_use must be
   final). Fix: discard text-only chunks when LastMessagesType==Tools.

2. "Empty/malformed response (HTTP 200)": with max reasoning_effort,
   V4 exhausts the entire max_tokens budget on thinking (finish_reason
   =length, content empty). The stream had only thinking blocks and no
   text/tool_use block, which Claude Code rejects. Fix: track
   HasContentBlock flag and append an empty text block fallback at all
   stream termination paths when no content block was emitted.

The HasContentBlock flag (vs checking LastMessagesType) avoids false
positives (text→thinking→done) and false negatives (empty stream).
Bug QuantumNous#1 guard is applied in both first-chunk and subsequent-chunk paths.
Bug QuantumNous#2 fallback covers first-chunk-done, usage-only, doneChunk, and
Finalize termination paths.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The OpenAI-to-Claude streaming converter now tracks emitted text and tool-use blocks. It discards trailing text after tool use and inserts a closed empty text block for thinking-only streams before completion or finalization.

Changes

Claude streaming content handling

Layer / File(s) Summary
Content state and fallback
relaykit/relayconvert/convmeta/meta.go, relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
ClaudeConvertInfo tracks emitted content blocks. The converter can append a closed empty text block when no content exists.
Streaming path integration
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
Tool-use and text paths update content state. Trailing text after tool use is discarded. Fallback emission runs before terminal events and explicit finalization.
Streaming regression coverage
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
Tests cover trailing text, thinking-only streams, finalization, and duplicate fallback prevention.

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

Sequence Diagram(s)

sequenceDiagram
  participant OpenAIStream
  participant StreamResponseOpenAI2Claude
  participant ClaudeConvertInfo
  participant ClaudeEvents
  OpenAIStream->>StreamResponseOpenAI2Claude: send text, reasoning, or tool-use chunks
  StreamResponseOpenAI2Claude->>ClaudeConvertInfo: record emitted content
  StreamResponseOpenAI2Claude->>ClaudeEvents: emit valid Claude content events
  StreamResponseOpenAI2Claude->>ClaudeEvents: append empty text block before termination when needed
Loading

Possibly related PRs

Suggested reviewers: calcium-ion

Poem

I’m a rabbit reviewing streams in the night,
Thinking hops on, then text comes to light.
Tool blocks stand firm; stray text fades away,
Empty blocks appear when thoughts end the day.
The Claude stream closes, neat and bright.

🚥 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 relay streaming fix and the DeepSeek V4 OpenAI-to-Claude edge cases covered by the changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

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

🧹 Nitpick comments (2)
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go (1)

47-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fix the stale comment describing stopOpenBlocks behavior.

The comment says stopOpenBlocks has already advanced the index for thinking blocks. stopOpenBlocks (lines 20-36) only reads state.Index; it never mutates it. The +1 on line 56 is what accounts for the still-open block, not a prior advance done elsewhere. Only stopOpenBlocksAndAdvance mutates state.Index. Rewrite the comment so it does not imply stopOpenBlocks performs an advance. A future maintainer who trusts this comment could remove the +1 and reintroduce an index collision.

📝 Proposed comment fix
-	// stopOpenBlocks has already advanced the index for thinking blocks, so
-	// state.Index points to the next free slot. For the empty-stream case
-	// (LastMessagesType == None), state.Index is 0.
+	// stopOpenBlocks does not mutate state.Index. For a still-open text or
+	// thinking block, state.Index still points at that block's index, so the
+	// fallback text block must use state.Index + 1. For the empty-stream case
+	// (LastMessagesType == None), state.Index is already the next free slot (0).
🤖 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 `@relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go` around
lines 47 - 57, Rewrite the comment in appendEmptyTextFallback to accurately
state that stopOpenBlocks only reads state.Index, while the +1 adjustment
accounts for the still-open block; identify stopOpenBlocksAndAdvance as the
helper that mutates the index. Keep the existing index calculation unchanged.
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go (1)

226-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard ContentBlock field access with require.NotNil before dereferencing .Type.

Several new assertions access .ContentBlock.Type without first confirming ContentBlock is non-nil: line 245 (textResponses[1].ContentBlock.Type), line 265 (toolResponses[1].ContentBlock.Type), line 321 (thinkingResponses[1].ContentBlock.Type), and line 393 (finalResponses[1].ContentBlock.Type). require.Len only guarantees slice length, not that ContentBlock is populated. Line 354-355 in this same diff shows the correct pattern: require.NotNil(t, finishResponses[1].ContentBlock) before assert.Equal(t, "text", finishResponses[1].ContentBlock.Type). Apply the same guard at the other four sites so a regression fails with a clear assertion message instead of a nil-pointer panic.

As per coding guidelines, "New or substantially rewritten tests must use testify/require for setup and fatal assertions and testify/assert for non-fatal checks."

✅ Proposed fix (repeat for each of the 4 sites)
-	assert.Equal(t, "text", textResponses[1].ContentBlock.Type)
+	require.NotNil(t, textResponses[1].ContentBlock)
+	assert.Equal(t, "text", textResponses[1].ContentBlock.Type)

Also applies to: 296-362, 364-398

🤖 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 `@relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go`
around lines 226 - 294, In
TestStreamResponseOpenAI2ClaudeDiscardsTrailingTextAfterToolUse and the other
affected stream-response tests, add require.NotNil assertions for each
ContentBlock before accessing ContentBlock.Type at the textResponses,
toolResponses, thinkingResponses, and finalResponses sites. Preserve the
existing assert.Equal checks and follow the established require.NotNil pattern
used for finishResponses.

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.

Nitpick comments:
In `@relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go`:
- Around line 226-294: In
TestStreamResponseOpenAI2ClaudeDiscardsTrailingTextAfterToolUse and the other
affected stream-response tests, add require.NotNil assertions for each
ContentBlock before accessing ContentBlock.Type at the textResponses,
toolResponses, thinkingResponses, and finalResponses sites. Preserve the
existing assert.Equal checks and follow the established require.NotNil pattern
used for finishResponses.

In `@relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go`:
- Around line 47-57: Rewrite the comment in appendEmptyTextFallback to
accurately state that stopOpenBlocks only reads state.Index, while the +1
adjustment accounts for the still-open block; identify stopOpenBlocksAndAdvance
as the helper that mutates the index. Keep the existing index calculation
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 89107ca1-c48e-4e5d-a2ff-0225e7bed4a1

📥 Commits

Reviewing files that changed from the base of the PR and between 0ab0202 and b9e1dda.

📒 Files selected for processing (3)
  • relaykit/relayconvert/convmeta/meta.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go
  • relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go

@seefs001 seefs001 closed this Aug 4, 2026
@seefs001 seefs001 reopened this Aug 4, 2026
neimaravila pushed a commit to neimaravila/new-api that referenced this pull request Aug 6, 2026
DeepSeek V4 breaks Claude Code two ways through the OpenAI->Claude stream
conversion: the sglang dsv4 parser emits a trailing content='\n' after
tool_calls, which puts a text block after tool_use and trips "Content block
not found"; and a reasoning run that spends the whole max_tokens budget on
thinking closes with no non-thinking content block at all, which Anthropic's
streaming protocol does not allow.

PR QuantumNous#6629 fixes both. It collides with PR QuantumNous#6394, which we already carry, in
two places: the ClaudeConvertInfo struct tail, where both append a field, and
the tool-call branch, where QuantumNous#6394 inserted the ToolCallOpenIndexes
initialisation the new HasContentBlock assignment was anchored to. Kept both
fields and re-anchored the assignment; the rest is verbatim. The fixes do not
overlap in behaviour, since appendEmptyTextFallback only runs when no text or
tool_use block was ever emitted.

Verified over production with scripts/apply-patches.sh: all fourteen apply in
order, relaykit builds with GOWORK=off, and both PRs' tests pass together --
ParallelToolCallsHaveValidBlockLifecycle and ReplayedToolNameDoesNotDuplicate
Start from QuantumNous#6394 alongside DiscardsTrailingTextAfterToolUse,
AppendsEmptyTextForThinkingOnlyStream and ThinkingThenTextDoesNotGetFallback
from QuantumNous#6629.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ZsKS6An5YHvZTW3cpVTNX
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