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:
committed by
brooklyn!
parent
d39a031329
commit
d15cd18fa1
@@ -458,6 +458,7 @@ export function ModelCatalogMenu({
|
||||
) : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
canDisableReasoning={caps?.can_disable_reasoning}
|
||||
defaultEffort={defaultEffort}
|
||||
effort={effEffort}
|
||||
fastControl={fastControl}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
141
tests/hermes_cli/test_inventory_reasoning_caps.py
Normal file
141
tests/hermes_cli/test_inventory_reasoning_caps.py
Normal 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
|
||||
Reference in New Issue
Block a user