mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
fix(ui): hide reasoning chip when model lacks effort levels
Resolve supported reasoning efforts per active model/provider and pass that context through /api/reasoning so Composer and other non-configurable models no longer show a misleading effort picker. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,9 @@
|
||||
- **PR #2824** by @gavinssr — A "Stop server" affordance in Settings → System that gracefully shuts down the local WebUI server. Useful when WebUI was launched via `./ctl.sh start` or the native macOS/Windows app and the user wants to stop it without context-switching to a terminal. Confirmation dialog before the actual shutdown. The `/api/shutdown` route is CSRF-gated and intended for local-loopback use. Originally a title-bar button; relocated to Settings per the project's deep-UX rule (default-hidden for niche destructive actions on always-visible surfaces).
|
||||
|
||||
### Fixed
|
||||
|
||||
### Fixed
|
||||
- **Reasoning effort chip visibility** — `/api/reasoning` now accepts `model` and `provider` query params and returns `supported_efforts` so the composer chip hides for models without configurable reasoning levels (for example Cursor Composer) while remaining available for models like GPT-5.5.
|
||||
- **Cursor ACP routing and new-chat defaults** — New conversations now carry the visible composer picker selection into `POST /api/session/new`, persist model changes before a session exists, and evict cached session agents when the model/provider changes mid-session.
|
||||
|
||||
- **PR #2685** by @LumenYoung — Prevent replayed context in chat reconciliation and metering. When a WebUI session is recovered (e.g., after a process restart, network drop, or browser reload), the sidebar/`state.db` reconciliation logic walks the sidecar transcript in order and only skips rows that can actually be aligned with the remaining sidecar context. The prior set-membership check was too broad: a legitimate fresh message that happened to share a key with any older repeated short message in the sidecar was mis-classified as already-seen and dropped from the replay, leading to lost context and inconsistent metering. Also caps the per-turn live-tool-prompt token estimate at 12,000 to prevent unbounded growth on bursts of large tool reads before exact provider accounting overrides.
|
||||
|
||||
+113
-1
@@ -2073,7 +2073,112 @@ def parse_reasoning_effort(effort):
|
||||
return None
|
||||
|
||||
|
||||
def get_reasoning_status() -> dict:
|
||||
def _heuristic_reasoning_efforts(model_id: str, provider_id: str) -> list[str]:
|
||||
"""Fallback when hermes_cli is unavailable."""
|
||||
model = str(model_id or "").strip().lower()
|
||||
provider = _resolve_provider_alias(str(provider_id or "").strip().lower())
|
||||
if not model or provider in {"cursor-acp", "copilot-acp"}:
|
||||
return []
|
||||
bare = model.rsplit("/", 1)[-1]
|
||||
if provider == "openai-codex" and bare.startswith(("gpt-5", "o1", "o3", "o4")):
|
||||
if bare.startswith(("o1", "o3", "o4")):
|
||||
return ["low", "medium", "high"]
|
||||
return list(VALID_REASONING_EFFORTS)
|
||||
if provider in {"copilot", "github-copilot"}:
|
||||
if bare.startswith(("gpt-5", "o1", "o3", "o4")):
|
||||
if bare.startswith(("o1", "o3", "o4")):
|
||||
return ["low", "medium", "high"]
|
||||
return list(VALID_REASONING_EFFORTS)
|
||||
prefixes = (
|
||||
"deepseek/",
|
||||
"anthropic/",
|
||||
"openai/",
|
||||
"x-ai/",
|
||||
"google/gemini-2",
|
||||
"google/gemma-4",
|
||||
"qwen/qwen3",
|
||||
"tencent/hy3-preview",
|
||||
"xiaomi/",
|
||||
)
|
||||
if any(model.startswith(prefix) for prefix in prefixes):
|
||||
return list(VALID_REASONING_EFFORTS)
|
||||
return []
|
||||
|
||||
|
||||
def resolve_model_reasoning_efforts(
|
||||
model_id: str | None = None,
|
||||
provider_id: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return supported reasoning-effort levels for *model_id*, or [] if none."""
|
||||
model = str(model_id or "").strip()
|
||||
if not model:
|
||||
return []
|
||||
|
||||
provider = str(provider_id or "").strip().lower() if provider_id else ""
|
||||
resolved_base_url = str(base_url or "").strip() or None
|
||||
if not provider:
|
||||
try:
|
||||
_, provider, resolved_base_url = resolve_model_provider(model)
|
||||
except Exception:
|
||||
provider = str((cfg.get("model") or {}).get("provider") or "").strip().lower()
|
||||
|
||||
provider = _resolve_provider_alias(provider)
|
||||
if provider in {"cursor-acp", "copilot-acp"}:
|
||||
return []
|
||||
|
||||
try:
|
||||
from hermes_cli.models import (
|
||||
github_model_reasoning_efforts,
|
||||
lmstudio_model_reasoning_options,
|
||||
)
|
||||
except Exception:
|
||||
return _heuristic_reasoning_efforts(model, provider)
|
||||
|
||||
if provider in {"copilot", "github-copilot"}:
|
||||
return github_model_reasoning_efforts(model)
|
||||
|
||||
if provider == "openai-codex":
|
||||
bare = model.rsplit("/", 1)[-1]
|
||||
return github_model_reasoning_efforts(bare)
|
||||
|
||||
if provider == "lmstudio":
|
||||
probe_base = resolved_base_url or _get_provider_base_url(provider)
|
||||
opts = lmstudio_model_reasoning_options(model, probe_base)
|
||||
normalized = [str(opt).strip().lower() for opt in opts if str(opt).strip()]
|
||||
if not normalized or set(normalized).issubset({"off"}):
|
||||
return []
|
||||
level_opts = [opt for opt in normalized if opt in VALID_REASONING_EFFORTS]
|
||||
if level_opts:
|
||||
return list(dict.fromkeys(level_opts))
|
||||
if set(normalized).issubset({"off", "on"}):
|
||||
return []
|
||||
return []
|
||||
|
||||
model_lower = model.lower()
|
||||
prefixes = (
|
||||
"deepseek/",
|
||||
"anthropic/",
|
||||
"openai/",
|
||||
"x-ai/",
|
||||
"google/gemini-2",
|
||||
"google/gemma-4",
|
||||
"qwen/qwen3",
|
||||
"tencent/hy3-preview",
|
||||
"xiaomi/",
|
||||
)
|
||||
if any(model_lower.startswith(prefix) for prefix in prefixes):
|
||||
return list(VALID_REASONING_EFFORTS)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def get_reasoning_status(
|
||||
*,
|
||||
model_id: str | None = None,
|
||||
provider_id: str | None = None,
|
||||
base_url: str | None = None,
|
||||
) -> dict:
|
||||
"""Return current reasoning configuration from the active profile's
|
||||
config.yaml — the same source of truth the CLI reads from.
|
||||
|
||||
@@ -2086,10 +2191,17 @@ def get_reasoning_status() -> dict:
|
||||
agent_cfg = config_data.get("agent") or {}
|
||||
show_raw = display_cfg.get("show_reasoning") if isinstance(display_cfg, dict) else None
|
||||
effort_raw = agent_cfg.get("reasoning_effort") if isinstance(agent_cfg, dict) else None
|
||||
supported_efforts = resolve_model_reasoning_efforts(
|
||||
model_id,
|
||||
provider_id=provider_id,
|
||||
base_url=base_url,
|
||||
)
|
||||
return {
|
||||
# Match CLI default (True if unset in config.yaml)
|
||||
"show_reasoning": bool(show_raw) if isinstance(show_raw, bool) else True,
|
||||
"reasoning_effort": str(effort_raw or "").strip().lower(),
|
||||
"supported_efforts": supported_efforts,
|
||||
"supports_reasoning_effort": bool(supported_efforts),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+12
-1
@@ -3918,7 +3918,18 @@ def handle_get(handler, parsed) -> bool:
|
||||
# Current reasoning config (shared source of truth with the CLI —
|
||||
# reads display.show_reasoning and agent.reasoning_effort from
|
||||
# the active profile's config.yaml).
|
||||
return j(handler, get_reasoning_status())
|
||||
query = parse_qs(parsed.query)
|
||||
model_id = (query.get("model", [""])[0] or "").strip() or None
|
||||
provider_id = (query.get("provider", [""])[0] or "").strip() or None
|
||||
base_url = (query.get("base_url", [""])[0] or "").strip() or None
|
||||
return j(
|
||||
handler,
|
||||
get_reasoning_status(
|
||||
model_id=model_id,
|
||||
provider_id=provider_id,
|
||||
base_url=base_url,
|
||||
),
|
||||
)
|
||||
|
||||
if parsed.path == "/api/onboarding/status":
|
||||
return j(handler, get_onboarding_status())
|
||||
|
||||
+3
-2
@@ -1141,7 +1141,8 @@ function cmdReasoning(args){
|
||||
}
|
||||
if(!arg){
|
||||
// Status — read from the same config.yaml keys the CLI uses.
|
||||
api('/api/reasoning').then(function(st){showToast(_fmtStatus(st));})
|
||||
const q=(typeof _reasoningEffortQuery==='function')?_reasoningEffortQuery():'';
|
||||
api('/api/reasoning'+q).then(function(st){showToast(_fmtStatus(st));})
|
||||
.catch(function(){showToast(BRAIN+' /reasoning — status unavailable');});
|
||||
return true;
|
||||
}
|
||||
@@ -1168,7 +1169,7 @@ function cmdReasoning(args){
|
||||
.then(function(st){
|
||||
const eff=(st && st.reasoning_effort)||arg;
|
||||
showToast(BRAIN+' Reasoning effort: '+eff+' (saved; applies to next turn)');
|
||||
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff);
|
||||
if(typeof _applyReasoningChip==='function') _applyReasoningChip(eff, st||{});
|
||||
})
|
||||
.catch(function(e){
|
||||
showToast(BRAIN+' Failed to set effort: '+(e && e.message ? e.message : arg));
|
||||
|
||||
+49
-7
@@ -1572,6 +1572,7 @@ async function selectModelFromDropdown(value){
|
||||
}
|
||||
sel.value=value;
|
||||
syncModelChip();
|
||||
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
|
||||
closeModelDropdown();
|
||||
if(typeof sel.onchange==='function') await sel.onchange();
|
||||
}
|
||||
@@ -1629,6 +1630,7 @@ window.addEventListener('resize',()=>{
|
||||
|
||||
// ── Reasoning effort chip ────────────────────────────────────────────────────
|
||||
let _currentReasoningEffort=null;
|
||||
let _currentReasoningEffortsSupported=null;
|
||||
|
||||
function _normalizeReasoningEffort(eff){
|
||||
return String(eff||'').trim().toLowerCase();
|
||||
@@ -1640,17 +1642,58 @@ function _formatReasoningEffortLabel(effort){
|
||||
return effort;
|
||||
}
|
||||
|
||||
function _applyReasoningChip(eff){
|
||||
function _reasoningEffortQuery(){
|
||||
const sel=$('modelSelect');
|
||||
const model=(S&&S.session&&S.session.model)||(sel&&sel.value)||'';
|
||||
const provider=(S&&S.session&&S.session.model_provider)||'';
|
||||
const params=new URLSearchParams();
|
||||
if(model) params.set('model', model);
|
||||
if(provider) params.set('provider', provider);
|
||||
const qs=params.toString();
|
||||
return qs?('?'+qs):'';
|
||||
}
|
||||
|
||||
function _applyReasoningOptions(supportedEfforts){
|
||||
const dd=$('composerReasoningDropdown');
|
||||
if(!dd) return;
|
||||
const supported=new Set(Array.isArray(supportedEfforts)?supportedEfforts:[]);
|
||||
dd.querySelectorAll('.reasoning-option').forEach(function(opt){
|
||||
const effort=opt.dataset.effort;
|
||||
if(effort==='none'){
|
||||
opt.style.display='';
|
||||
return;
|
||||
}
|
||||
if(!supported.size){
|
||||
opt.style.display='none';
|
||||
return;
|
||||
}
|
||||
opt.style.display=supported.has(effort)?'':'none';
|
||||
});
|
||||
}
|
||||
|
||||
function _applyReasoningChip(eff, meta){
|
||||
const effort=_normalizeReasoningEffort(eff);
|
||||
_currentReasoningEffort=effort;
|
||||
if(meta&&Array.isArray(meta.supported_efforts)){
|
||||
_currentReasoningEffortsSupported=meta.supported_efforts;
|
||||
}
|
||||
const wrap=$('composerReasoningWrap');
|
||||
const label=$('composerReasoningLabel');
|
||||
const chip=$('composerReasoningChip');
|
||||
const mobileLabel=$('composerMobileReasoningLabel');
|
||||
const mobileAction=$('composerMobileReasoningAction');
|
||||
if(!wrap||!label) return;
|
||||
const supports=Array.isArray(_currentReasoningEffortsSupported)
|
||||
?_currentReasoningEffortsSupported.length>0
|
||||
:true;
|
||||
if(!supports){
|
||||
wrap.style.display='none';
|
||||
if(mobileAction) mobileAction.style.display='none';
|
||||
return;
|
||||
}
|
||||
wrap.style.display='';
|
||||
if(mobileAction) mobileAction.style.display='';
|
||||
_applyReasoningOptions(_currentReasoningEffortsSupported);
|
||||
const text=_formatReasoningEffortLabel(effort);
|
||||
label.textContent=text;
|
||||
if(mobileLabel) mobileLabel.textContent=text;
|
||||
@@ -1664,14 +1707,13 @@ function _applyReasoningChip(eff){
|
||||
}
|
||||
|
||||
function fetchReasoningChip(){
|
||||
api('/api/reasoning').then(function(st){
|
||||
_applyReasoningChip((st&&st.reasoning_effort)||'');
|
||||
}).catch(function(){_applyReasoningChip('');});
|
||||
api('/api/reasoning'+_reasoningEffortQuery()).then(function(st){
|
||||
_applyReasoningChip((st&&st.reasoning_effort)||'', st||{});
|
||||
}).catch(function(){_applyReasoningChip('', {supported_efforts:[]});});
|
||||
}
|
||||
|
||||
function syncReasoningChip(){
|
||||
if(_currentReasoningEffort===null){fetchReasoningChip();return;}
|
||||
_applyReasoningChip(_currentReasoningEffort);
|
||||
fetchReasoningChip();
|
||||
}
|
||||
|
||||
function _highlightReasoningOption(effort){
|
||||
@@ -1737,7 +1779,7 @@ document.addEventListener('click',function(e){
|
||||
if(effort){
|
||||
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:effort})})
|
||||
.then(function(st){
|
||||
_applyReasoningChip((st&&st.reasoning_effort)||effort);
|
||||
_applyReasoningChip((st&&st.reasoning_effort)||effort, st||{});
|
||||
showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort));
|
||||
})
|
||||
.catch(function(){showToast('🧠 Failed to set effort');});
|
||||
|
||||
@@ -47,12 +47,13 @@ def test_reasoning_chip_html_starts_hidden():
|
||||
assert m, "composerReasoningWrap must start with style='display:none'"
|
||||
|
||||
|
||||
def test_applyReasoningChip_shows_wrap():
|
||||
"""_applyReasoningChip must set wrap display to empty string (visible)."""
|
||||
def test_ui_js_passes_model_context_to_reasoning_api():
|
||||
with open("static/ui.js") as f:
|
||||
src = f.read()
|
||||
assert "wrap.style.display=''" in src or "wrap.style.display =''" in src, \
|
||||
"_applyReasoningChip must set wrap.style.display='' to make chip visible"
|
||||
assert "_reasoningEffortQuery" in src, (
|
||||
"ui.js must pass the active session model/provider to /api/reasoning"
|
||||
)
|
||||
assert "api('/api/reasoning'+_reasoningEffortQuery())" in src
|
||||
|
||||
|
||||
def test_fetchReasoningChip_calls_apply():
|
||||
|
||||
@@ -135,16 +135,13 @@ class TestReasoningChipNoneState:
|
||||
|
||||
def test_none_and_default_do_not_hide_reasoning_chip(self):
|
||||
fn = self.get_apply_reasoning_chip()
|
||||
assert "wrap.style.display='none'" in fn, (
|
||||
"_applyReasoningChip must hide the chip when the active model does "
|
||||
"not support reasoning effort controls"
|
||||
)
|
||||
assert "wrap.style.display='';" in fn, (
|
||||
"_applyReasoningChip must show the reasoning chip even for empty/"
|
||||
"default or 'none' effort values"
|
||||
)
|
||||
assert "if(!eff" not in fn and "wrap.style.display='none'" not in fn, (
|
||||
"_applyReasoningChip must not use a truthy guard that hides the "
|
||||
"chip for the valid 'none' state"
|
||||
)
|
||||
assert "wrap.style.display='none'" not in fn, (
|
||||
"the None/default reasoning state should be visible, not hidden"
|
||||
"_applyReasoningChip must show the reasoning chip when the model "
|
||||
"supports reasoning effort controls"
|
||||
)
|
||||
|
||||
def test_none_and_default_have_visible_labels(self):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for model-aware reasoning effort chip visibility."""
|
||||
|
||||
from api import config as cfg
|
||||
|
||||
|
||||
def test_cursor_acp_models_do_not_support_reasoning_effort_levels():
|
||||
assert cfg.resolve_model_reasoning_efforts(
|
||||
"cursor/composer-2.5",
|
||||
provider_id="cursor-acp",
|
||||
) == []
|
||||
|
||||
|
||||
def test_openai_codex_gpt5_supports_reasoning_effort_levels():
|
||||
efforts = cfg.resolve_model_reasoning_efforts(
|
||||
"gpt-5.5",
|
||||
provider_id="openai-codex",
|
||||
)
|
||||
assert "medium" in efforts
|
||||
assert "high" in efforts
|
||||
|
||||
|
||||
def test_get_reasoning_status_includes_supported_efforts(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
cfg,
|
||||
"resolve_model_reasoning_efforts",
|
||||
lambda *a, **k: ["low", "medium", "high"],
|
||||
)
|
||||
status = cfg.get_reasoning_status(
|
||||
model_id="gpt-5.5",
|
||||
provider_id="openai-codex",
|
||||
)
|
||||
assert status["supported_efforts"] == ["low", "medium", "high"]
|
||||
assert status["supports_reasoning_effort"] is True
|
||||
Reference in New Issue
Block a user