Studio: Bypass Permissions menu fix, decimal GB sizes, and GLM-5.2 high/max/disabled thinking (#6444)

* Studio: fix Bypass Permissions menu freeze and show decimal GB for model sizes

Bypass Permissions freeze: the warning dialog lived inside the composer
"+"/More dropdown and kept the menu mounted via onSelect preventDefault,
so confirming or cancelling the dialog left both popovers frozen open.
Lift the dialog out of the menu into a store-driven
BypassPermissionsConfirmDialog mounted at a stable spot in the composer.
The menu item now closes normally on select and just toggles a new
bypassConfirmOpen store flag, so the popovers dismiss as expected.

Model search sizes: formatBytes divided bytes by 1024 but labelled the
result "GB", so unsloth/GLM-5.2-GGUF:UD-IQ1_S showed 201.8 GB where
Hugging Face reports 217 GB. Switch the search display to decimal
(base-1000) units to match what Hugging Face reports. The GPU-fit math
stays base-1024 since VRAM capacity is binary.

* Studio: address review feedback and add GLM-5.2 high/max/disabled thinking

Review feedback on the Bypass Permissions and size-format changes:

- Mount the Bypass Permissions warning dialog once at the chat-page root
  instead of inside each Composer. It is driven by global store state, so
  the per-composer mount meant Compare mode (multiple composers) rendered
  duplicate dialogs and the shared-composer menu had none. A single root
  mount fixes both.
- Defer opening the dialog past Radix's menu-close focus restoration with
  setTimeout(0), so the dropdown does not steal focus back and break the
  dialog's focus trap.
- Clamp the unit index in formatBytes so units[i] cannot go out of bounds
  past TB (and to absorb log() float error at exact powers of 1000).

GLM-5.2 reasoning levels:

GLM-5.2's template gates thinking with enable_thinking and also reads a
reasoning_effort level ('high' or 'max'), so it needs high / max /
disabled rather than the binary toggle it got before (its style was
detected as enable_thinking, which made 'high' unreachable). Add a new
reasoning style 'enable_thinking_effort' that reuses the effort dropdown
but, unlike gpt-oss, can be fully disabled:

- detect_reasoning_flags classifies a template that has both
  enable_thinking and reasoning_effort, extracting the discrete levels
  from the quoted effort literals it branches on. Templates with only one
  of the two (gpt-oss, Qwen3, DeepSeek, GLM-4.6) are unchanged.
- _request_reasoning_kwargs maps the new style to enable_thinking plus an
  in-range reasoning_effort; disabling sends enable_thinking=false. The
  gpt-oss reasoning_effort path is left untouched.
- The backend reports reasoning_effort_levels on the load/status response;
  the frontend carries them through to the effort dropdown and sends
  enable_thinking + reasoning_effort for this style.

Verified: backend reasoning kwargs render the real GLM-5.2 template to
"Reasoning Effort: High/Max" (thinking) and an empty <think></think>
(disabled); tsc, eslint, i18n parity and the production build all pass.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: address review feedback on reasoning effort and formatBytes

- chat-adapter localReasoningEffort: accept 'minimal' so a template that
  branches on it (extracted into reasoning_effort_levels) is sent through
  instead of being coerced to 'low' and then dropped by the backend.
- formatBytes: return '0 B' for non-finite / non-positive sizes (missing
  metadata -> NaN, Infinity, negatives) and clamp the unit index lower
  bound to 0, so sub-1-byte values can't produce a negative index.

* Studio: hybrid reasoning none gate and decimal GB in load progress

- _request_reasoning_kwargs: for enable_thinking_effort models, treat a
  raw reasoning_effort='none' (OpenAI 'no reasoning' sentinel) as the
  enable_thinking=false off gate, so a direct API caller can disable
  thinking even without passing enable_thinking. The frontend already
  sends enable_thinking=false; this only affects raw API callers.
- use-chat-model-runtime: the download / 'X of Y GB in memory' load
  progress divided bytes by 1024**3 but labelled GB, so it disagreed with
  the model picker and Hugging Face. Use decimal GB (1e9) to match.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: carry hybrid reasoning levels on all load paths and harden formatBytes

Review follow-ups on the enable_thinking_effort work:

- Every model-load path now copies reasoning_effort_levels and derives
  supportsReasoningOff, via a shared reasoningCapsFromLoad() helper. The
  shared/Compare composer load and the three chat-adapter auto-load paths
  previously set only reasoningStyle, so a GLM-style hybrid model loaded
  through Compare or first-chat auto-load fell back to the default
  low|medium|high and lost its Max / Off controls.
- The local send path clamps the effort to the loaded model's advertised
  levels (clampReasoningEffortToLevels) instead of a hard-coded list. A
  stale "max" carried over from an external provider no longer reaches a
  pure reasoning_effort (gpt-oss) model that only accepts none|low|medium|
  high, where the backend would have dropped it.
- formatBytes divides iteratively instead of via Math.log, which has float
  error at exact powers of 1000 (log(1e12)/log(1000) = 3.9999... would
  label 1 TB as "1000 GB"). Keeps the non-finite/non-positive guard.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-06-18 10:10:01 -07:00 committed by GitHub
commit 0c1127cb08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 319 additions and 99 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<>
<DropdownMenuItem
className={
bypassPermissions ? "text-bypass font-medium" : undefined
<DropdownMenuItem
className={bypassPermissions ? "text-bypass font-medium" : undefined}
onSelect={() => {
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);
}
}}
>
<HugeiconsIcon icon={ShieldBanIcon} strokeWidth={2} />
Bypass permissions
{bypassPermissions ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
</DropdownMenuItem>
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Enable Bypass permissions?</AlertDialogTitle>
<AlertDialogDescription>
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
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
className="!bg-destructive !text-destructive-foreground hover:!bg-destructive/90"
onClick={() => {
setBypassPermissions(true);
setDialogOpen(false);
}}
>
I understand
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
}}
>
<HugeiconsIcon icon={ShieldBanIcon} strokeWidth={2} />
Bypass permissions
{bypassPermissions ? (
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
) : null}
</DropdownMenuItem>
);
}
// 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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Enable Bypass permissions?</AlertDialogTitle>
<AlertDialogDescription>
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
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
className="!bg-destructive !text-destructive-foreground hover:!bg-destructive/90"
onClick={() => {
setBypassPermissions(true);
setOpen(false);
}}
>
I understand
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -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 && <GuidedTour {...tour.tourProps} />}
{/* 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 && <BypassPermissionsConfirmDialog />}
<div className="relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
<NativeModelDropOverlay state={nativeModelDropState} />
{/* Fade under the top bar so messages dissolve as they scroll

View file

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

View file

@ -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<ReasoningEffort, "low" | "medium" | "high">;
@ -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

View file

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

View file

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

View file

@ -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<ChatRuntimeStore>((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<string, Set<string>>(),
toolConfirmations: {},
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
@ -1338,6 +1349,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((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);

View file

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