@@ -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( ) ;
0 commit comments