Skip to content

Commit 653c905

Browse files
Merge pull request #165 from jhult/fix/deepseek-reasoning-content
fix(deepseek): preserve reasoning_content for V4 thinking-mode multi-turn
2 parents 5449913 + f3d9027 commit 653c905

2 files changed

Lines changed: 76 additions & 14 deletions

File tree

src-rust/crates/api/src/providers/openai_compat.rs

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ pub struct ProviderQuirks {
6464
/// reasoning output. Example: `Some("reasoning_content")` for DeepSeek.
6565
pub reasoning_field: Option<String>,
6666

67+
/// Whether this provider requires reasoning_content to be echoed back on
68+
/// subsequent turns in multi-turn conversations. DeepSeek V4 is currently
69+
/// the only provider with this requirement; most providers ignore this field.
70+
/// When false, reasoning is not included in outbound messages to save tokens.
71+
pub requires_reasoning_roundtrip: bool,
72+
6773
/// Hard cap on `max_tokens` sent to this provider. When the request
6874
/// carries a higher value it is silently clamped down to this limit.
6975
/// Use this for providers whose models have a lower output ceiling than
@@ -241,21 +247,23 @@ impl OpenAiCompatProvider {
241247
Self::apply_fix_tool_user_sequence(&mut messages);
242248
}
243249

244-
// For providers with a reasoning field (e.g. DeepSeek's
245-
// "reasoning_content"), inject reasoning text back into assistant
246-
// messages that contain tool calls. Non-tool-call turns omit the
247-
// field to save tokens.
248-
if let Some(ref field) = self.quirks.reasoning_field {
249-
Self::inject_reasoning_for_tool_turns(
250-
&mut messages,
251-
&request.messages,
252-
field,
253-
);
250+
// For providers that require reasoning_content in multi-turn conversations
251+
// (e.g. DeepSeek V4), inject reasoning text back into assistant messages
252+
// that contain tool calls. Non-tool-call turns omit the field to save tokens.
253+
// Only providers with requires_reasoning_roundtrip=true need this.
254+
if self.quirks.requires_reasoning_roundtrip {
255+
if let Some(ref field) = self.quirks.reasoning_field {
256+
Self::inject_reasoning_for_tool_turns(
257+
&mut messages,
258+
&request.messages,
259+
field,
260+
);
261+
}
254262
}
255263

256-
// Some providers (DeepSeek, Ollama) reject `content: null` on
257-
// assistant messages — replace with an empty string.
258-
if self.quirks.reasoning_field.is_some() || self.quirks.no_api_key_required {
264+
// Some providers (DeepSeek when reasoning_roundtrip enabled, Ollama) reject
265+
// `content: null` on assistant messages — replace with an empty string.
266+
if self.quirks.requires_reasoning_roundtrip || self.quirks.no_api_key_required {
259267
Self::ensure_content_not_null(&mut messages);
260268
}
261269

@@ -832,6 +840,12 @@ impl LlmProvider for OpenAiCompatProvider {
832840
let mut message_started = false;
833841
let mut message_id = String::from("unknown");
834842
let mut model_name = String::new();
843+
// Dedicated index for the Thinking content block emitted when a
844+
// provider streams a `reasoning_content` field (DeepSeek V4, etc.).
845+
// Chosen to avoid colliding with text (index 0) or tool calls
846+
// (1 + tc_index).
847+
const THINKING_BLOCK_INDEX: usize = usize::MAX - 100;
848+
let mut thinking_open = false;
835849
let mut tool_call_buffers: std::collections::HashMap<
836850
usize,
837851
(String, String, String),
@@ -961,8 +975,28 @@ impl LlmProvider for OpenAiCompatProvider {
961975
for field in &fields_to_check {
962976
if let Some(reasoning) = delta.get(*field).and_then(|v| v.as_str()) {
963977
if !reasoning.is_empty() {
978+
// Open a dedicated Thinking block on first
979+
// reasoning delta so the accumulator has a
980+
// partial to append into (see
981+
// StreamAccumulator::on_event). Without
982+
// this start event the reasoning deltas
983+
// would be dropped and the completed
984+
// assistant message would not carry any
985+
// ContentBlock::Thinking — which is what
986+
// DeepSeek V4 thinking mode requires the
987+
// client to echo back on subsequent turns.
988+
if !thinking_open {
989+
yield Ok(StreamEvent::ContentBlockStart {
990+
index: THINKING_BLOCK_INDEX,
991+
content_block: ContentBlock::Thinking {
992+
thinking: String::new(),
993+
signature: String::new(),
994+
},
995+
});
996+
thinking_open = true;
997+
}
964998
yield Ok(StreamEvent::ReasoningDelta {
965-
index: 0,
999+
index: THINKING_BLOCK_INDEX,
9661000
reasoning: reasoning.to_string(),
9671001
});
9681002
break;
@@ -974,6 +1008,15 @@ impl LlmProvider for OpenAiCompatProvider {
9741008
// Text content delta
9751009
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
9761010
if !content.is_empty() {
1011+
// Close any open thinking block before visible text
1012+
// starts streaming, so the blocks land in order in
1013+
// the final message: [Thinking, Text, ToolUse...].
1014+
if thinking_open {
1015+
yield Ok(StreamEvent::ContentBlockStop {
1016+
index: THINKING_BLOCK_INDEX,
1017+
});
1018+
thinking_open = false;
1019+
}
9771020
yield Ok(StreamEvent::TextDelta {
9781021
index: 0,
9791022
text: content.to_string(),
@@ -985,6 +1028,14 @@ impl LlmProvider for OpenAiCompatProvider {
9851028
if let Some(tool_calls) =
9861029
delta.get("tool_calls").and_then(|t| t.as_array())
9871030
{
1031+
// Close any open thinking block before tool calls
1032+
// start (same ordering guarantee as for text above).
1033+
if thinking_open {
1034+
yield Ok(StreamEvent::ContentBlockStop {
1035+
index: THINKING_BLOCK_INDEX,
1036+
});
1037+
thinking_open = false;
1038+
}
9881039
for tc in tool_calls {
9891040
let tc_index = tc
9901041
.get("index")
@@ -1039,6 +1090,14 @@ impl LlmProvider for OpenAiCompatProvider {
10391090
choice.get("finish_reason").and_then(|v| v.as_str())
10401091
{
10411092
if !finish_reason.is_empty() && finish_reason != "null" {
1093+
// Flush any still-open thinking block first so it
1094+
// is finalized into the assistant message.
1095+
if thinking_open {
1096+
yield Ok(StreamEvent::ContentBlockStop {
1097+
index: THINKING_BLOCK_INDEX,
1098+
});
1099+
thinking_open = false;
1100+
}
10421101
yield Ok(StreamEvent::ContentBlockStop { index: 0 });
10431102
let mut tc_indices: Vec<usize> =
10441103
tool_call_buffers.keys().cloned().collect();

src-rust/crates/api/src/providers/openai_compat_providers.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ pub fn custom_openai() -> OpenAiCompatProvider {
134134
}
135135

136136
/// DeepSeek V4 — supports reasoning output via `reasoning_content` field.
137+
/// V4 models require reasoning_content to be echoed back on subsequent turns
138+
/// in multi-turn conversations with tool calls.
137139
/// Reads `DEEPSEEK_API_KEY`.
138140
pub fn deepseek() -> OpenAiCompatProvider {
139141
let key = std::env::var("DEEPSEEK_API_KEY").unwrap_or_default();
@@ -145,6 +147,7 @@ pub fn deepseek() -> OpenAiCompatProvider {
145147
.with_api_key(key)
146148
.with_quirks(ProviderQuirks {
147149
reasoning_field: Some("reasoning_content".to_string()),
150+
requires_reasoning_roundtrip: true,
148151
overflow_patterns: vec!["maximum context length is".to_string()],
149152
include_usage_in_stream: true,
150153
max_tokens_cap: None,

0 commit comments

Comments
 (0)