Skip to content

Commit fa4508e

Browse files
authored
Route Harmony reasoning separately from final output (#21378)
Differential Revision: D112991739 Pull Request resolved: #21378
1 parent b20f16a commit fa4508e

2 files changed

Lines changed: 101 additions & 32 deletions

File tree

examples/llm_server/python/protocol.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ class Usage(BaseModel):
102102
class ResponseMessage(BaseModel):
103103
role: str = "assistant"
104104
content: Optional[str] = None
105+
reasoning_content: Optional[str] = None
105106
tool_calls: Optional[list[ToolCall]] = None
106107

107108

@@ -123,6 +124,7 @@ class ChatCompletionResponse(BaseModel):
123124
class DeltaMessage(BaseModel):
124125
role: Optional[str] = None
125126
content: Optional[str] = None
127+
reasoning_content: Optional[str] = None
126128
tool_calls: Optional[list[ToolCall]] = None
127129

128130

examples/llm_server/python/serving_chat.py

Lines changed: 99 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)