diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 92cc2c748c..403ace3ff3 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -552,6 +552,27 @@ _TOOL_TEMPLATE_MARKERS = ( ) +# Canonical reasoning_effort levels, weakest -> strongest. Used to read the +# discrete set a template branches on (e.g. GLM-5.2 uses 'high' | 'max') so we +# only ever offer levels the template actually understands. +_REASONING_EFFORT_SCALE = ("minimal", "low", "medium", "high", "max") + + +def _extract_reasoning_effort_levels(chat_template: str) -> list: + """Return the reasoning_effort levels a template references, in canonical + (weakest -> strongest) order. + + Looks for the quoted literals (e.g. ``'high'`` / ``"max"``) the template + compares ``reasoning_effort`` against, so we surface exactly the levels it + branches on and nothing else. + """ + return [ + level + for level in _REASONING_EFFORT_SCALE + if f"'{level}'" in chat_template or f'"{level}"' in chat_template + ] + + def detect_reasoning_flags( chat_template: Optional[str], model_identifier: Optional[str] = None, @@ -571,6 +592,7 @@ def detect_reasoning_flags( "supports_reasoning": False, "reasoning_style": "enable_thinking", "reasoning_always_on": False, + "reasoning_effort_levels": [], "supports_preserve_thinking": False, "supports_tools": False, } @@ -579,7 +601,25 @@ def detect_reasoning_flags( tpl = chat_template prefix = f"{log_source}: " if log_source else "" - if "enable_thinking" in tpl: + effort_levels = ( + _extract_reasoning_effort_levels(tpl) + if ("reasoning_effort" in tpl and "enable_thinking" in tpl) + else [] + ) + if "enable_thinking" in tpl and "reasoning_effort" in tpl and effort_levels: + # GLM-5.2-style: an enable_thinking on/off gate PLUS a reasoning_effort + # level among a discrete set (e.g. 'high' | 'max'). Distinct from + # gpt-oss (reasoning_effort only, no on/off gate) and Qwen + # (enable_thinking only). Disabling is enable_thinking=false; the levels + # are the quoted effort literals the template actually branches on. + flags["supports_reasoning"] = True + flags["reasoning_style"] = "enable_thinking_effort" + flags["reasoning_effort_levels"] = effort_levels + logger.info( + f"{prefix}model supports reasoning " + f"(enable_thinking + reasoning_effort: {effort_levels})" + ) + elif "enable_thinking" in tpl: flags["supports_reasoning"] = True flags["reasoning_style"] = "enable_thinking" logger.info(f"{prefix}model supports reasoning (enable_thinking)") @@ -1185,6 +1225,7 @@ class LlamaCppBackend: self._supports_reasoning: bool = False self._reasoning_always_on: bool = False self._reasoning_style: str = "enable_thinking" + self._reasoning_effort_levels: list = [] self._supports_preserve_thinking: bool = False self._supports_tools: bool = False self._cache_type_kv: Optional[str] = None @@ -1468,6 +1509,12 @@ class LlamaCppBackend: def reasoning_style(self) -> str: return self._reasoning_style + @property + def reasoning_effort_levels(self) -> list: + """Discrete reasoning_effort levels the template offers (e.g. GLM-5.2's + ['high', 'max']). Empty unless reasoning_style == 'enable_thinking_effort'.""" + return self._reasoning_effort_levels + @property def supports_preserve_thinking(self) -> bool: return self._supports_preserve_thinking @@ -1477,6 +1524,10 @@ class LlamaCppBackend: return self._reasoning_default def _reasoning_kwargs(self, enable_thinking: bool) -> dict: + if self._reasoning_style == "enable_thinking_effort": + # GLM-5.2-style: enable_thinking is the on/off gate; when on, leave + # the template's default effort (max) in place. + return {"enable_thinking": enable_thinking} if self._reasoning_style == "reasoning_effort": return {"reasoning_effort": "high" if enable_thinking else "low"} return {"enable_thinking": enable_thinking} @@ -1497,7 +1548,20 @@ class LlamaCppBackend: # Always-on reasoning models hardcode tags and don't consume # enable_thinking / reasoning_effort -- skip. if self._supports_reasoning and not self._reasoning_always_on: - if self._reasoning_style == "reasoning_effort": + if self._reasoning_style == "enable_thinking_effort": + # GLM-5.2-style: enable_thinking gates thinking on/off, and the + # reasoning_effort level (e.g. 'high' | 'max') is only meaningful + # while thinking is on. Disabling is enable_thinking=false; a raw + # API caller can also disable via the OpenAI-style + # reasoning_effort="none" sentinel. We never coerce off into a + # 'low' effort the way gpt-oss does (those models genuinely + # cannot disable). + thinking_off = enable_thinking is False or reasoning_effort == "none" + if enable_thinking is not None or reasoning_effort == "none": + kwargs["enable_thinking"] = not thinking_off + if not thinking_off and reasoning_effort in self._reasoning_effort_levels: + kwargs["reasoning_effort"] = reasoning_effort + elif self._reasoning_style == "reasoning_effort": if reasoning_effort in ("none", "low", "medium", "high"): kwargs["reasoning_effort"] = reasoning_effort elif reasoning_effort == "minimal": @@ -3071,6 +3135,7 @@ class LlamaCppBackend: self._supports_reasoning = False self._reasoning_always_on = False self._reasoning_style = "enable_thinking" + self._reasoning_effort_levels = [] self._reasoning_default = True self._supports_preserve_thinking = False self._supports_tools = False @@ -3290,6 +3355,7 @@ class LlamaCppBackend: ) self._supports_reasoning = flags["supports_reasoning"] self._reasoning_style = flags["reasoning_style"] + self._reasoning_effort_levels = flags.get("reasoning_effort_levels", []) self._reasoning_always_on = flags["reasoning_always_on"] self._supports_preserve_thinking = flags["supports_preserve_thinking"] self._supports_tools = flags["supports_tools"] @@ -5399,6 +5465,7 @@ class LlamaCppBackend: ) self._supports_reasoning = flags["supports_reasoning"] self._reasoning_style = flags["reasoning_style"] + self._reasoning_effort_levels = flags.get("reasoning_effort_levels", []) self._reasoning_always_on = flags["reasoning_always_on"] self._supports_preserve_thinking = flags["supports_preserve_thinking"] self._supports_tools = flags["supports_tools"] @@ -6388,6 +6455,7 @@ class LlamaCppBackend: self._supports_reasoning = False self._reasoning_always_on = False self._reasoning_style = "enable_thinking" + self._reasoning_effort_levels = [] self._reasoning_default = True self._supports_preserve_thinking = False self._supports_tools = False diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index 3c6ccd70a8..c9b10fcfc2 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -220,9 +220,15 @@ class LoadResponse(BaseModel): False, description = "Whether model supports thinking/reasoning mode (enable_thinking or reasoning_effort)", ) - reasoning_style: Literal["enable_thinking", "reasoning_effort"] = Field( - "enable_thinking", - description = "Reasoning control style: 'enable_thinking' (boolean) or 'reasoning_effort' (low|medium|high)", + reasoning_style: Literal["enable_thinking", "reasoning_effort", "enable_thinking_effort"] = ( + Field( + "enable_thinking", + description = "Reasoning control style: 'enable_thinking' (boolean), 'reasoning_effort' (low|medium|high), or 'enable_thinking_effort' (on/off gate plus an effort level, e.g. GLM-5.2 high|max)", + ) + ) + reasoning_effort_levels: List[str] = Field( + default_factory = list, + description = "Discrete reasoning_effort levels the template offers when reasoning_style is 'enable_thinking_effort' (e.g. ['high', 'max']); empty otherwise", ) reasoning_always_on: bool = Field( False, @@ -332,9 +338,15 @@ class InferenceStatusResponse(BaseModel): supports_reasoning: bool = Field( False, description = "Whether the active model supports reasoning/thinking mode" ) - reasoning_style: Literal["enable_thinking", "reasoning_effort"] = Field( - "enable_thinking", - description = "Reasoning control style: 'enable_thinking' (boolean) or 'reasoning_effort' (low|medium|high)", + reasoning_style: Literal["enable_thinking", "reasoning_effort", "enable_thinking_effort"] = ( + Field( + "enable_thinking", + description = "Reasoning control style: 'enable_thinking' (boolean), 'reasoning_effort' (low|medium|high), or 'enable_thinking_effort' (on/off gate plus an effort level, e.g. GLM-5.2 high|max)", + ) + ) + reasoning_effort_levels: List[str] = Field( + default_factory = list, + description = "Discrete reasoning_effort levels the template offers when reasoning_style is 'enable_thinking_effort' (e.g. ['high', 'max']); empty otherwise", ) reasoning_always_on: bool = Field( False, description = "Whether reasoning is always on (not toggleable)" diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index b4a098eaf4..b3f112a944 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -1039,6 +1039,7 @@ def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict: "supports_reasoning": False, "reasoning_style": "enable_thinking", "reasoning_always_on": False, + "reasoning_effort_levels": [], "supports_preserve_thinking": False, "supports_tools": False, } @@ -2243,6 +2244,7 @@ async def load_model( native_context_length = llama_backend.native_context_length, supports_reasoning = llama_backend.supports_reasoning, reasoning_style = llama_backend.reasoning_style, + reasoning_effort_levels = llama_backend.reasoning_effort_levels, reasoning_always_on = llama_backend.reasoning_always_on, supports_preserve_thinking = llama_backend.supports_preserve_thinking, supports_tools = llama_backend.supports_tools, @@ -2289,6 +2291,7 @@ async def load_model( ), supports_reasoning = _sf_supports_reasoning, reasoning_style = _sf_reasoning_style, + reasoning_effort_levels = _sf_flags.get("reasoning_effort_levels", []), reasoning_always_on = _sf_flags["reasoning_always_on"], supports_preserve_thinking = _sf_flags["supports_preserve_thinking"], supports_tools = _sf_flags["supports_tools"], @@ -2553,6 +2556,7 @@ async def load_model( native_context_length = llama_backend.native_context_length, supports_reasoning = llama_backend.supports_reasoning, reasoning_style = llama_backend.reasoning_style, + reasoning_effort_levels = llama_backend.reasoning_effort_levels, reasoning_always_on = llama_backend.reasoning_always_on, supports_preserve_thinking = llama_backend.supports_preserve_thinking, supports_tools = llama_backend.supports_tools, @@ -2669,6 +2673,7 @@ async def load_model( requires_trust_remote_code = _requires_rc, supports_reasoning = _sf_flags["supports_reasoning"], reasoning_style = _sf_flags["reasoning_style"], + reasoning_effort_levels = _sf_flags.get("reasoning_effort_levels", []), reasoning_always_on = _sf_flags["reasoning_always_on"], supports_preserve_thinking = _sf_flags["supports_preserve_thinking"], supports_tools = _sf_flags["supports_tools"], @@ -3226,6 +3231,7 @@ async def get_status(current_subject: str = Depends(get_current_subject)): requires_trust_remote_code = False, supports_reasoning = llama_backend.supports_reasoning, reasoning_style = llama_backend.reasoning_style, + reasoning_effort_levels = llama_backend.reasoning_effort_levels, reasoning_always_on = llama_backend.reasoning_always_on, supports_preserve_thinking = llama_backend.supports_preserve_thinking, supports_tools = llama_backend.supports_tools, @@ -3286,6 +3292,7 @@ async def get_status(current_subject: str = Depends(get_current_subject)): ), supports_reasoning = _sf_flags["supports_reasoning"], reasoning_style = _sf_flags["reasoning_style"], + reasoning_effort_levels = _sf_flags.get("reasoning_effort_levels", []), reasoning_always_on = _sf_flags["reasoning_always_on"], supports_preserve_thinking = _sf_flags["supports_preserve_thinking"], supports_tools = _sf_flags["supports_tools"], diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 6f11ec96f7..c08619d611 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -272,10 +272,22 @@ function ListLabel({ /** Format bytes to a human-readable size string. */ function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; + // Guard non-positive / non-finite sizes (0, missing -> NaN, Infinity) so we + // never render "NaN undefined" or a negative unit index. + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + // Decimal (base-1000) units to match what Hugging Face reports for a repo's + // file sizes -- e.g. 217 GB, not the 201.8 GiB a base-1024 divide would show. + // (GPU-fit math below stays base-1024 since VRAM is binary.) + // Divide iteratively rather than via Math.log, which has float error at exact + // powers of 1000 (log(1e12)/log(1000) = 3.9999... would mislabel 1 TB as + // "1000 GB"); the loop also can't run off the end of units. const units = ["B", "KB", "MB", "GB", "TB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const value = bytes / 1024 ** i; + let i = 0; + let value = bytes; + while (value >= 1000 && i < units.length - 1) { + value /= 1000; + i += 1; + } return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`; } diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index f719af975e..8789a1bb71 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -2078,7 +2078,11 @@ const ReasoningToggle: FC<{ side?: "top" | "bottom" }> = ({ return null; } - const isEffort = effectiveReasoningStyle === "reasoning_effort"; + // enable_thinking_effort (GLM-5.2: high|max + disable) reuses the effort + // dropdown; it just also carries an Off row via supportsReasoningOff. + const isEffort = + effectiveReasoningStyle === "reasoning_effort" || + effectiveReasoningStyle === "enable_thinking_effort"; // Dropdown when there are effort levels or preserve-thinking; else a toggle. const useDropdown = isEffort || supportsPreserveThinking; const activeLook = isEffort diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 956056b83b..a9097527be 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -20,7 +20,10 @@ import { toExternalBackendProviderType, } from "../external-providers"; import { pickFriendlyContainerName } from "../lib/friendly-names"; -import { tryAdoptServerActiveModel } from "../lib/apply-inference-status-to-store"; +import { + reasoningCapsFromLoad, + tryAdoptServerActiveModel, +} from "../lib/apply-inference-status-to-store"; import { clampReasoningEffortToLevels, getExternalMaxOutputTokens, @@ -1302,7 +1305,7 @@ async function autoLoadSmallestModel(): Promise<{ supportsReasoning: loadResp.supports_reasoning ?? false, reasoningAlwaysOn: loadResp.reasoning_always_on ?? false, reasoningEnabled: loadResp.supports_reasoning ?? false, - reasoningStyle: loadResp.reasoning_style ?? "enable_thinking", + ...reasoningCapsFromLoad(loadResp), supportsPreserveThinking: loadResp.supports_preserve_thinking ?? false, supportsTools: loadResp.supports_tools ?? false, @@ -1370,7 +1373,7 @@ async function autoLoadSmallestModel(): Promise<{ supportsReasoning: sfLoadResp.supports_reasoning ?? false, reasoningAlwaysOn: sfLoadResp.reasoning_always_on ?? false, reasoningEnabled: sfLoadResp.supports_reasoning ?? false, - reasoningStyle: sfLoadResp.reasoning_style ?? "enable_thinking", + ...reasoningCapsFromLoad(sfLoadResp), supportsPreserveThinking: sfLoadResp.supports_preserve_thinking ?? false, supportsTools: sfLoadResp.supports_tools ?? false, @@ -1474,7 +1477,7 @@ async function autoLoadSmallestModel(): Promise<{ supportsReasoning: loadResp.supports_reasoning ?? false, reasoningAlwaysOn: loadResp.reasoning_always_on ?? false, reasoningEnabled: loadResp.supports_reasoning ?? false, - reasoningStyle: loadResp.reasoning_style ?? "enable_thinking", + ...reasoningCapsFromLoad(loadResp), supportsPreserveThinking: loadResp.supports_preserve_thinking ?? false, supportsTools: loadResp.supports_tools ?? false, ...resolveToolsEnabledOnLoad(loadResp.supports_tools ?? false), @@ -2218,6 +2221,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { reasoningEnabled, reasoningStyle, reasoningEffort, + reasoningEffortLevels, supportsPreserveThinking, preserveThinking, } = runtime; @@ -2258,12 +2262,15 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { reasoningEffort, externalReasoningCaps.reasoningEffortLevels, ) as RequestReasoningEffort; - const localReasoningEffort = - reasoningEffort === "low" || - reasoningEffort === "medium" || - reasoningEffort === "high" - ? reasoningEffort - : "low"; + // Clamp to the loaded local model's advertised levels so a stale value + // (e.g. "max" carried over from an external model, or a level this model + // lacks) becomes one the backend will honor instead of being dropped: + // gpt-oss-style reasoning_effort gets low|medium|high, GLM-style + // enable_thinking_effort gets high|max. + const localReasoningEffort = clampReasoningEffortToLevels( + reasoningEffort, + reasoningEffortLevels, + ); const externalReasoningEnabled = !externalReasoningCaps.supportsReasoningOff ? true : reasoningEnabled; const buildRequestPayload = async ( @@ -2506,11 +2513,25 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { ...(sandboxSessionId ? { session_id: sandboxSessionId } : {}), ...(useAdapter === undefined ? {} : { use_adapter: useAdapter }), ...(supportsReasoning - ? reasoningStyle === "reasoning_effort" - ? reasoningEnabled - ? { reasoning_effort: localReasoningEffort } - : {} - : { thinking: { type: reasoningEnabled ? "enabled" : "disabled" } } + ? reasoningStyle === "enable_thinking_effort" + ? // GLM-5.2-style: on/off gate plus an effort level. Disabling + // sends enable_thinking=false (a real disable); enabling sends + // the chosen level (e.g. high|max). + reasoningEnabled + ? { + enable_thinking: true, + reasoning_effort: localReasoningEffort, + } + : { enable_thinking: false } + : reasoningStyle === "reasoning_effort" + ? reasoningEnabled + ? { reasoning_effort: localReasoningEffort } + : {} + : { + thinking: { + type: reasoningEnabled ? "enabled" : "disabled", + }, + } : {}), ...(supportsPreserveThinking ? { preserve_thinking: preserveThinking } diff --git a/studio/frontend/src/features/chat/bypass-permissions-menu-item.tsx b/studio/frontend/src/features/chat/bypass-permissions-menu-item.tsx index 2f61d07a52..14cb6747e9 100644 --- a/studio/frontend/src/features/chat/bypass-permissions-menu-item.tsx +++ b/studio/frontend/src/features/chat/bypass-permissions-menu-item.tsx @@ -1,8 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import { useState } from "react"; - import { ShieldBanIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -20,63 +18,80 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import { Tick02Icon } from "@/lib/tick-icon"; -// "Bypass Permissions" entry for the composer "+" -> More menu. Mirrors the +// "Bypass permissions" entry for the composer "+" -> More menu. Mirrors the // settings toggle: enabling demands the danger warning, disabling is immediate. -// onSelect preventDefault keeps the menu mounted so the warning dialog (which -// lives in this same fragment) survives instead of unmounting with the menu. +// The menu closes normally on select (no preventDefault) -- the warning dialog +// lives outside the menu (BypassPermissionsConfirmDialog, mounted once at the +// chat-page root and driven by the store), so it survives the menu unmounting +// and the "+"/More popovers don't stay frozen. export function BypassPermissionsMenuItem() { const bypassPermissions = useChatRuntimeStore((s) => s.bypassPermissions); const setBypassPermissions = useChatRuntimeStore( (s) => s.setBypassPermissions, ); - const [dialogOpen, setDialogOpen] = useState(false); + const setBypassConfirmOpen = useChatRuntimeStore( + (s) => s.setBypassConfirmOpen, + ); return ( - <> - { + if (bypassPermissions) { + setBypassPermissions(false); + } else { + // Defer past Radix's menu-close focus restoration: opening the dialog + // synchronously here lets the dropdown grab focus back and breaks the + // dialog's focus trap. + setTimeout(() => setBypassConfirmOpen(true), 0); } - onSelect={(e) => { - if (bypassPermissions) { - setBypassPermissions(false); - } else { - e.preventDefault(); - setDialogOpen(true); - } - }} - > - - Bypass permissions - {bypassPermissions ? ( - - ) : null} - - - - - Enable Bypass permissions? - - Bypass permissions is dangerous since the AI model might delete, - corrupt your machine, and or cause real world damage to you or the - world - only accept if you are certain - - - - Cancel - { - setBypassPermissions(true); - setDialogOpen(false); - }} - > - I understand - - - - - + }} + > + + Bypass permissions + {bypassPermissions ? ( + + ) : null} + + ); +} + +// The danger-confirmation dialog. Mounted once at the chat-page root (not inside +// a Composer or the menu) and driven by global store state, so it works for both +// the main and shared composers, never duplicates in Compare mode, and confirming +// or cancelling never leaves the composer "+"/More popovers frozen open. +export function BypassPermissionsConfirmDialog() { + const open = useChatRuntimeStore((s) => s.bypassConfirmOpen); + const setOpen = useChatRuntimeStore((s) => s.setBypassConfirmOpen); + const setBypassPermissions = useChatRuntimeStore( + (s) => s.setBypassPermissions, + ); + + return ( + + + + Enable Bypass permissions? + + Bypass permissions is dangerous since the AI model might delete, + corrupt your machine, and or cause real world damage to you or the + world - only accept if you are certain + + + + Cancel + { + setBypassPermissions(true); + setOpen(false); + }} + > + I understand + + + + ); } diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 5c1da88022..c4ca5d96f9 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -94,6 +94,7 @@ import { RegisterCompareHandle, SharedComposer, } from "./shared-composer"; +import { BypassPermissionsConfirmDialog } from "./bypass-permissions-menu-item"; import { CHAT_CODE_TOOLS_ENABLED_KEY, CHAT_IMAGE_TOOLS_ENABLED_KEY, @@ -2235,6 +2236,12 @@ export function ChatPage({ {/* Portaled surfaces render to document.body, escaping the parent's hidden wrapper, so gate them on `active` to keep them off other tabs. */} {active && } + {/* Single app-level mount for the Bypass permissions warning. It is driven + by global store state, so it must live at one stable root (not inside a + Composer) -- otherwise Compare mode's multiple composers would each + render their own copy and the shared-composer menu would have none. It + also portals to body, so gate it on `active` like the tour above. */} + {active && }
{/* Fade under the top bar so messages dissolve as they scroll 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 7777fdb7e9..f8a0eb579b 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 @@ -29,7 +29,9 @@ import { resolveToolsEnabledOnLoad, saveSpeculativeType, useChatRuntimeStore, + type ReasoningEffort, } from "../stores/chat-runtime-store"; +import { clampReasoningEffortToLevels } from "../provider-capabilities"; import { applyActiveModelStatusToStore, clampLocalReasoningEffort, @@ -677,14 +679,21 @@ export function useChatModelRuntime() { const reasoningStyle = loadResponse.reasoning_style ?? "enable_thinking"; const supportsReasoning = loadResponse.supports_reasoning ?? false; const supportsTools = loadResponse.supports_tools ?? false; + // GLM-5.2-style models report their own effort levels (e.g. + // high|max); everything else keeps the default low/medium/high. const reasoningEffortLevels = - reasoningStyle === "reasoning_effort" - ? (["low", "medium", "high"] as const) + loadResponse.reasoning_effort_levels && + loadResponse.reasoning_effort_levels.length > 0 + ? (loadResponse.reasoning_effort_levels as ReasoningEffort[]) : (["low", "medium", "high"] as const); const existingReasoningEffort = useChatRuntimeStore.getState().reasoningEffort; - const clampedReasoningEffort = clampLocalReasoningEffort( - existingReasoningEffort, - ); + const clampedReasoningEffort = + reasoningStyle === "enable_thinking_effort" + ? clampReasoningEffortToLevels( + existingReasoningEffort, + reasoningEffortLevels, + ) + : clampLocalReasoningEffort(existingReasoningEffort); const ggufMaxContextLength = reportedMaxCtx; const nextReasoningEnabled = reasoningAlwaysOn ? true @@ -927,8 +936,8 @@ export function useChatModelRuntime() { if (prog.progress > 0 && prog.progress < 1) { hasShownProgress = true; - const dlGb = prog.downloaded_bytes / (1024 ** 3); - const totalGb = prog.expected_bytes / (1024 ** 3); + const dlGb = prog.downloaded_bytes / 1e9; + const totalGb = prog.expected_bytes / 1e9; const pct = Math.round(prog.progress * 100); const progressLabel = composeProgressLabel( dlGb, @@ -960,7 +969,7 @@ export function useChatModelRuntime() { prog.progress === 0 ) { hasShownProgress = true; - const dlGb = prog.downloaded_bytes / (1024 ** 3); + const dlGb = prog.downloaded_bytes / 1e9; const est = estimate(dlSamples, prog.downloaded_bytes, 0); const rateSuffix = est.stable ? ` • ${formatRate(est.rate)}` : ""; @@ -1018,8 +1027,10 @@ export function useChatModelRuntime() { return; } if (prog.bytes_total <= 0) return; // nothing useful to render - const loadedGb = prog.bytes_loaded / (1024 ** 3); - const totalGb = prog.bytes_total / (1024 ** 3); + // Decimal GB (1e9) so the total matches the file size Hugging Face + // reports and the model-picker shows, not the smaller base-1024 GiB. + const loadedGb = prog.bytes_loaded / 1e9; + const totalGb = prog.bytes_total / 1e9; const pct = Math.min(99, Math.round(prog.fraction * 100)); const est = estimate(mmapSamples, prog.bytes_loaded, prog.bytes_total); const base = `${loadedGb.toFixed(1)} of ${totalGb.toFixed(1)} GB in memory`; diff --git a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts index dbe9ef4dc3..9b7ca08c1b 100644 --- a/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts +++ b/studio/frontend/src/features/chat/lib/apply-inference-status-to-store.ts @@ -7,10 +7,12 @@ import { CHAT_REASONING_ENABLED_KEY, loadOptionalBool, type ReasoningEffort, + type ReasoningStyle, resolveToolsEnabledOnLoad, useChatRuntimeStore, } from "../stores/chat-runtime-store"; import { isMultimodalResponse, type InferenceStatusResponse } from "../types/api"; +import { clampReasoningEffortToLevels } from "../provider-capabilities"; import type { ChatModelSummary } from "../types/runtime"; type LocalReasoningEffort = Extract; @@ -49,6 +51,38 @@ export function clampLocalReasoningEffort( return "low"; } +/** + * Reasoning capability fields derived from a model load/status response. + * + * Centralises the effort-levels + can-disable derivation so every load path + * (main load, status sync, shared/Compare composer, first-chat auto-load) agrees: + * a hybrid GLM-style `enable_thinking_effort` model keeps its high|max|Off + * controls no matter which path loaded it, instead of falling back to the + * default low|medium|high and losing Max/Off. + */ +export function reasoningCapsFromLoad(resp: { + reasoning_style?: ReasoningStyle | null; + reasoning_effort_levels?: string[] | null; +}): { + reasoningStyle: ReasoningStyle; + reasoningEffortLevels: readonly ReasoningEffort[]; + supportsReasoningOff: boolean; +} { + const reasoningStyle: ReasoningStyle = + resp.reasoning_style ?? "enable_thinking"; + const reasoningEffortLevels: readonly ReasoningEffort[] = + resp.reasoning_effort_levels && resp.reasoning_effort_levels.length > 0 + ? (resp.reasoning_effort_levels as ReasoningEffort[]) + : (["low", "medium", "high"] as const); + // enable_thinking and enable_thinking_effort can both be turned off; only the + // pure gpt-oss-style reasoning_effort is always-on. + return { + reasoningStyle, + reasoningEffortLevels, + supportsReasoningOff: reasoningStyle !== "reasoning_effort", + }; +} + export function resolveInferenceCheckpointId( status: InferenceStatusResponse, ): string | null { @@ -110,9 +144,11 @@ export function applyActiveModelStatusToStore( const supportsReasoning = status.supports_reasoning ?? false; const reasoningAlwaysOn = status.reasoning_always_on ?? false; const reasoningStyle = status.reasoning_style ?? "enable_thinking"; + // GLM-5.2-style models report their own effort levels (e.g. high|max); + // everything else keeps the default low/medium/high. const reasoningEffortLevels = - reasoningStyle === "reasoning_effort" - ? (["low", "medium", "high"] as const) + status.reasoning_effort_levels && status.reasoning_effort_levels.length > 0 + ? (status.reasoning_effort_levels as ReasoningEffort[]) : (["low", "medium", "high"] as const); const supportsPreserveThinking = status.supports_preserve_thinking ?? false; const supportsTools = status.supports_tools ?? false; @@ -128,9 +164,13 @@ export function applyActiveModelStatusToStore( : null; const currentSpecType = normalizeSpeculativeType(status.speculative_type); const prevState = useChatRuntimeStore.getState(); - const clampedReasoningEffort = clampLocalReasoningEffort( - prevState.reasoningEffort, - ); + const clampedReasoningEffort = + reasoningStyle === "enable_thinking_effort" + ? clampReasoningEffortToLevels( + prevState.reasoningEffort, + reasoningEffortLevels, + ) + : clampLocalReasoningEffort(prevState.reasoningEffort); const nextDefaultChatTemplate = status.chat_template === undefined ? prevState.defaultChatTemplate diff --git a/studio/frontend/src/features/chat/provider-capabilities.ts b/studio/frontend/src/features/chat/provider-capabilities.ts index 07c953fecc..79c9a3205c 100644 --- a/studio/frontend/src/features/chat/provider-capabilities.ts +++ b/studio/frontend/src/features/chat/provider-capabilities.ts @@ -30,7 +30,10 @@ export interface ProviderCapabilities { export type ExternalReasoningCapabilities = { supportsReasoning: boolean; - reasoningStyle: "enable_thinking" | "reasoning_effort"; + // Mirrors the store's ReasoningStyle. External providers only ever use the + // first two; "enable_thinking_effort" exists so a local model's caps can be + // assigned here without narrowing. + reasoningStyle: "enable_thinking" | "reasoning_effort" | "enable_thinking_effort"; reasoningAlwaysOn: boolean; supportsReasoningOff: boolean; reasoningEffortLevels: readonly ( diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 13aaba11d5..2adc3ae25a 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -61,6 +61,7 @@ import { import { listPromptEntries, type PromptEntry } from "./api/prompts-api"; import { McpComposerButton } from "./mcp-composer-button"; import { BypassPermissionsMenuItem } from "./bypass-permissions-menu-item"; +import { reasoningCapsFromLoad } from "./lib/apply-inference-status-to-store"; import { KnowledgeBaseComposerButton } from "@/features/rag/components/knowledge-base-composer-button"; import { NewProjectDialog } from "./components/new-project-dialog"; import { useChatProjects } from "./hooks/use-chat-projects"; @@ -617,7 +618,11 @@ export function SharedComposer({ const reasoningDisabled = !modelLoaded || !effectiveSupportsReasoning; const showReasoningControl = effectiveSupportsReasoning || effectiveReasoningAlwaysOn; - const isEffort = effectiveReasoningStyle === "reasoning_effort"; + // enable_thinking_effort (GLM-5.2: high|max + disable) reuses the effort + // dropdown; it just also carries an Off row via supportsReasoningOff. + const isEffort = + effectiveReasoningStyle === "reasoning_effort" || + effectiveReasoningStyle === "enable_thinking_effort"; const thinkingActiveLook = isEffort ? reasoningLockedOn || (effectiveReasoningVisualEnabled && !reasoningDisabled) : reasoningLockedOn || (effectiveReasoningEnabled && !reasoningDisabled); @@ -1001,7 +1006,7 @@ export function SharedComposer({ useChatRuntimeStore.setState({ supportsReasoning: resp.supports_reasoning ?? false, reasoningAlwaysOn: resp.reasoning_always_on ?? false, - reasoningStyle: resp.reasoning_style ?? "enable_thinking", + ...reasoningCapsFromLoad(resp), supportsPreserveThinking: resp.supports_preserve_thinking ?? false, supportsTools: resp.supports_tools ?? false, tensorParallel: resp.tensor_parallel ?? false, 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 f5db82366c..e2d8701695 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -168,7 +168,13 @@ function saveLastExternalCheckpoint(value: string | null): void { } } -export type ReasoningStyle = "enable_thinking" | "reasoning_effort"; +// "enable_thinking_effort" is a hybrid: an on/off gate (enable_thinking) plus an +// effort level among a discrete set (e.g. GLM-5.2's high|max). It reuses the +// reasoning_effort dropdown UI but, unlike gpt-oss, can be fully disabled. +export type ReasoningStyle = + | "enable_thinking" + | "reasoning_effort" + | "enable_thinking_effort"; /** One live DiffusionGemma denoising snapshot: the current canvas text at a * given step of a given block (block/step are 0-based; total = steps in block). */ export type DiffusionCanvasFrame = { @@ -533,6 +539,9 @@ type ChatRuntimeStore = { * (secrets are still stripped). Takes precedence over confirmToolCalls. */ bypassPermissions: boolean; + /** Whether the "Enable Bypass Permissions?" warning dialog is open. Lifted out + * of the composer menu so confirming/cancelling it doesn't leave the menu frozen. */ + bypassConfirmOpen: boolean; /** * Per-chat set of tool names the user chose to auto-approve via "Always * allow". Keyed by UI confirmation scope, not necessarily the backend @@ -662,6 +671,7 @@ type ChatRuntimeStore = { setMcpEnabledForChat: (enabled: boolean) => void; setConfirmToolCalls: (enabled: boolean) => void; setBypassPermissions: (enabled: boolean) => void; + setBypassConfirmOpen: (open: boolean) => void; allowToolAlways: (sessionId: string, toolName: string) => void; setToolConfirmation: ( toolCallId: string, @@ -973,6 +983,7 @@ export const useChatRuntimeStore = create((set, get) => ({ // the confirmation gate, so it must be re-enabled (through the warning // dialog) each session rather than silently reactivating on reload. bypassPermissions: false, + bypassConfirmOpen: false, alwaysAllowToolsBySession: new Map>(), toolConfirmations: {}, webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false), @@ -1338,6 +1349,8 @@ export const useChatRuntimeStore = create((set, get) => ({ // Deliberately not persisted (see init): a reload must not silently keep // the sandbox/confirmation bypass active without re-accepting the warning. set(() => ({ bypassPermissions })), + setBypassConfirmOpen: (bypassConfirmOpen) => + set(() => ({ bypassConfirmOpen })), allowToolAlways: (sessionId, toolName) => set((state) => { const current = state.alwaysAllowToolsBySession.get(sessionId); diff --git a/studio/frontend/src/features/chat/types/api.ts b/studio/frontend/src/features/chat/types/api.ts index 8fe0a0ef12..4ac70486e5 100644 --- a/studio/frontend/src/features/chat/types/api.ts +++ b/studio/frontend/src/features/chat/types/api.ts @@ -137,7 +137,8 @@ export interface LoadModelResponse { max_context_length?: number | null; native_context_length?: number | null; supports_reasoning?: boolean; - reasoning_style?: "enable_thinking" | "reasoning_effort"; + reasoning_style?: "enable_thinking" | "reasoning_effort" | "enable_thinking_effort"; + reasoning_effort_levels?: string[]; reasoning_always_on?: boolean; supports_preserve_thinking?: boolean; supports_tools?: boolean; @@ -176,7 +177,8 @@ export interface InferenceStatusResponse { } | null; requires_trust_remote_code?: boolean; supports_reasoning?: boolean; - reasoning_style?: "enable_thinking" | "reasoning_effort"; + reasoning_style?: "enable_thinking" | "reasoning_effort" | "enable_thinking_effort"; + reasoning_effort_levels?: string[]; reasoning_always_on?: boolean; supports_preserve_thinking?: boolean; supports_tools?: boolean;