text: surface model reasoning as a separate field (think/thinking/thought)

Qwen-style chat templates pre-fill the opening <think> in the prompt, so the
model emits only the reasoning body + a bare closing </think> — and they think
by DEFAULT regardless of the API enable_thinking flag. The old paired-tag
reasoning extractor missed the bare close, leaking the whole thought (and the
</think>) into content and conversation history.

- extract_reasoning_content: handle a bare </think|/thinking|/thought> with no
  opening tag (treat the prefix as reasoning).
- streaming: a chunk-safe reasoning gate routes the thought into
  delta.reasoning / reasoning_content until </think>, then flips to content;
  tool extraction runs on the post-</think> answer only.
- non-streaming: extract reasoning, set message.reasoning(+_content), clean
  content; tools parsed from the answer.
- activate whenever the model auto-thinks (qwen3/qwq/deepseek-r1/… name) OR
  reasoning is explicitly enabled — not just on the API flag.
- configurable suppression: per-model `suppress_reasoning`, or per-request via
  the standard reasoning:{exclude:true} / reasoning_effort:"none" /
  suppress_reasoning fields. Emits both `reasoning` and DeepSeek-style
  `reasoning_content` for client compatibility.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 54a83db0
...@@ -2726,7 +2726,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_ ...@@ -2726,7 +2726,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"cache_type_k", "cache_type_v", "turboquant", "engine", "engine_fallback", "cache_type_k", "cache_type_v", "turboquant", "engine", "engine_fallback",
"quant_backend", "kv_cache_budget_mb", "kv_cache_slots", "mmproj", "quant_backend", "kv_cache_budget_mb", "kv_cache_slots", "mmproj",
"auto_compact", "auto_compact_pct", "auto_compact_strategy", "auto_compact", "auto_compact_pct", "auto_compact_strategy",
"auto_compact_model"): "auto_compact_model", "suppress_reasoning"):
if key in data: if key in data:
entry[key] = data[key] entry[key] = data[key]
......
...@@ -722,6 +722,13 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson } ...@@ -722,6 +722,13 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
</div> </div>
</div> </div>
<!-- Reasoning channel: surface the model's <think> thought as a separate
reasoning field by default; this suppresses it from the response. -->
<div style="margin-top:1rem">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-suppress-reasoning"> Suppress reasoning output <span class="muted">(drop the model's &lt;think&gt; thinking instead of returning it)</span></label>
<span class="form-hint" style="font-size:11px">By default the model's thinking is returned as a separate <code>reasoning</code> / <code>reasoning_content</code> field (think/thinking/thought tags, including the bare <code>&lt;/think&gt;</code> Qwen pre-fill emits) and kept out of <code>content</code>. Enable this to discard it entirely. A request can override per-call with <code>reasoning:{exclude:true}</code>, <code>reasoning_effort:"none"</code>, or <code>suppress_reasoning</code>.</span>
</div>
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:.75rem"> <div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-top:.75rem">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-4bit"> 4-bit quantization</label> <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-4bit"> 4-bit quantization</label>
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-8bit"> 8-bit quantization</label> <label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:13px"><input type="checkbox" id="cfg-8bit"> 8-bit quantization</label>
...@@ -3235,6 +3242,7 @@ function openCfgModal(idx, cfgIdx){ ...@@ -3235,6 +3242,7 @@ function openCfgModal(idx, cfgIdx){
document.getElementById('cfg-autocompact-pct').value = s.auto_compact_pct != null ? s.auto_compact_pct : 85; document.getElementById('cfg-autocompact-pct').value = s.auto_compact_pct != null ? s.auto_compact_pct : 85;
document.getElementById('cfg-autocompact-strategy').value = s.auto_compact_strategy || 'drop_oldest'; document.getElementById('cfg-autocompact-strategy').value = s.auto_compact_strategy || 'drop_oldest';
_populateCompactModelSelect(s.auto_compact_model || ''); _populateCompactModelSelect(s.auto_compact_model || '');
document.getElementById('cfg-suppress-reasoning').checked = !!s.suppress_reasoning;
_toggleAutoCompact(); _toggleAutoCompact();
document.getElementById('cfg-4bit').checked = !!s.load_in_4bit; document.getElementById('cfg-4bit').checked = !!s.load_in_4bit;
document.getElementById('cfg-8bit').checked = !!s.load_in_8bit; document.getElementById('cfg-8bit').checked = !!s.load_in_8bit;
...@@ -3625,6 +3633,7 @@ async function saveModelConfig(){ ...@@ -3625,6 +3633,7 @@ async function saveModelConfig(){
auto_compact_pct: parseInt(document.getElementById('cfg-autocompact-pct').value) || 85, auto_compact_pct: parseInt(document.getElementById('cfg-autocompact-pct').value) || 85,
auto_compact_strategy: document.getElementById('cfg-autocompact-strategy').value || 'drop_oldest', auto_compact_strategy: document.getElementById('cfg-autocompact-strategy').value || 'drop_oldest',
auto_compact_model: document.getElementById('cfg-autocompact-model').value || '', auto_compact_model: document.getElementById('cfg-autocompact-model').value || '',
suppress_reasoning: document.getElementById('cfg-suppress-reasoning').checked,
max_gpu_percent: isNaN(maxGpu) ? null : maxGpu, max_gpu_percent: isNaN(maxGpu) ? null : maxGpu,
manual_ram_gb: isNaN(ramGb) ? null : ramGb, manual_ram_gb: isNaN(ramGb) ? null : ramGb,
load_in_4bit: document.getElementById('cfg-4bit').checked, load_in_4bit: document.getElementById('cfg-4bit').checked,
......
This diff is collapsed.
...@@ -76,7 +76,25 @@ def extract_reasoning_content(text: str, model_family: str = None) -> Tuple[str, ...@@ -76,7 +76,25 @@ def extract_reasoning_content(text: str, model_family: str = None) -> Tuple[str,
break break
except: except:
continue continue
# Bare closing tag, no opening tag. Qwen3 (and other models whose chat template
# PRE-FILLS the opening <think> in the prompt) generate only the reasoning body
# followed by a closing </think> — there is no opening tag in the output, so the
# paired patterns above never match and the whole thought would leak into the
# content. Treat everything up to the first bare close tag as reasoning, as long
# as no matching opening tag precedes it.
if not reasoning_content:
for close in ('</think>', '</thinking>', '</thought>'):
idx = protected_text.lower().find(close)
if idx == -1:
continue
open_tag = close.replace('</', '<', 1)
if open_tag.lower() in protected_text[:idx].lower():
continue # a real opening tag exists — leave it to the paired logic
reasoning_content = protected_text[:idx].strip()
clean_text = protected_text[idx + len(close):].strip()
break
# Cleanup with pre-compiled patterns # Cleanup with pre-compiled patterns
for p in REASONING_CLEANUP_PATTERNS: for p in REASONING_CLEANUP_PATTERNS:
clean_text = p.sub('', clean_text) clean_text = p.sub('', clean_text)
......
...@@ -86,6 +86,9 @@ class ChatCompletionRequest(BaseModel): ...@@ -86,6 +86,9 @@ class ChatCompletionRequest(BaseModel):
response_format: Optional[Dict] = Field(None, description="Structured-output format, e.g. {'type': 'json_object'}.") response_format: Optional[Dict] = Field(None, description="Structured-output format, e.g. {'type': 'json_object'}.")
user: Optional[str] = Field(None, description="Opaque end-user identifier (passthrough).") user: Optional[str] = Field(None, description="Opaque end-user identifier (passthrough).")
enable_thinking: Optional[bool] = Field(False, description="Enable thinking/reasoning mode for models that support it.") enable_thinking: Optional[bool] = Field(False, description="Enable thinking/reasoning mode for models that support it.")
reasoning: Optional[Dict] = Field(None, description="Reasoning controls. OpenRouter-style; set {'exclude': true} to drop the model's thinking from the response.")
reasoning_effort: Optional[str] = Field(None, description="OpenAI-style reasoning effort ('low'|'medium'|'high'); 'none'/'off' suppresses reasoning output.")
suppress_reasoning: Optional[bool] = Field(None, description="Drop the model's reasoning/thinking from the response instead of returning it as a separate field.")
model_config = ConfigDict(extra="allow") # Allow extra fields to prevent 422 errors model_config = ConfigDict(extra="allow") # Allow extra fields to prevent 422 errors
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment