@@ -67,13 +67,17 @@ def __init__(
6767 prompt_token_offset : int = 0 ,
6868 content_filter : Optional [Callable [[str ], str ]] = None ,
6969 content_filter_specials : Optional [set [str ]] = None ,
70+ reasoning_extractor : Optional [
71+ Callable [[str ], tuple [Optional [str ], str ]]
72+ ] = None ,
7073 ):
7174 self ._runtime = runtime
7275 self ._template = template
7376 self ._model_id = model_id
7477 self ._max_context = max_context
7578 self ._prompt_token_offset = prompt_token_offset
7679 self ._content_filter = content_filter
80+ self ._reasoning_extractor = reasoning_extractor
7781 # Detector CLASS; a fresh instance is created per request so streaming
7882 # state is never shared across concurrent requests.
7983 self ._tool_detector_cls = tool_detector_cls
@@ -122,6 +126,17 @@ def _visible_content(self, text: str) -> str:
122126 text = self ._content_filter (text )
123127 return self ._strip_specials (text )
124128
129+ def _split_reasoning (self , text : str ) -> tuple [Optional [str ], str ]:
130+ if self ._reasoning_extractor is None :
131+ return None , text
132+ return self ._reasoning_extractor (text )
133+
134+ @staticmethod
135+ def _return_reasoning (req : ChatCompletionRequest ) -> bool :
136+ kwargs = req .chat_template_kwargs or {}
137+ value = kwargs .get ("return_reasoning" , False )
138+ return value if isinstance (value , bool ) else False
139+
125140 @staticmethod
126141 def _to_openai_tool_call (item : ToolCallItem ) -> ToolCall :
127142 return ToolCall (
@@ -172,17 +187,20 @@ async def _collect_until_stop(self, stream: AsyncIterator[str], stops: list[str]
172187 break
173188 return text , stopped
174189
175- def _extract_tools (self , req : ChatCompletionRequest , text : str ):
176- """Returns (tool_calls | None, content_text). Falls back to plain text."""
190+ def _extract_response (self , req : ChatCompletionRequest , text : str ):
191+ """Return tool calls, optional reasoning, and user-visible content."""
192+ tool_calls = None
177193 if self ._tools_active (req ):
178194 parsed = self ._tool_detector_cls ().detect_and_parse (
179195 text , self ._tool_schemas (req )
180196 )
181197 if parsed .calls :
182- content = self ._visible_content (parsed .normal_text ) or None
183- return [self ._to_openai_tool_call (c ) for c in parsed .calls ], content
198+ tool_calls = [self ._to_openai_tool_call (c ) for c in parsed .calls ]
184199 text = parsed .normal_text
185- return None , self ._visible_content (text )
200+ reasoning , text = self ._split_reasoning (text )
201+ if not self ._return_reasoning (req ):
202+ reasoning = None
203+ return tool_calls , reasoning , self ._visible_content (text ) or None
186204
187205 @staticmethod
188206 def _log_generation_stats (
@@ -408,8 +426,10 @@ async def create(self, req: ChatCompletionRequest):
408426 # the tool schemas, the model can emit a <tool_call> that we'd surface as
409427 # plain text (parsing is disabled), instead of a normal answer.
410428 template_tools = None if req .tool_choice == "none" else req .tools
429+ template_kwargs = dict (req .chat_template_kwargs or {})
430+ template_kwargs .pop ("return_reasoning" , None )
411431 prompt = self ._template .render (
412- req .messages , tools = template_tools , template_kwargs = req . chat_template_kwargs
432+ req .messages , tools = template_tools , template_kwargs = template_kwargs
413433 )
414434 # Token-ID segments splice prior assistant turns' exact ids so warm resume
415435 # survives the template's lossy tool-call re-render; plain text when
@@ -420,7 +440,7 @@ async def create(self, req: ChatCompletionRequest):
420440 messages = req .messages ,
421441 rendered_prompt = prompt ,
422442 tools = template_tools ,
423- template_kwargs = req . chat_template_kwargs ,
443+ template_kwargs = template_kwargs ,
424444 )
425445 # Pre-flight context check against the tokens the worker will actually
426446 # assemble: for segments that is sum(len(ids)) + tokenized text, not the
@@ -450,7 +470,7 @@ async def create(self, req: ChatCompletionRequest):
450470 # reproduces the exact resident scaffold even if the mode changes between
451471 # requests.
452472 preamble = self ._template .generation_preamble (
453- req . chat_template_kwargs , tools = template_tools
473+ template_kwargs , tools = template_tools
454474 )
455475 # Admit the session up front (before the stream's first chunk) so a
456476 # capacity refusal is an HTTP status, not a mid-stream error event.
@@ -488,7 +508,9 @@ async def _complete(
488508 raise GenerationError (str (e ))
489509 # Bound the raw output at the first stop/special token BEFORE tool
490510 # parsing, so a call after the stop boundary is not parsed/emitted.
491- tool_calls , content = self ._extract_tools (req , self ._truncate_raw (text , req ))
511+ tool_calls , reasoning , content = self ._extract_response (
512+ req , self ._truncate_raw (text , req )
513+ )
492514 # Record after the response is finalized: the fingerprint is of exactly
493515 # what we return (content + tool_calls), so the next turn can confirm the
494516 # client echoed this turn before splicing its ids.
@@ -508,7 +530,11 @@ async def _complete(
508530 model = self ._model_id ,
509531 choices = [
510532 Choice (
511- message = ResponseMessage (content = content , tool_calls = tool_calls ),
533+ message = ResponseMessage (
534+ content = content ,
535+ reasoning_content = reasoning ,
536+ tool_calls = tool_calls ,
537+ ),
512538 finish_reason = finish ,
513539 )
514540 ],
@@ -549,6 +575,42 @@ def on_stop():
549575 ):
550576 yield token
551577
578+ async def _stream_final_chunks (
579+ self ,
580+ req : ChatCompletionRequest ,
581+ stats : GenStats ,
582+ use_tools : bool ,
583+ tool_calls ,
584+ reasoning : Optional [str ],
585+ content : Optional [str ],
586+ stopped : bool ,
587+ chunk ,
588+ ) -> AsyncIterator [str ]:
589+ if use_tools :
590+ if reasoning :
591+ yield chunk (DeltaMessage (reasoning_content = reasoning ))
592+ if content :
593+ yield chunk (DeltaMessage (content = content ))
594+ for tool_call in tool_calls or []:
595+ yield chunk (DeltaMessage (tool_calls = [tool_call ]))
596+ finish = self ._finish_reason (
597+ req ,
598+ stats .completion_tokens ,
599+ tool_calls ,
600+ stopped ,
601+ stats .finish_reason ,
602+ )
603+ else :
604+ finish = self ._finish_reason (
605+ req ,
606+ stats .completion_tokens ,
607+ stopped = stopped ,
608+ worker_finish = stats .finish_reason ,
609+ )
610+
611+ self ._log_generation_stats (req .session_id , stats , finish )
612+ yield chunk (DeltaMessage (), finish = finish )
613+
552614 async def _stream (
553615 self ,
554616 req : ChatCompletionRequest ,
@@ -571,6 +633,7 @@ def chunk(delta: DeltaMessage, finish=None) -> str:
571633 error : Optional [Exception ] = None
572634 use_tools = self ._tools_active (req )
573635 tool_calls = None
636+ reasoning = None
574637 content = None
575638
576639 stats = GenStats ()
@@ -594,9 +657,23 @@ def chunk(delta: DeltaMessage, finish=None) -> str:
594657 ),
595658 stops ,
596659 )
597- tool_calls , content = self ._extract_tools (
660+ tool_calls , reasoning , content = self ._extract_response (
598661 req , self ._truncate_raw (raw , req )
599662 )
663+ elif self ._reasoning_extractor is not None :
664+ raw , stop_hit [0 ] = await self ._collect_until_stop (
665+ self ._runtime .generate_stream (
666+ req .session_id , prompt , options , stats
667+ ),
668+ stops ,
669+ )
670+ tool_calls , reasoning , content = self ._extract_response (
671+ req , self ._apply_stop (raw , stops )
672+ )
673+ if reasoning :
674+ yield chunk (DeltaMessage (reasoning_content = reasoning ))
675+ if content :
676+ yield chunk (DeltaMessage (content = content ))
600677 else :
601678 streamed : list [str ] = []
602679 async for token in self ._stream_plain_content (
@@ -628,27 +705,17 @@ def chunk(delta: DeltaMessage, finish=None) -> str:
628705 preamble = preamble ,
629706 )
630707
631- if use_tools :
632- if content :
633- yield chunk (DeltaMessage (content = content ))
634- for tc in tool_calls or []:
635- yield chunk (DeltaMessage (tool_calls = [tc ]))
636- finish = self ._finish_reason (
637- req ,
638- stats .completion_tokens ,
639- tool_calls ,
640- stop_hit [0 ],
641- stats .finish_reason ,
642- )
643- else :
644- finish = self ._finish_reason (
645- req ,
646- stats .completion_tokens ,
647- stopped = stop_hit [0 ],
648- worker_finish = stats .finish_reason ,
649- )
650- self ._log_generation_stats (req .session_id , stats , finish )
651- yield chunk (DeltaMessage (), finish = finish )
708+ async for final_chunk in self ._stream_final_chunks (
709+ req ,
710+ stats ,
711+ use_tools ,
712+ tool_calls ,
713+ reasoning ,
714+ content ,
715+ stop_hit [0 ],
716+ chunk ,
717+ ):
718+ yield final_chunk
652719 if req .stream_options and req .stream_options .include_usage :
653720 usage_chunk = ChatCompletionChunk (
654721 id = cid ,
0 commit comments