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 <think>/<think> 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 <think>/<think> 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 <danielhanchen@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-03-27 05:42:26 -07:00 committed by GitHub
commit e36f72c685
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 46 additions and 3 deletions

View file

@ -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 <think> tags or reasoning_content
# in their chat template always produce thinking output
# (no toggle to disable it).
if not self._supports_reasoning:
if (
"<think>" in tpl
and "</think>" in tpl
or "reasoning_content" in tpl
):
self._supports_reasoning = True
self._reasoning_always_on = True
logger.info(
"GGUF metadata: model always reasons (<think> 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

View file

@ -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 <think> 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"
)

View file

@ -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,
)

View file

@ -307,6 +307,7 @@ async function autoLoadSmallestModel(): Promise<boolean> {
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<boolean> {
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,

View file

@ -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,

View file

@ -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 ? (
<LightbulbIcon className="size-3" />
) : (
<LightbulbOffIcon className="size-3" />

View file

@ -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<ChatRuntimeStore>((set) => ({
activeGgufVariant: null,
ggufContextLength: null,
supportsReasoning: false,
reasoningAlwaysOn: false,
reasoningEnabled: true,
supportsTools: false,
toolsEnabled: false,

View file

@ -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;
}