fix: replay DeepSeek reasoning_content on tool-turn history#71146
fix: replay DeepSeek reasoning_content on tool-turn history#71146snowzlm wants to merge 5 commits into
Conversation
Greptile SummaryThis PR injects a
Confidence Score: 4/5Safe to merge after addressing the missing empty-string fallback, which can still trigger a 400 for cross-model tool-call history with no text content. A P1 gap exists: the final fallback to "" is documented in the PR description but never assigned in code, leaving the original 400 possible for tool-call turns without any text content. All other logic is correct and the test suite covers the primary paths. src/agents/openai-transport-stream.ts — specifically the end of injectDeepSeekReasoningReplay where the "" fallback is missing. Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/agents/openai-transport-stream.ts
Line: 1792-1795
Comment:
**Missing empty-string fallback when all content sources are absent**
The PR description explicitly states Priority 6 is "None of the above → empty string `""` (verified safe against live API)", but the implementation never assigns `""`. If `record.reasoning_content` is absent (which is the whole problem being fixed), `record.reasoning` / `record.reasoning_text` are absent, no replay-signature key is present, and `record.content` is `null` or not a string (common for OpenAI tool-call messages with `content: null`), the function returns without setting `reasoning_content` at all. The field remains absent and DeepSeek will still return the 400 error this PR is meant to prevent. A final unconditional assignment of `record.reasoning_content = record.reasoning_content ?? "";` after the last `if` block would close the gap.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/agents/openai-transport-stream.ts
Line: 1723-1737
Comment:
**Incomplete `keysToDelete` when multiple replay-signature keys exist**
`extractReplayReasoningContent` early-returns as soon as it finds the first non-empty replay-signature key, leaving any subsequent matching keys out of the returned `keysToDelete` array. Those keys will survive the cleanup loop and be forwarded to the DeepSeek API. Concretely, if a message had two keys whose names satisfy `parseReplayReasoningSignatureKey` (e.g., two `rs_`-prefixed reasoning blocks), only the first and any empty-valued predecessors are deleted; the rest are orphaned. The fix is to collect all matching keys first, then pick the first non-empty value.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix: normalize DeepSeek reasoning replay..." | Re-trigger Greptile |
| if (typeof record.content === "string" && record.content.trim().length > 0) { | ||
| record.reasoning_content = record.content; | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing empty-string fallback when all content sources are absent
The PR description explicitly states Priority 6 is "None of the above → empty string "" (verified safe against live API)", but the implementation never assigns "". If record.reasoning_content is absent (which is the whole problem being fixed), record.reasoning / record.reasoning_text are absent, no replay-signature key is present, and record.content is null or not a string (common for OpenAI tool-call messages with content: null), the function returns without setting reasoning_content at all. The field remains absent and DeepSeek will still return the 400 error this PR is meant to prevent. A final unconditional assignment of record.reasoning_content = record.reasoning_content ?? ""; after the last if block would close the gap.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/openai-transport-stream.ts
Line: 1792-1795
Comment:
**Missing empty-string fallback when all content sources are absent**
The PR description explicitly states Priority 6 is "None of the above → empty string `""` (verified safe against live API)", but the implementation never assigns `""`. If `record.reasoning_content` is absent (which is the whole problem being fixed), `record.reasoning` / `record.reasoning_text` are absent, no replay-signature key is present, and `record.content` is `null` or not a string (common for OpenAI tool-call messages with `content: null`), the function returns without setting `reasoning_content` at all. The field remains absent and DeepSeek will still return the 400 error this PR is meant to prevent. A final unconditional assignment of `record.reasoning_content = record.reasoning_content ?? "";` after the last `if` block would close the gap.
How can I resolve this? If you propose a fix, please make it concise.| function extractReplayReasoningContent(record: Record<string, unknown>): { | ||
| content?: string; | ||
| keysToDelete: string[]; | ||
| } { | ||
| const keysToDelete: string[] = []; | ||
| for (const [key, value] of Object.entries(record)) { | ||
| if (!parseReplayReasoningSignatureKey(key)) { | ||
| continue; | ||
| } | ||
| keysToDelete.push(key); | ||
| if (typeof value === "string" && value.trim().length > 0) { | ||
| return { content: value, keysToDelete }; | ||
| } | ||
| } | ||
| return { keysToDelete }; |
There was a problem hiding this comment.
Incomplete
keysToDelete when multiple replay-signature keys exist
extractReplayReasoningContent early-returns as soon as it finds the first non-empty replay-signature key, leaving any subsequent matching keys out of the returned keysToDelete array. Those keys will survive the cleanup loop and be forwarded to the DeepSeek API. Concretely, if a message had two keys whose names satisfy parseReplayReasoningSignatureKey (e.g., two rs_-prefixed reasoning blocks), only the first and any empty-valued predecessors are deleted; the rest are orphaned. The fix is to collect all matching keys first, then pick the first non-empty value.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/openai-transport-stream.ts
Line: 1723-1737
Comment:
**Incomplete `keysToDelete` when multiple replay-signature keys exist**
`extractReplayReasoningContent` early-returns as soon as it finds the first non-empty replay-signature key, leaving any subsequent matching keys out of the returned `keysToDelete` array. Those keys will survive the cleanup loop and be forwarded to the DeepSeek API. Concretely, if a message had two keys whose names satisfy `parseReplayReasoningSignatureKey` (e.g., two `rs_`-prefixed reasoning blocks), only the first and any empty-valued predecessors are deleted; the rest are orphaned. The fix is to collect all matching keys first, then pick the first non-empty value.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a0a8a27f99
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (typeof value === "string" && value.trim().length > 0) { | ||
| return { content: value, keysToDelete }; | ||
| } |
There was a problem hiding this comment.
Delete all replay-signature keys before returning content
extractReplayReasoningContent returns as soon as it finds the first non-empty replay value, so any later replay-signature keys on the same assistant tool-call message are never collected into keysToDelete. This can happen when a single turn contains multiple reasoning replay items, and it leaves stray JSON-encoded keys in the outgoing message payload, which defeats the scrub step and can still trigger DeepSeek request validation errors on replayed history.
Useful? React with 👍 / 👎.
Addresses Greptile review feedback: P1: When all fallback sources are absent (e.g., OpenAI tool-call with content: null replayed against DeepSeek), reasoning_content remained undefined, still triggering 400. Add unconditional empty-string fallback. P2: extractReplayReasoningContent early-returned on the first non-empty replay-signature key, leaving subsequent matching keys unsanitized. Collect all matching keys before picking the first non-empty value.
|
Thanks for the contribution. Closing this as superseded by #71105, which has now landed the DeepSeek V4 catalog/default update plus the provider-owned The landed path keeps the behavior inside the DeepSeek plugin boundary, preserves legacy |
Problem
Under DeepSeek V4 thinking mode, when historical assistant tool-call messages lack the
reasoning_contentfield, the API returns a 400 error:Root Cause
convertMessagesdoes not generate areasoning_contentfield when converting assistant tool-call history to OpenAI format. DeepSeek thinking mode strictly requires this field to be present.Fix
In
buildOpenAICompletionsParams, afterconvertMessages, insertinjectDeepSeekReasoningReplayto injectreasoning_contentas a fallback for DeepSeek model assistant tool-call messages.Fallback priority (highest to lowest):
reasoning_content→ keep as-isreasoningfield → copyreasoning_textfield → copycontentextractioncontentstring → copy""(verified safe against live API)Only applied to models with
provider=deepseekorbaseUrlcontainingdeepseek.com.Verification
missingAfterPatch: 0Related
Requires companion fix in
@mariozechner/pi-aibuildParamsfor the pi-ai streaming path: snowzlm/pi-mono#1