fix(relay): handle DeepSeek V4 streaming edge cases in OpenAI→Claude … - #6629
fix(relay): handle DeepSeek V4 streaming edge cases in OpenAI→Claude …#6629PengJunchen wants to merge 1 commit into
Conversation
…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.
WalkthroughThe 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. ChangesClaude streaming content handling
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
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go (1)
47-57: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFix the stale comment describing
stopOpenBlocksbehavior.The comment says stopOpenBlocks has already advanced the index for thinking blocks.
stopOpenBlocks(lines 20-36) only readsstate.Index; it never mutates it. The+1on line 56 is what accounts for the still-open block, not a prior advance done elsewhere. OnlystopOpenBlocksAndAdvancemutatesstate.Index. Rewrite the comment so it does not implystopOpenBlocksperforms an advance. A future maintainer who trusts this comment could remove the+1and 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 winGuard
ContentBlockfield access withrequire.NotNilbefore dereferencing.Type.Several new assertions access
.ContentBlock.Typewithout first confirmingContentBlockis 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.Lenonly guarantees slice length, not thatContentBlockis populated. Line 354-355 in this same diff shows the correct pattern:require.NotNil(t, finishResponses[1].ContentBlock)beforeassert.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
📒 Files selected for processing (3)
relaykit/relayconvert/convmeta/meta.gorelaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.gorelaykit/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
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
Important
📝 变更描述 / 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/messagesformat viaStreamResponseOpenAI2Claude.Bug #1:
Content block not found(tool-call scenario)Root cause: The sglang dsv4 parser emits a trailing
content='\n'chunk aftertool_usecompletes. 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_tokensbudget between thinking and content (unlike V3's separate CoT budget). Withreasoning_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
HasContentBlockflag toClaudeConvertInfothat 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
HasContentBlockoverLastMessagesType == Thinking: The cumulative flag avoids false positives (e.g.text → thinking → donewould incorrectly trigger fallback with the state-based check) and false negatives (completely empty streams).🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
📸 运行证明 / Proof of Work
Tests (all passing)
Build verification
cd relaykit && GOWORK=off go build ./...OKgo build ./service/... ./relay/...OKgo test ./relayconvert/... ./service/... ./relay/common/...OKRegression test coverage
TestStreamResponseOpenAI2ClaudeDiscardsTrailingTextAfterToolUseTestStreamResponseOpenAI2ClaudeAppendsEmptyTextForThinkingOnlyStreamTestFinalizeStreamResponseOpenAI2ClaudeAppendsEmptyTextForThinkingOnlyTestStreamResponseOpenAI2ClaudeThinkingThenTextDoesNotGetFallbackSummary by CodeRabbit
Bug Fixes
Tests