From b7d3dcdd6ee1ba628a81690b9efb6e035cbe335b Mon Sep 17 00:00:00 2001 From: Jordan Anderson Date: Fri, 3 Jul 2026 02:12:21 +0000 Subject: [PATCH 1/2] studio: re-emit prefilled opener on the safetensors stream Qwen3.5/3.6-style hybrid-thinking templates prefill '\n' in the generation prompt, so the model's output stream carries no opening tag and Studio's tag-based splitting rendered all reasoning as answer text (unslothai/unsloth#6815). Yield the opener before generate_stream when the rendered prompt ends with it; enable_thinking=False renders a closed empty block and is untouched. GGUF path unaffected (llama-server owns that convention). Co-Authored-By: Claude Fable 5 --- studio/backend/core/inference/inference.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 4dca4db7689..60381a19166 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -982,6 +982,18 @@ def _generate_chat_response_inner( formatted_prompt = self.format_chat_prompt(messages, system_prompt) # Step 3: generate + + # Newer hybrid-thinking templates (Qwen3.5 / Qwen3.6 style) *prefill* the reasoning + # opener in the generation prompt (`...<|im_start|>assistant\n\n`), so the + # model's own output stream is `reasoning...answer` with no opening tag. + # The shared stream protocol marks reasoning with literal ... tags + # (the harmony parser above emits them for gpt-oss, and llama-server handles the + # prefilled-opener convention natively on the GGUF path). Without re-emitting the + # opener here, everything the model thinks renders as answer text on the + # safetensors path (#6815). enable_thinking=False prompts end with an *empty* + # `\n\n` block, not the bare opener, so they are unaffected. + if formatted_prompt.rstrip().endswith(""): + yield "" yield from self.generate_stream( formatted_prompt, temperature, From e8db9ced64f484c6791f7d5192c612c67ee6ea51 Mon Sep 17 00:00:00 2001 From: Jordan Anderson Date: Fri, 3 Jul 2026 02:54:25 +0000 Subject: [PATCH 2/2] studio: carry the synthetic opener in every cumulative snapshot The stream protocol yields cumulative snapshots that consumers diff against the previous value (routes/inference.py: cumulative[len(prev_text):]). Yielding the opener once as its own snapshot made the next snapshot lose its first len('') characters and dropped the opener from the final value. Prefix every generate_stream snapshot instead; the no-prefill path is unchanged (empty prefix). Addresses the Codex P1 review finding on #6832. Co-Authored-By: Claude Fable 5 --- studio/backend/core/inference/inference.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py index 60381a19166..f113b7fe291 100644 --- a/studio/backend/core/inference/inference.py +++ b/studio/backend/core/inference/inference.py @@ -992,9 +992,13 @@ def _generate_chat_response_inner( # opener here, everything the model thinks renders as answer text on the # safetensors path (#6815). enable_thinking=False prompts end with an *empty* # `\n\n` block, not the bare opener, so they are unaffected. - if formatted_prompt.rstrip().endswith(""): - yield "" - yield from self.generate_stream( + # This generator yields *cumulative* snapshots (consumers diff each value + # against the previous one), so the synthetic opener must remain part of + # every subsequent snapshot rather than be emitted once on its own. + think_prefix = "" if formatted_prompt.rstrip().endswith("") else "" + if think_prefix: + yield think_prefix + for cumulative in self.generate_stream( formatted_prompt, temperature, top_p, @@ -1004,7 +1008,8 @@ def _generate_chat_response_inner( repetition_penalty, cancel_event = cancel_event, _adapter_state = _adapter_state, - ) + ): + yield think_prefix + cumulative def _generate_vision_response( self,