From e36f72c685b275bb4efd33c0e0daebbdec4d1877 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 05:42:26 -0700 Subject: [PATCH] Detect always-on reasoning models and show Think button as locked-on (#4654) * Detect always-on reasoning models and show Think button as locked-on Models with hardcoded / tags or reasoning_content in their chat template (e.g. distilled reasoning models) always produce thinking output regardless of any toggle. Previously these models were not detected as reasoning-capable at all, so the Think button was grayed out even though the model was actively reasoning. Backend: - Detect / and reasoning_content in GGUF chat templates as a fallback when enable_thinking is not present - Add reasoning_always_on flag to LoadResponse and InferenceStatusResponse - Pass the flag through all GGUF load and status response paths Frontend: - Add reasoningAlwaysOn to the chat runtime store and API types - When reasoning_always_on is true, show the Think button as lit (active) but not clickable, with a tooltip explaining the model always uses thinking - Force reasoningEnabled=true when the model always reasons * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use pointer-events-none instead of disabled for always-on Think button The HTML disabled attribute was not fully blocking clicks on the Think button for always-on reasoning models. Switch to pointer-events-none CSS class which prevents all mouse interaction at the CSS level. * Use a static span instead of disabled button for always-on Think Replace the button element with a plain span when reasoning is always on. This makes it physically impossible to toggle since there is no clickable element at all, avoiding any CSS or disabled-attribute edge cases. * Simplify always-on Think button to stay lit and remain toggleable Keep the Think button as a normal toggleable button but ensure it shows as lit when reasoning_always_on is true. The model always reasons regardless of the toggle state so there is no need to block interaction. --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 21 +++++++++++++++++++ studio/backend/models/inference.py | 7 +++++++ studio/backend/routes/inference.py | 3 +++ .../src/features/chat/api/chat-adapter.ts | 2 ++ .../chat/hooks/use-chat-model-runtime.ts | 6 +++++- .../src/features/chat/shared-composer.tsx | 6 ++++-- .../chat/stores/chat-runtime-store.ts | 2 ++ .../frontend/src/features/chat/types/api.ts | 2 ++ 8 files changed, 46 insertions(+), 3 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 3f9bc0421e..7909af8a23 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -50,6 +50,7 @@ class LlamaCppBackend: self._effective_context_length: Optional[int] = None self._chat_template: Optional[str] = None self._supports_reasoning: bool = False + self._reasoning_always_on: bool = False self._supports_tools: bool = False self._cache_type_kv: Optional[str] = None self._reasoning_default: bool = True @@ -107,6 +108,10 @@ class LlamaCppBackend: def supports_reasoning(self) -> bool: return self._supports_reasoning + @property + def reasoning_always_on(self) -> bool: + return self._reasoning_always_on + @property def reasoning_default(self) -> bool: return self._reasoning_default @@ -550,6 +555,7 @@ class LlamaCppBackend: self._context_length = None self._chat_template = None self._supports_reasoning = False + self._reasoning_always_on = False self._supports_tools = False self._n_layers = None self._n_kv_heads = None @@ -627,6 +633,20 @@ class LlamaCppBackend: logger.info( "GGUF metadata: model supports reasoning (DeepSeek thinking)" ) + # Models with hardcoded tags or reasoning_content + # in their chat template always produce thinking output + # (no toggle to disable it). + if not self._supports_reasoning: + if ( + "" in tpl + and "" in tpl + or "reasoning_content" in tpl + ): + self._supports_reasoning = True + self._reasoning_always_on = True + logger.info( + "GGUF metadata: model always reasons ( tags in template)" + ) # Detect tool calling support from chat template tool_markers = [ "{%- if tools %}", @@ -1318,6 +1338,7 @@ class LlamaCppBackend: self._effective_context_length = None self._chat_template = None self._supports_reasoning = False + self._reasoning_always_on = False self._supports_tools = False self._cache_type_kv = None self._n_layers = None diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 36395af7bd..accdcc1290 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -136,6 +136,10 @@ class LoadResponse(BaseModel): False, description = "Whether model supports thinking/reasoning mode (enable_thinking)", ) + reasoning_always_on: bool = Field( + False, + description = "Whether reasoning is always on (hardcoded tags, not toggleable)", + ) supports_tools: bool = Field( False, description = "Whether model supports tool calling (web search, etc.)", @@ -193,6 +197,9 @@ class InferenceStatusResponse(BaseModel): supports_reasoning: bool = Field( False, description = "Whether the active model supports reasoning/thinking mode" ) + reasoning_always_on: bool = Field( + False, description = "Whether reasoning is always on (not toggleable)" + ) supports_tools: bool = Field( False, description = "Whether the active model supports tool calling" ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index f57342b59c..6f44a3c69f 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -156,6 +156,7 @@ async def load_model( inference = inference_config, context_length = llama_backend.context_length, supports_reasoning = llama_backend.supports_reasoning, + reasoning_always_on = llama_backend.reasoning_always_on, chat_template = llama_backend.chat_template, ) else: @@ -280,6 +281,7 @@ async def load_model( inference = inference_config, context_length = llama_backend.context_length, supports_reasoning = llama_backend.supports_reasoning, + reasoning_always_on = llama_backend.reasoning_always_on, supports_tools = llama_backend.supports_tools, cache_type_kv = llama_backend.cache_type_kv, chat_template = llama_backend.chat_template, @@ -609,6 +611,7 @@ async def get_status( loaded = [_model_id], inference = _inference_cfg, supports_reasoning = llama_backend.supports_reasoning, + reasoning_always_on = llama_backend.reasoning_always_on, supports_tools = llama_backend.supports_tools, context_length = llama_backend.context_length, ) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 95af560305..ab4cdd9d6a 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -307,6 +307,7 @@ async function autoLoadSmallestModel(): Promise { useChatRuntimeStore.setState({ ggufContextLength: loadResp.context_length ?? 131072, supportsReasoning: loadResp.supports_reasoning ?? false, + reasoningAlwaysOn: loadResp.reasoning_always_on ?? false, reasoningEnabled: loadResp.supports_reasoning ?? false, supportsTools: loadResp.supports_tools ?? false, toolsEnabled: loadResp.supports_tools ?? false, @@ -392,6 +393,7 @@ async function autoLoadSmallestModel(): Promise { useChatRuntimeStore.setState({ ggufContextLength: loadResp.context_length ?? 131072, supportsReasoning: loadResp.supports_reasoning ?? false, + reasoningAlwaysOn: loadResp.reasoning_always_on ?? false, reasoningEnabled: loadResp.supports_reasoning ?? false, supportsTools: loadResp.supports_tools ?? false, toolsEnabled: loadResp.supports_tools ?? false, diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index 25c776948f..cc9ec3971d 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -238,9 +238,11 @@ export function useChatModelRuntime() { // Restore reasoning/tools support flags and context length const supportsReasoning = statusRes.supports_reasoning ?? false; + const reasoningAlwaysOn = statusRes.reasoning_always_on ?? false; const supportsTools = statusRes.supports_tools ?? false; useChatRuntimeStore.setState({ supportsReasoning, + reasoningAlwaysOn, supportsTools, ggufContextLength: statusRes.is_gguf ? (statusRes.context_length ?? null) : null, }); @@ -420,10 +422,12 @@ export function useChatModelRuntime() { && customContextLength !== nativeCtx ? customContextLength : null; + const reasoningAlwaysOn = loadResponse.reasoning_always_on ?? false; useChatRuntimeStore.setState({ ggufContextLength: nativeCtx, supportsReasoning: loadResponse.supports_reasoning ?? false, - reasoningEnabled: reasoningDefault, + reasoningAlwaysOn, + reasoningEnabled: reasoningAlwaysOn ? true : reasoningDefault, supportsTools: loadResponse.supports_tools ?? false, toolsEnabled: loadResponse.supports_tools ?? false, codeToolsEnabled: loadResponse.supports_tools ?? false, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 5ac8c79160..59b0880add 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -241,6 +241,7 @@ export function SharedComposer({ (s) => !!s.params.checkpoint && !s.modelLoading, ); const supportsReasoning = useChatRuntimeStore((s) => s.supportsReasoning); + const reasoningAlwaysOn = useChatRuntimeStore((s) => s.reasoningAlwaysOn); const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled); const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled); const supportsTools = useChatRuntimeStore((s) => s.supportsTools); @@ -528,6 +529,7 @@ export function SharedComposer({ type="button" disabled={reasoningDisabled} onClick={() => { + if (reasoningAlwaysOn) return; const next = !reasoningEnabled; setReasoningEnabled(next); // Qwen3/3.5: adjust params for thinking on/off @@ -544,13 +546,13 @@ export function SharedComposer({ "flex items-center gap-0.5 rounded-full px-2 py-0.5 text-xs font-medium transition-colors", reasoningDisabled ? "cursor-not-allowed opacity-40" - : reasoningEnabled + : (reasoningEnabled || reasoningAlwaysOn) ? "bg-primary/10 text-primary hover:bg-primary/20" : "bg-muted text-muted-foreground hover:bg-muted-foreground/15", )} aria-label={reasoningEnabled ? "Disable thinking" : "Enable thinking"} > - {reasoningEnabled && !reasoningDisabled ? ( + {(reasoningEnabled || reasoningAlwaysOn) && !reasoningDisabled ? ( ) : ( diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 2d60d52043..35a7ab068b 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -151,6 +151,7 @@ type ChatRuntimeStore = { activeGgufVariant: string | null; ggufContextLength: number | null; supportsReasoning: boolean; + reasoningAlwaysOn: boolean; reasoningEnabled: boolean; supportsTools: boolean; toolsEnabled: boolean; @@ -213,6 +214,7 @@ export const useChatRuntimeStore = create((set) => ({ activeGgufVariant: null, ggufContextLength: null, supportsReasoning: false, + reasoningAlwaysOn: false, reasoningEnabled: true, supportsTools: false, toolsEnabled: false, diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index ff5ebe50ca..f41f1279a7 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -87,6 +87,7 @@ export interface LoadModelResponse { }; context_length?: number | null; supports_reasoning?: boolean; + reasoning_always_on?: boolean; supports_tools?: boolean; cache_type_kv?: string | null; chat_template?: string | null; @@ -115,6 +116,7 @@ export interface InferenceStatusResponse { trust_remote_code?: boolean; }; supports_reasoning?: boolean; + reasoning_always_on?: boolean; supports_tools?: boolean; context_length?: number | null; }