feat(desktop): hide the Thinking toggle where a disable is rejected

The picker offered an off switch for every reasoning model, including routes
whose upstream answers a disable with HTTP 400 — so "thinking off" was a
control that could not work. Carry the catalog's mandatory verdict through
model.options as can_disable_reasoning and hide the toggle when it is false.

Effort levels are left alone. The catalog's supported_efforts under-reports
what the Portal serves (z-ai/glm-5.3 publishes max, high, low yet honors
minimal at its lowest thinking), so filtering the scale by it would hide
levels that work.
This commit is contained in:
Brooklyn Nicholson
2026-08-19 22:04:47 -05:00
committed by brooklyn!
parent d39a031329
commit d15cd18fa1
5 changed files with 205 additions and 5 deletions

View File

@@ -458,6 +458,7 @@ export function ModelCatalogMenu({
) : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
canDisableReasoning={caps?.can_disable_reasoning}
defaultEffort={defaultEffort}
effort={effEffort}
fastControl={fastControl}

View File

@@ -59,6 +59,10 @@ export function resolveFastControl(
}
interface ModelEditSubmenuProps {
/** Whether this model can turn thinking off. False on reasoning-mandatory
* routes, whose upstream rejects a disable — the toggle is hidden rather
* than offered as a control that silently does nothing. */
canDisableReasoning?: boolean
/** The profile's configured default effort — what an unset row inherits.
* Passed in (not read from a store) so this submenu stays pure. */
defaultEffort: string
@@ -98,6 +102,7 @@ export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
}
function ModelEditSubmenuBody({
canDisableReasoning,
defaultEffort,
effort,
fastControl,
@@ -111,6 +116,7 @@ function ModelEditSubmenuBody({
const effortValue = resolveReasoningEffort(effort, defaultEffort)
const thinkingOn = isThinkingEnabled(effort, defaultEffort)
const showThinkingToggle = reasoning && canDisableReasoning !== false
const setFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
@@ -139,7 +145,7 @@ function ModelEditSubmenuBody({
) : (
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel>
{reasoning ? (
{showThinkingToggle ? (
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
{copy.thinking}
<Switch

View File

@@ -427,6 +427,10 @@ export interface ModelOptionProvider {
}
export interface ModelCapabilities {
/** False when the route rejects a reasoning disable ("mandatory" in the
* provider catalog), so the Thinking toggle must not be offered. Absent
* when the catalog doesn't say. */
can_disable_reasoning?: boolean
fast: boolean
reasoning: boolean
}

View File

@@ -34,7 +34,7 @@ Substrate facts (verified May 2026):
from __future__ import annotations
from dataclasses import dataclass, replace
from typing import Optional
from typing import Any, Optional
# ─── Public types ───────────────────────────────────────────────────────
@@ -402,14 +402,51 @@ def format_aux_picker_entries(
return entries
def _reasoning_catalog_reader(slug: str):
"""Per-model reasoning-capability reader for aggregators that publish one.
Cache-only — building the picker payload must never block on HTTP. A cold
cache warms in the background so the next open is accurate; until then the
model reports no restriction and the UI offers the full scale.
"""
try:
from hermes_cli.models import (
nous_model_reasoning_capabilities,
openrouter_model_reasoning_capabilities,
warm_nous_reasoning_caps_async,
warm_openrouter_reasoning_caps_async,
)
except Exception:
return None
if slug == "nous":
warm_nous_reasoning_caps_async()
return nous_model_reasoning_capabilities
if slug == "openrouter":
warm_openrouter_reasoning_caps_async()
return openrouter_model_reasoning_capabilities
return None
def _apply_capabilities(rows: list[dict]) -> None:
"""Attach a ``{model: {fast, reasoning}}`` map to each provider row.
"""Attach a ``{model: {fast, reasoning, ...}}`` map to each provider row.
`fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime
enforces). `reasoning` comes from the models.dev catalog when known and
defaults to True otherwise — the effort dial is broadly accepted and a
no-op on models that ignore it, whereas hiding it from a capable-but-
uncatalogued model is the worse failure.
Aggregators that publish per-model reasoning detail add
`can_disable_reasoning`, False on reasoning-mandatory routes whose upstream
answers a disable with HTTP 400. Omitted when the catalog doesn't say,
which the UI reads as "no restriction known".
The catalog's `supported_efforts` list is deliberately NOT forwarded: it
under-reports. The Portal accepts and honors levels a route doesn't
advertise (``z-ai/glm-5.3`` publishes ``max, high, low`` yet serves
``minimal`` at its lowest thinking), so filtering the picker by that list
would hide levels that demonstrably work.
"""
from hermes_cli.models import model_supports_fast_mode
@@ -420,7 +457,8 @@ def _apply_capabilities(rows: list[dict]) -> None:
for row in rows:
slug = row.get("slug") or ""
caps: dict[str, dict[str, bool]] = {}
caps: dict[str, dict[str, Any]] = {}
read_reasoning_catalog = _reasoning_catalog_reader(slug.lower())
for model in row.get("models") or []:
reasoning = True
@@ -432,11 +470,21 @@ def _apply_capabilities(rows: list[dict]) -> None:
except Exception:
reasoning = True
caps[model] = {
entry: dict[str, Any] = {
"fast": bool(model_supports_fast_mode(model)),
"reasoning": reasoning,
}
if reasoning and read_reasoning_catalog is not None:
try:
detail = read_reasoning_catalog(model)
except Exception:
detail = None
if detail:
entry["can_disable_reasoning"] = not detail.get("mandatory")
caps[model] = entry
row["capabilities"] = caps

View File

@@ -0,0 +1,141 @@
"""Tests for the reasoning detail inventory._apply_capabilities puts on the
picker payload. The desktop model picker hides its Thinking toggle from this,
so a route that can't disable reasoning must be describable here — otherwise
the UI offers an off switch whose setting the upstream rejects.
The catalog's `supported_efforts` is intentionally absent from the payload:
the Portal honors levels a route doesn't advertise, so publishing it would
invite a picker filter that hides working levels.
"""
import hermes_cli.inventory as inv
import hermes_cli.models as models_mod
def _patch_catalog(monkeypatch, caps_by_model, *, provider="nous"):
"""Point the Nous/OpenRouter catalog readers at a fixed capability map."""
monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda model: False)
monkeypatch.setattr(
models_mod, "warm_nous_reasoning_caps_async", lambda: None, raising=False
)
monkeypatch.setattr(
models_mod, "warm_openrouter_reasoning_caps_async", lambda: None, raising=False
)
reader = f"{provider}_model_reasoning_capabilities"
monkeypatch.setattr(
models_mod, reader, lambda model, **kw: caps_by_model.get(model), raising=False
)
def test_optional_reasoning_route_can_disable(monkeypatch):
"""A route that accepts a disable says so."""
_patch_catalog(monkeypatch, {
"deepseek/deepseek-v4-pro": {
"supports_reasoning": True,
"supported_efforts": ["xhigh", "high"],
"mandatory": False,
},
})
rows = [{"slug": "nous", "models": ["deepseek/deepseek-v4-pro"]}]
inv._apply_capabilities(rows)
assert rows[0]["capabilities"]["deepseek/deepseek-v4-pro"]["can_disable_reasoning"] is True
def test_advertised_efforts_never_reach_the_picker(monkeypatch):
"""The catalog's level list stays off the wire even when it is published.
It under-reports what the Portal serves, so forwarding it would let the
picker hide levels that work. Only the disable verdict crosses.
"""
_patch_catalog(monkeypatch, {
"deepseek/deepseek-v4-pro": {
"supports_reasoning": True,
"supported_efforts": ["xhigh", "high"],
"mandatory": False,
},
})
rows = [{"slug": "nous", "models": ["deepseek/deepseek-v4-pro"]}]
inv._apply_capabilities(rows)
assert "supported_efforts" not in rows[0]["capabilities"]["deepseek/deepseek-v4-pro"]
def test_reasoning_mandatory_route_cannot_disable(monkeypatch):
"""`mandatory` inverts into the flag the Thinking toggle keys off.
The Portal answers a disable on these routes with HTTP 400, so offering
the toggle would be offering a control that cannot work.
"""
_patch_catalog(monkeypatch, {
"z-ai/glm-5.3": {
"supports_reasoning": True,
"supported_efforts": ["max", "high", "low"],
"mandatory": True,
},
})
rows = [{"slug": "nous", "models": ["z-ai/glm-5.3"]}]
inv._apply_capabilities(rows)
assert rows[0]["capabilities"]["z-ai/glm-5.3"]["can_disable_reasoning"] is False
def test_unlisted_model_states_no_restriction(monkeypatch):
"""A model the catalog doesn't cover omits both keys rather than guessing.
The UI reads "absent" as no known restriction and offers the full scale,
which is the right failure: over-offering beats hiding levels a model
actually accepts.
"""
_patch_catalog(monkeypatch, {})
rows = [{"slug": "nous", "models": ["mystery/model"]}]
inv._apply_capabilities(rows)
caps = rows[0]["capabilities"]["mystery/model"]
assert "supported_efforts" not in caps
assert "can_disable_reasoning" not in caps
assert caps["reasoning"] is True
def test_providers_without_a_reasoning_catalog_are_untouched(monkeypatch):
"""Only aggregators that publish per-model detail gain the extra keys."""
_patch_catalog(monkeypatch, {
"gpt-5.6": {"supports_reasoning": True, "supported_efforts": ["high"], "mandatory": True},
})
rows = [{"slug": "openai-api", "models": ["gpt-5.6"]}]
inv._apply_capabilities(rows)
caps = rows[0]["capabilities"]["gpt-5.6"]
assert "supported_efforts" not in caps
assert "can_disable_reasoning" not in caps
def test_openrouter_uses_its_own_catalog(monkeypatch):
"""The reader is chosen per provider row, not hardcoded to one aggregator."""
_patch_catalog(
monkeypatch,
{"x-ai/grok-5": {"supports_reasoning": True, "mandatory": True}},
provider="openrouter",
)
rows = [{"slug": "openrouter", "models": ["x-ai/grok-5"]}]
inv._apply_capabilities(rows)
assert rows[0]["capabilities"]["x-ai/grok-5"]["can_disable_reasoning"] is False
def test_catalog_failure_never_breaks_the_picker(monkeypatch):
"""A raising catalog reader degrades to "unknown", not to a broken payload."""
monkeypatch.setattr(models_mod, "model_supports_fast_mode", lambda model: False)
monkeypatch.setattr(models_mod, "warm_nous_reasoning_caps_async", lambda: None, raising=False)
def _boom(model, **kw):
raise RuntimeError("catalog exploded")
monkeypatch.setattr(models_mod, "nous_model_reasoning_capabilities", _boom, raising=False)
rows = [{"slug": "nous", "models": ["deepseek/deepseek-v4-pro"]}]
inv._apply_capabilities(rows)
caps = rows[0]["capabilities"]["deepseek/deepseek-v4-pro"]
assert "supported_efforts" not in caps
assert caps["reasoning"] is True