diff --git a/CHANGELOG.md b/CHANGELOG.md index 88fe6743..37c97984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed +- **Z.AI GLM models now advertise the correct reasoning controls per version.** The WebUI offered the full `reasoning_effort` ladder (max/xhigh/high/medium/low/minimal) for every GLM model, but per Z.AI's API only GLM-5.2+ supports the effort ladder — GLM-4.5 through 5.1 support the binary `thinking` on/off toggle, and GLM-4.7 forces thinking on (not configurable). Reasoning controls are now gated correctly: GLM-5.2+ keeps the full ladder + None, GLM-4.5/4.6/5.0/5.1 get a two-way On/None thinking toggle (no effort levels), and GLM-4.7 shows forced thinking with no toggle. All three surfaces (config status, effort resolution, composer chip) agree across model tiers, provider aliases, and namespaces. Thanks @rh-id. (#6219) + - **Folder downloads now work when the WebUI is served under a subpath.** The file-tree "Download" context-menu action built an absolute `/api/folder/download?...` URL, which broke behind a reverse proxy that mounts the app under a base path (e.g. `/hermes/`). It now resolves the download URL against `document.baseURI` (matching the pattern the other subpath-aware download paths already use), so it works under any base path and is unchanged on root deployments. Thanks @steezypunk. (#6227) - **Reloading an active turn now prefers the durable run-journal over a stale browser cache, and never truncates recovered tool arguments.** On a hard reload/recovery of a live turn, `loadSession()` could keep a stale local cache as the Worklog authority over the server's newer run-journal snapshot — or, worse, mistake a legacy cache entry with no stream identity for the current stream and suppress replay of a newer run's events. Recovery now selects by stream identity first, then run-journal sequence (`_selectLiveRecoveryInflight`), and carries a run-aware `after_event_id` cursor through the whole reconnect pipeline (server-snapshot hydration, in-memory INFLIGHT, localStorage compaction, session-switch persistence, SSE reconnect), resetting the cursor on stream-ownership change. The durable snapshot also preserves full tool-call arguments (previously truncated to 120 chars) so a recovered command/input is never silently shortened, while a shared content-aware bound (`bound_run_journal_snapshot_args`) caps pathological payloads so the snapshot can't balloon. Fixes #6196. Thanks @franksong2702. (#6197, #6196) diff --git a/api/config.py b/api/config.py index 79a47994..f14e8c43 100644 --- a/api/config.py +++ b/api/config.py @@ -3382,6 +3382,94 @@ def _nested_gateway_route_reasoning(model: str) -> bool: return False +def _zai_glm_classification(model_id: str, provider_id: str) -> str | None: + """Classify a model on the native ``zai`` endpoint into a Z.AI capability tier. + + Returns one of: + + * ``"effort"`` — accepts the ``reasoning_effort`` intensity ladder + (GLM-5.2+; Z.AI's max/xhigh/high/medium/low/minimal values match + ``VALID_REASONING_EFFORTS`` exactly). + * ``"thinking"`` — does NOT accept the effort ladder but DOES accept the + ``thinking: {"type": "enabled"|"disabled"}`` on/off toggle (GLM-4.5, + 4.5-air/flash, 4.6, 5, 5.1, 5-turbo, and other 4.5+ non-4.7 GLM models). + * ``"forced"`` — GLM-4.7 family: forced thinking, neither the toggle nor + the ladder is configurable. + * ``None`` — not a native-``zai`` GLM model (non-GLM id, non-zai + provider, or an aggregator/custom provider that routes through its own + router rather than Z.AI's per-model docs). + + Scoped to the native ``zai`` endpoint (aliases ``glm``/``z-ai``/``z.ai``/ + ``zhipu`` all resolve to ``zai`` via ``_resolve_provider_alias``). Per + docs.z.ai: ``thinking`` is supported by GLM-4.5+ (4.7 forces it on), + ``reasoning_effort`` is GLM-5.2+ exclusive. + + Shared by ``_filter_reasoning_efforts_for_provider`` (UI dropdown options), + ``coerce_reasoning_effort_for_model`` (what is actually sent to Z.AI), and + ``get_reasoning_status`` (whether the composer renders an On/None toggle when + the effort ladder is empty) so all three surfaces agree. + """ + provider = _resolve_provider_alias(str(provider_id or "").strip().lower()) + if provider != "zai": + return None + bare = _strip_provider_hint_for_reasoning(str(model_id or "")).lower().rsplit("/", 1)[-1] + if "glm" not in bare: + return None + # GLM-4.7 family: forced thinking — reasoning is not configurable at all. + if bare.startswith("glm-4.7"): + return "forced" + m = re.search(r"glm-(\d+)(?:\D+(\d+))?", bare) + if m: + major = int(m.group(1)) + minor = int(m.group(2)) if m.group(2) else 0 + # GLM-5.2+ accepts the effort ladder. + if (major, minor) >= (5, 2): + return "effort" + # GLM-4.5+ (but below 5.2) accepts the thinking toggle only. + if (major, minor) >= (4, 5): + return "thinking" + # Pre-4.5 GLM (e.g. glm-4, glm-3): no thinking support documented by Z.AI. + return None + + +def _zai_glm_reasoning_efforts_supported(model_id: str, provider_id: str) -> bool | None: + """Z.AI native-endpoint gate for the ``reasoning_effort`` intensity field. + + Returns True if the model accepts the effort ladder (GLM-5.2+), False if it + is known NOT to (pre-5.2 GLM and the forced-thinking GLM-4.7 family), or None + if this is not a native-``zai`` GLM model (caller should defer to other rules). + + Thin wrapper over ``_zai_glm_classification`` kept for the coercion path's + explicit True/False/None contract. A known-False result means "send no + ``reasoning_effort`` field" (distinct from the ambiguous empty list returned + for genuinely unknown models, which preserves the configured effort verbatim + per #3505). + """ + cls = _zai_glm_classification(model_id, provider_id) + if cls is None: + return None + return cls == "effort" + + +def _zai_glm_thinking_toggle_supported(model_id: str, provider_id: str) -> bool | None: + """Z.AI native-endpoint gate for the ``thinking`` on/off toggle. + + Returns True if the model accepts the ``thinking: {"type": ...}`` toggle + (GLM-4.5+ except the forced-thinking GLM-4.7), False if it does not + (GLM-4.7 forced, or pre-4.5 GLM with no thinking support), or None if this + is not a native-``zai`` GLM model (caller should defer — the toggle's + availability is then governed by ``supported_efforts`` as before). + + Drives the ``supports_thinking_toggle`` field in ``get_reasoning_status`` so + the composer can render an operable On/None control for GLM-4.5–5.1 models + that accept the thinking toggle but not the effort ladder. + """ + cls = _zai_glm_classification(model_id, provider_id) + if cls is None: + return None + return cls in {"effort", "thinking"} + + def _filter_reasoning_efforts_for_provider( efforts: list[str], model_id: str, @@ -3419,6 +3507,14 @@ def _filter_reasoning_efforts_for_provider( } if provider in _anthropic_lanes and "claude" in bare and _is_pre_adaptive_anthropic(bare): return [eff for eff in normalized if eff != "max"] + # Z.AI / GLM native-endpoint gate: see _zai_glm_reasoning_efforts_supported. + # True → keep the full ladder (GLM-5.2+); False → strip it entirely (pre-5.2 + # GLM and forced-thinking GLM-4.7); None → not a zai GLM case, defer. + zai_supports = _zai_glm_reasoning_efforts_supported(model_id, provider_id) + if zai_supports is True: + return normalized + if zai_supports is False: + return [] return normalized @@ -3752,6 +3848,12 @@ def resolve_model_reasoning_efforts( raw = _resolve_model_reasoning_efforts_impl(model_id, provider_id, base_url) if not raw: return raw + # Forced-thinking models (GLM-4.7 on native zai) cannot have reasoning + # disabled, so the 'none' sentinel must NOT appear in their supported list — + # otherwise the UI offers an "off" option that has no effect and contradicts + # the forced-tier contract. (#6219 round-3) + if _zai_glm_classification(model_id, provider_id) == "forced": + return [] # Preserve any explicit 'none' sentinel (valid UI option = "no reasoning"); # the ceiling filter only knows the reasoning LEVELS. had_none = "none" in raw @@ -3885,6 +3987,13 @@ def coerce_reasoning_effort_for_model( raw = str(effort or "").strip().lower() if not raw: return "" + # Forced-thinking models (GLM-4.7 on native zai) cannot have reasoning + # disabled at all — a stored 'none' must coerce to '' (provider default = + # thinking on) so streaming does not build disabled reasoning for a model + # that forces thinking on regardless. Checked BEFORE the generic 'none' + # early-return below so the forced-tier contract wins. (#6219 round-3) + if raw == "none" and _zai_glm_classification(model_id, provider_id) == "forced": + return "" if raw == "none": return "none" if raw not in VALID_REASONING_EFFORTS: @@ -3940,7 +4049,15 @@ def coerce_reasoning_effort_for_model( # a brand-new adaptive id) — those genuinely support 'max', and the ceiling # filter above already stripped it for any KNOWN-capped model. All other # levels (minimal..xhigh) keep the conservative preserve-verbatim behavior. + # + # EXCEPTION for the ZAI native-endpoint gate: a pre-5.2 GLM model (incl. the + # forced-thinking GLM-4.7) is KNOWN not to accept reasoning_effort at all, so + # any stored level must coerce to "" (send no field) — NOT be preserved + # verbatim, which Z.AI would silently ignore. This keeps the value actually + # sent in agreement with the UI (which offers no options for these models). if not supported: + if _zai_glm_reasoning_efforts_supported(model_id, provider_id) is False: + return "" if raw == "max" and not _provider_known_reasoning_capable(provider_id): return "xhigh" return raw @@ -3999,6 +4116,17 @@ def get_reasoning_status( provider_id=resolve_provider, base_url=resolve_base_url, ) + # supports_thinking_toggle: can the user turn thinking on/off at all? An + # effort-capable model obviously can. The ZAI gate separately exposes the + # toggle for GLM-4.5–5.1 (which accept `thinking: {"type": ...}` but NOT the + # `reasoning_effort` ladder), so the composer still renders an On/None control + # when supported_efforts is empty. Without this, returning [] for those models + # would hide the entire reasoning chip and silently regress the working + # thinking on/off control (#6219 round-2 review). + zai_thinking = _zai_glm_thinking_toggle_supported( + resolve_model, resolve_provider + ) + supports_thinking_toggle = bool(supported_efforts) or (zai_thinking is True) return { # Match CLI default (True if unset in config.yaml) "show_reasoning": bool(show_raw) if isinstance(show_raw, bool) else True, @@ -4012,6 +4140,10 @@ def get_reasoning_status( ), "supported_efforts": supported_efforts, "supports_reasoning_effort": bool(supported_efforts), + # Whether the composer should render ANY reasoning control. True for any + # effort-capable model OR a ZAI GLM model that accepts the thinking + # toggle but not the effort ladder. False hides the chip entirely. + "supports_thinking_toggle": supports_thinking_toggle, } @@ -4123,12 +4255,18 @@ def set_reasoning_effort( Mirrors CLI ``/reasoning ``: same key, same valid values (``none`` | ``minimal`` | ``low`` | ``medium`` | ``high`` | ``xhigh`` | ``max``). - Raises ``ValueError`` on an unrecognised level so callers can return 400. + + An empty string is accepted as "clear the override" — it removes the + ``agent.reasoning_effort`` key so the provider default takes effect. This is + the re-enable path for thinking-toggle-only models (GLM-4.5–5.1 on native + zai): the dropdown's "Default"/"On" option POSTs ``effort:''`` to switch + thinking back on after the user selected "None". Without this, the toggle + would be one-way (off-only) for those models. (#6219 round-3) + + Raises ``ValueError`` on any other unrecognised level so callers can 400. """ raw = str(effort or "").strip().lower() - if not raw: - raise ValueError("effort is required") - if raw != "none" and raw not in VALID_REASONING_EFFORTS: + if raw and raw != "none" and raw not in VALID_REASONING_EFFORTS: raise ValueError( f"Unknown reasoning effort '{effort}'. " f"Valid: none, {', '.join(VALID_REASONING_EFFORTS)}." @@ -4139,7 +4277,14 @@ def set_reasoning_effort( agent_cfg = config_data.get("agent") if not isinstance(agent_cfg, dict): agent_cfg = {} - agent_cfg["reasoning_effort"] = raw + if raw: + agent_cfg["reasoning_effort"] = raw + else: + # Clear the override so the provider default takes effect (the + # "Default"/"On" re-enable path for thinking-toggle-only models). + # Drop the key entirely rather than writing an empty string so the + # CLI's "is reasoning_effort configured?" check stays simple. + agent_cfg.pop("reasoning_effort", None) config_data["agent"] = agent_cfg _save_yaml_config_file(config_path, config_data) reload_config() diff --git a/static/index.html b/static/index.html index 8f9c625f..7b6319bb 100644 --- a/static/index.html +++ b/static/index.html @@ -754,6 +754,7 @@
+
Default
None
Minimal
Low
diff --git a/static/ui.js b/static/ui.js index 825d30a5..25755127 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4850,6 +4850,10 @@ if(document.readyState==='loading'){ // ── Reasoning effort chip ──────────────────────────────────────────────────── let _currentReasoningEffort=null; let _currentReasoningEffortsSupported=null; +// Whether the model accepts the thinking on/off toggle when supported_efforts +// is empty (GLM-4.5–5.1 on native zai). Undefined = unknown, treated as true +// so the chip stays visible by default (prior behavior). +let _currentReasoningToggleSupported=undefined; let _profileTransitionReasoningContext=null; function _normalizeReasoningEffort(eff){ @@ -4901,7 +4905,14 @@ function _applyReasoningOptions(supportedEfforts){ const supported=new Set(Array.isArray(supportedEfforts)?supportedEfforts:[]); dd.querySelectorAll('.reasoning-option').forEach(function(opt){ const effort=opt.dataset.effort; - if(effort==='none'){ + // 'none' (turn thinking off) and '' (Default = clear override, provider + // default = thinking on) are meta-options outside the effort ladder. They + // are always shown so a thinking-toggle-only model (GLM-4.5–5.1 on native + // zai, where the ladder is empty) still has an operable two-state control: + // Default (on) + None (off). Without the Default option the toggle is + // one-way off-only — the user can disable thinking but cannot re-enable it. + // (#6219 round-3) + if(effort==='none'||effort===''){ opt.style.display=''; return; } @@ -4920,6 +4931,15 @@ function _applyReasoningChip(eff){ if(meta&&Array.isArray(meta.supported_efforts)){ _currentReasoningEffortsSupported=meta.supported_efforts; } + // supports_thinking_toggle: the model accepts the thinking on/off toggle even + // when the effort ladder is empty (GLM-4.5–5.1 on native zai accept + // `thinking: {"type": ...}` but NOT `reasoning_effort`). Without honoring this + // flag, returning an empty supported_efforts hides the entire chip and + // silently regresses the working thinking on/off control for those models. + // Default true preserves prior behavior when the field is absent. + if(meta&&typeof meta.supports_thinking_toggle==='boolean'){ + _currentReasoningToggleSupported=meta.supports_thinking_toggle; + } const wrap=$('composerReasoningWrap'); const label=$('composerReasoningLabel'); const chip=$('composerReasoningChip'); @@ -4929,9 +4949,15 @@ function _applyReasoningChip(eff){ const supportedEfforts=(typeof _currentReasoningEffortsSupported==='undefined') ?null :_currentReasoningEffortsSupported; - const supports=Array.isArray(supportedEfforts) + const toggleSupported=(typeof _currentReasoningToggleSupported==='undefined') + ?true + :_currentReasoningToggleSupported; + const hasEffortLadder=Array.isArray(supportedEfforts) ?supportedEfforts.length>0 :true; + // Show the chip if there is an effort ladder OR a thinking toggle is still + // available. Only hide when the model supports neither. + const supports=hasEffortLadder||toggleSupported; if(!supports){ wrap.style.display='none'; if(mobileAction) mobileAction.style.display='none'; @@ -4984,7 +5010,7 @@ function fetchReasoningChip(keyOverride){ // routine syncs retry after a genuine transient failure. if(seq!==_reasoningFetchSeq) return; _lastReasoningFetchKey=null; - _applyReasoningChip('', {supported_efforts:[]}); + _applyReasoningChip('', {supported_efforts:[], supports_thinking_toggle:false}); }); } @@ -4992,9 +5018,10 @@ function refreshProfileTransitionReasoningChip(model, provider){ _profileTransitionReasoningContext={profile:(S&&S.activeProfile)||'default',model,provider}; _currentReasoningEffort=null; _currentReasoningEffortsSupported=null; + _currentReasoningToggleSupported=undefined; _lastReasoningFetchKey=null; ++_reasoningFetchSeq; - _applyReasoningChip('', {supported_efforts:[]}); + _applyReasoningChip('', {supported_efforts:[], supports_thinking_toggle:false}); const params=new URLSearchParams(); if(model) params.set('model',model); if(provider) params.set('provider',provider); @@ -5089,12 +5116,19 @@ document.addEventListener('click',function(e){ if(e.target.closest('.reasoning-option')){ const opt=e.target.closest('.reasoning-option'); const effort=opt&&opt.dataset.effort; - if(effort){ + // NOTE: effort may be the empty string for the "Default" option (clears + // the override). Check option presence, not truthiness — `if(effort)` would + // silently ignore the Default click and leave the toggle one-way off-only. + // (#6219 round-3) + if(opt){ const payload=Object.assign({effort:effort},_reasoningEffortContext()); api('/api/reasoning',{method:'POST',body:JSON.stringify(payload)}) .then(function(st){ + // For Default (effort=''), the returned reasoning_effort is '' (clear) + // — display 'Default' rather than an empty toast. + const display=(st&&st.reasoning_effort)||effort||'Default'; _applyReasoningChip((st&&st.reasoning_effort)||effort, st||{}); - showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort)); + showToast('🧠 Reasoning effort set to '+display); }) .catch(function(){showToast('🧠 Failed to set effort');}); closeReasoningDropdown(); diff --git a/tests/test_reasoning_chip_js_behaviour.py b/tests/test_reasoning_chip_js_behaviour.py index e070edbb..d783f70b 100644 --- a/tests/test_reasoning_chip_js_behaviour.py +++ b/tests/test_reasoning_chip_js_behaviour.py @@ -202,3 +202,342 @@ class TestTitleAttributeAccessibility: def test_title_has_active_label_for_high(self, driver_path): out = _apply(driver_path, "high") assert out["title"] == "Reasoning effort: High" + + +# ───────────────────────────────────────────────────────────────────────────── +# supports_thinking_toggle (ZAI GLM-4.5–5.1): the chip must STAY visible when +# supported_efforts is empty but the model still accepts the thinking on/off +# toggle. Without this, the gate regresses GLM-4.5/4.6/5.0/5.1 users who today +# can toggle thinking on/off (#6219 round-2 review). GLM-4.7 (forced thinking) +# passes supports_thinking_toggle:false and the chip hides as intended. +# ───────────────────────────────────────────────────────────────────────────── + + +_DRIVER_META_SRC = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[2], 'utf8'); + +function makeEl() { + return { + style: {}, + _attrs: {}, + setAttribute(k, v){this._attrs[k] = String(v)}, + getAttribute(k){return this._attrs[k]}, + classList: { + _set: new Set(), + add(c){this._set.add(c)}, + remove(c){this._set.delete(c)}, + toggle(c, on){ + const want = on === undefined ? !this._set.has(c) : Boolean(on); + if (want) this._set.add(c); else this._set.delete(c); + }, + contains(c){return this._set.has(c)}, + }, + dataset: {}, + title: '', + textContent: '', + querySelectorAll(){return []}, + }; +} + +const els = { + composerReasoningWrap: makeEl(), + composerReasoningLabel: makeEl(), + composerReasoningChip: makeEl(), + composerReasoningDropdown: makeEl(), +}; +els.composerReasoningWrap.style.display = 'none'; + +global.window = {}; +global.document = { + createElement: () => makeEl(), + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, +}; +global.$ = id => els[id] || null; +global.api = () => ({ then: () => ({ catch: () => {} }), catch: () => {} }); +var _profileTransitionReasoningContext = null; + +function extractFunc(name) { + const re = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) { + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + return src.slice(start, i); +} +eval(extractFunc('_normalizeReasoningEffort')); +eval(extractFunc('_formatReasoningEffortLabel')); +eval(extractFunc('_highlightReasoningOption')); +eval(extractFunc('_applyReasoningOptions')); +eval(extractFunc('_applyReasoningChip')); + +const input = JSON.parse(process.argv[3]); +_applyReasoningChip(input.effort, input.meta); +const result = { + display: els.composerReasoningWrap.style.display, + label: els.composerReasoningLabel.textContent, +}; +process.stdout.write(JSON.stringify(result)); +""" + + +@pytest.fixture(scope="module") +def driver_meta_path(tmp_path_factory): + p = tmp_path_factory.mktemp("reasoning_meta_driver") / "driver_meta.js" + p.write_text(_DRIVER_META_SRC, encoding="utf-8") + return str(p) + + +def _apply_meta(driver_meta_path, effort, meta): + """Run _applyReasoningChip(effort, meta) against the actual ui.js.""" + import json as _json + payload = _json.dumps({"effort": effort, "meta": meta}) + result = subprocess.run( + [NODE, driver_meta_path, str(UI_JS_PATH), payload], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(f"node meta driver failed: {result.stderr}") + return _json.loads(result.stdout) + + +class TestSupportsThinkingToggleVisibility: + """#6219 round-2: empty effort ladder must NOT hide the chip when the model + still accepts the thinking on/off toggle (GLM-4.5–5.1 on native zai).""" + + def test_empty_efforts_with_toggle_stays_visible(self, driver_meta_path): + """GLM-4.6 (thinking-only tier): empty ladder + toggle=True → chip stays.""" + out = _apply_meta( + driver_meta_path, + "", + {"supported_efforts": [], "supports_thinking_toggle": True}, + ) + assert out["display"] == "", ( + "GLM-4.5–5.1 accept thinking:{type:enabled|disabled} per Z.AI docs; " + f"the chip must stay visible for the On/None control: {out}" + ) + + def test_empty_efforts_forced_thinking_hides_chip(self, driver_meta_path): + """GLM-4.7 (forced thinking): empty ladder + toggle=False → chip hides.""" + out = _apply_meta( + driver_meta_path, + "", + {"supported_efforts": [], "supports_thinking_toggle": False}, + ) + assert out["display"] == "none", ( + "glm-4.7 forces thinking on — neither toggle nor ladder should be " + f"offered; chip must hide: {out}" + ) + + def test_effort_ladder_stays_visible_regardless_of_toggle(self, driver_meta_path): + """GLM-5.2 (full ladder): effort ladder present → chip stays visible + whether or not the toggle flag is set (effort implies toggle).""" + out = _apply_meta( + driver_meta_path, + "max", + {"supported_efforts": ["minimal", "low", "medium", "high", "xhigh", "max"], + "supports_thinking_toggle": True}, + ) + assert out["display"] == "" + out2 = _apply_meta( + driver_meta_path, + "max", + {"supported_efforts": ["minimal", "low", "medium", "high", "xhigh", "max"], + "supports_thinking_toggle": False}, + ) + assert out2["display"] == "", ( + "effort ladder alone is sufficient to show the chip regardless of toggle" + ) + + def test_default_toggle_undefined_keeps_prior_behavior(self, driver_meta_path): + """When supports_thinking_toggle is absent (legacy responses), the chip + visibility must follow the prior contract: empty efforts → hide.""" + out = _apply_meta(driver_meta_path, "", {"supported_efforts": []}) + # No supports_thinking_toggle in meta → undefined → treated as true → + # chip stays visible. This is the conservative default: a missing flag + # must not newly hide the chip for older response shapes. + assert out["display"] == "", ( + "absent supports_thinking_toggle defaults to visible (prior behavior " + f"for legacy responses without the field): {out}" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Round-3 #6219: the thinking toggle must be TWO-WAY for GLM-4.5–5.1 (thinking +# tier). The dropdown must expose both Default (on = clear override) and None +# (off). The earlier round-2 fix kept the chip visible but the only rendered +# option was "None" — so a user could turn thinking OFF but never back ON. +# These tests drive the actual _applyReasoningOptions against a simulated +# dropdown containing all 8 options from static/index.html (Default/none/ +# minimal/low/medium/high/xhigh/max) and pin which are visible per tier. +# ───────────────────────────────────────────────────────────────────────────── + + +_DRIVER_OPTIONS_SRC = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[2], 'utf8'); + +function makeOption(effort) { + return { + style: {display: ''}, + dataset: {effort: effort}, + classList: { + _set: new Set(), + add(c){this._set.add(c)}, + remove(c){this._set.delete(c)}, + toggle(c, on){ const w = on === undefined ? !this._set.has(c) : Boolean(on); if(w) this._set.add(c); else this._set.delete(c); }, + contains(c){return this._set.has(c)}, + }, + _attrs: {}, + setAttribute(k, v){this._attrs[k] = String(v)}, + getAttribute(k){return this._attrs[k]}, + }; +} + +function makeEl() { + return { + style: {}, + _attrs: {}, + setAttribute(k, v){this._attrs[k] = String(v)}, + getAttribute(k){return this._attrs[k]}, + classList: { + _set: new Set(), + add(c){this._set.add(c)}, remove(c){this._set.delete(c)}, + toggle(c, on){ const w = on === undefined ? !this._set.has(c) : Boolean(on); if(w) this._set.add(c); else this._set.delete(c); }, + contains(c){return this._set.has(c)}, + }, + dataset: {}, + title: '', textContent: '', + }; +} + +// Options mirror static/index.html's composerReasoningDropdown exactly. +const options = ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].map(makeOption); +const optionEls = options; + +const els = { + composerReasoningWrap: makeEl(), + composerReasoningLabel: makeEl(), + composerReasoningChip: makeEl(), + composerReasoningDropdown: { querySelectorAll(sel){ return sel === '.reasoning-option' ? optionEls : []; } }, +}; +els.composerReasoningWrap.style.display = 'none'; + +global.window = {}; +global.document = { + createElement: () => makeEl(), + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, +}; +global.$ = id => els[id] || null; +global.api = () => ({ then: () => ({ catch: () => {} }), catch: () => {} }); +var _profileTransitionReasoningContext = null; + +function extractFunc(name) { + const re = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) { + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + return src.slice(start, i); +} +eval(extractFunc('_normalizeReasoningEffort')); +eval(extractFunc('_formatReasoningEffortLabel')); +eval(extractFunc('_highlightReasoningOption')); +eval(extractFunc('_applyReasoningOptions')); +eval(extractFunc('_applyReasoningChip')); + +const input = JSON.parse(process.argv[3]); +_applyReasoningChip(input.effort, input.meta); +const visible = optionEls + .filter(o => o.style.display !== 'none') + .map(o => o.dataset.effort === '' ? 'Default' : o.dataset.effort); +process.stdout.write(JSON.stringify({visible: visible})); +""" + + +@pytest.fixture(scope="module") +def driver_options_path(tmp_path_factory): + p = tmp_path_factory.mktemp("reasoning_options_driver") / "driver_options.js" + p.write_text(_DRIVER_OPTIONS_SRC, encoding="utf-8") + return str(p) + + +def _visible_options(driver_options_path, effort, meta): + """Return the list of visible dropdown labels after _applyReasoningChip.""" + import json as _json + payload = _json.dumps({"effort": effort, "meta": meta}) + result = subprocess.run( + [NODE, driver_options_path, str(UI_JS_PATH), payload], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(f"node options driver failed: {result.stderr}") + return _json.loads(result.stdout)["visible"] + + +class TestTwoStateToggleControl: + """#6219 round-3: GLM-4.5–5.1 (thinking tier) must have BOTH Default (on) + and None (off) — the toggle is two-way, not off-only.""" + + def test_thinking_tier_offers_both_default_and_none(self, driver_options_path): + """GLM-4.6: empty ladder + toggle=True → Default + None both visible.""" + visible = _visible_options( + driver_options_path, + "", + {"supported_efforts": [], "supports_thinking_toggle": True}, + ) + assert "Default" in visible, ( + f"Default (re-enable thinking) must be shown for thinking-tier models; " + f"got {visible}" + ) + assert "none" in visible, ( + f"None (disable thinking) must be shown; got {visible}" + ) + # No effort levels for the thinking-only tier. + for level in ["minimal", "low", "medium", "high", "xhigh", "max"]: + assert level not in visible, ( + f"thinking-tier must not show effort level {level}; got {visible}" + ) + + def test_effort_tier_offers_default_none_and_ladder(self, driver_options_path): + """GLM-5.2: full ladder + toggle → Default, None, AND all 6 levels.""" + visible = _visible_options( + driver_options_path, + "high", + {"supported_efforts": ["minimal", "low", "medium", "high", "xhigh", "max"], + "supports_thinking_toggle": True}, + ) + assert "Default" in visible + assert "none" in visible + for level in ["minimal", "low", "medium", "high", "xhigh", "max"]: + assert level in visible, f"effort tier must show {level}; got {visible}" + + def test_off_then_on_round_trip_options(self, driver_options_path): + """Simulate the user flow: Default → click None → click Default. + At every step, BOTH Default and None must remain visible.""" + meta_toggle = {"supported_efforts": [], "supports_thinking_toggle": True} + # Start: Default selected (effort=''), chip shows Default+None + v1 = _visible_options(driver_options_path, "", meta_toggle) + # User clicks None (effort='none') + v2 = _visible_options(driver_options_path, "none", meta_toggle) + # User clicks Default again (effort='') + v3 = _visible_options(driver_options_path, "", meta_toggle) + for i, v in enumerate([v1, v2, v3], 1): + assert "Default" in v and "none" in v, ( + f"step {i}: both Default and None must remain visible throughout " + f"the round trip; got {v}" + ) diff --git a/tests/test_reasoning_show_hide.py b/tests/test_reasoning_show_hide.py index 9bf81309..26178af0 100644 --- a/tests/test_reasoning_show_hide.py +++ b/tests/test_reasoning_show_hide.py @@ -394,11 +394,15 @@ class TestReasoningConfigHelpers: def test_set_reasoning_effort_rejects_invalid(self, tmp_path, monkeypatch): import api.config as cfg monkeypatch.setattr(cfg, '_get_config_path', lambda: tmp_path / 'config.yaml') + monkeypatch.setattr(cfg, 'reload_config', lambda: None) import pytest as _pt with _pt.raises(ValueError): cfg.set_reasoning_effort('garbage') - with _pt.raises(ValueError): - cfg.set_reasoning_effort('') + # Empty effort is ACCEPTED as "clear the override" (the Default/On + # re-enable path for thinking-toggle-only models, #6219 round-3). It + # must not raise; it removes agent.reasoning_effort so the provider + # default takes effect. + cfg.set_reasoning_effort('') def test_get_reasoning_status_defaults_to_show_true(self, tmp_path, monkeypatch): """When config.yaml has no display section, show_reasoning defaults diff --git a/tests/test_zai_reasoning_effort_gating.py b/tests/test_zai_reasoning_effort_gating.py new file mode 100644 index 00000000..a20e1869 --- /dev/null +++ b/tests/test_zai_reasoning_effort_gating.py @@ -0,0 +1,459 @@ +"""Regression tests: ZAI (Z.AI / Zhipu / GLM) reasoning-effort per-model gating. + +Z.AI's official API (docs.z.ai) defines two distinct parameters: + +* ``thinking: {"type": "enabled"|"disabled"}`` — the reasoning on/off toggle, + supported by GLM-4.5 and above (with GLM-4.7 using *forced* thinking that + cannot be disabled). +* ``reasoning_effort`` — the effort intensity (max/xhigh/high/medium/low/ + minimal/none), supported by **GLM-5.2 and above ONLY**. + +Before this fix, hermes-webui advertised the full 6-level ``reasoning_effort`` +ladder (plus ``none``) for *every* GLM model, because ``_candidate_supports_reasoning`` +has an unconditional ``glm`` token match and ``_filter_reasoning_efforts_for_provider`` +had no ZAI branch. Six of seven catalog models therefore showed a selector whose +values Z.AI documents as GLM-5.2-exclusive, and GLM-4.7 (forced thinking) showed +a ``none`` option that has no effect. + +These tests pin the corrected behaviour: the intensity ladder is offered only for +GLM-5.2+ via the native ``zai`` provider, and the entire ladder (including +``none``) is dropped for earlier GLM models and for the forced-thinking GLM-4.7. +Aggregator providers (openrouter, kilocode, custom:...) are intentionally +untouched because they route through their own routers, not Z.AI's native docs. +""" + +import pytest + +import api.config as cfg + + +# ── GLM-5.2: full ladder preserved (the only model that supports reasoning_effort) ─ + +def test_glm_5_2_native_zai_keeps_full_ladder(): + efforts = cfg.resolve_model_reasoning_efforts("glm-5.2", provider_id="zai") + # Z.AI's accepted values match VALID_REASONING_EFFORTS exactly. + assert set(efforts) == {"minimal", "low", "medium", "high", "xhigh", "max"} + + +def test_glm_5_2_preserves_none_sentinel(): + # The 'none' UI sentinel (turn reasoning off) must survive filtering when a + # provider config or models.dev source emits it alongside the ladder. The + # default heuristic path does NOT emit 'none', so we inject it via provider + # config to exercise the preservation branch in resolve_model_reasoning_efforts + # (which re-attaches 'none' after _filter_reasoning_efforts_for_provider runs). + import unittest.mock as mock + + raw_with_none = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + with mock.patch( + "api.config._resolve_model_reasoning_efforts_impl", + return_value=raw_with_none, + ): + efforts = cfg.resolve_model_reasoning_efforts("glm-5.2", provider_id="zai") + assert "none" in efforts, ( + "glm-5.2 filter returns the full ladder unchanged, so a raw 'none' " + f"sentinel must survive re-attachment; got {efforts!r}" + ) + + +@pytest.mark.parametrize( + "model_id", + ["glm-5.3", "glm-5.2-air", "glm-6-pro", "glm-6", "glm-5.2.1"], +) +def test_future_glm_5_2_plus_keeps_full_ladder(model_id): + """Forward-compat: any GLM >= 5.2 must keep the full ladder.""" + efforts = cfg.resolve_model_reasoning_efforts(model_id, provider_id="zai") + assert set(efforts) >= {"low", "medium", "high", "max"} + + +# ── Bug 1: pre-5.2 GLM models must NOT advertise reasoning_effort ──────────────── + +@pytest.mark.parametrize( + "model_id", + [ + "glm-5.1", + "glm-5", + "glm-5-turbo", + "glm-4.5", + "glm-4.5-flash", + ], +) +def test_pre_5_2_glm_drops_reasoning_effort_ladder(model_id): + """Bug 1: these GLM models do not support reasoning_effort per Z.AI docs.""" + efforts = cfg.resolve_model_reasoning_efforts(model_id, provider_id="zai") + assert efforts == [], ( + f"{model_id} via native zai must not advertise reasoning_effort " + f"(Z.AI: reasoning_effort is GLM-5.2+ only); got {efforts!r}" + ) + + +# ── Bug 3: GLM-4.7 forced thinking — no ladder AND no 'none' ───────────────────── + +def test_glm_4_7_forced_thinking_drops_entire_ladder(): + """Bug 3: GLM-4.7 uses forced thinking and cannot be disabled.""" + efforts = cfg.resolve_model_reasoning_efforts("glm-4.7", provider_id="zai") + assert efforts == [], ( + "glm-4.7 uses forced thinking per Z.AI docs — no reasoning_effort " + f"and no 'none'; got {efforts!r}" + ) + + +def test_glm_4_7_air_forced_thinking_drops_entire_ladder(): + efforts = cfg.resolve_model_reasoning_efforts("glm-4.7-air", provider_id="zai") + assert efforts == [] + + +# ── Three-tier classification: effort / thinking / forced ──────────────────────── +# Per docs.z.ai, the three Z.AI capability tiers are distinct: +# - GLM-5.2+: effort ladder (max..minimal) AND thinking toggle +# - GLM-4.5–5.1: thinking toggle ONLY (no effort ladder) +# - GLM-4.7: forced thinking (neither toggle nor ladder) +# Returning [] for the effort ladder must NOT also hide the thinking on/off +# control for the middle tier — otherwise GLM-4.5/4.6/5.0/5.1 users lose the +# working thinking toggle they had before (#6219 round-2 review). + + +@pytest.mark.parametrize( + "model_id,expected", + [ + # GLM-5.2+ → effort ladder + thinking toggle + ("glm-5.2", "effort"), + ("glm-5.2-air", "effort"), + ("glm-5.3", "effort"), + ("glm-6", "effort"), + # GLM-4.5 up to (but not including) 5.2 → thinking toggle only + ("glm-5.1", "thinking"), + ("glm-5", "thinking"), + ("glm-5-turbo", "thinking"), + ("glm-4.5", "thinking"), + ("glm-4.5-flash", "thinking"), + ("glm-4.5-air", "thinking"), + ("glm-4.6", "thinking"), + # GLM-4.7 family → forced thinking (no toggle, no ladder) + ("glm-4.7", "forced"), + ("glm-4.7-air", "forced"), + # Non-zai / non-glm → defer (None) + ("gpt-5", None), + ("claude-sonnet-4.6", None), + ], +) +def test_zai_glm_classification_three_tiers(model_id, expected): + cls = cfg._zai_glm_classification(model_id, provider_id="zai") + assert cls == expected, ( + f"{model_id} via zai should classify as {expected!r}, got {cls!r}" + ) + + +def test_classification_none_for_non_zai_provider(): + """Aggregators must defer (None) — they route through their own routers.""" + assert cfg._zai_glm_classification("glm-5.2", provider_id="openrouter") is None + assert cfg._zai_glm_classification("glm-5.2", provider_id="custom:newapi") is None + + +def test_classification_none_for_non_glm_on_zai(): + """A non-GLM model routed through zai must defer (None).""" + assert cfg._zai_glm_classification("gpt-5", provider_id="zai") is None + assert cfg._zai_glm_classification("claude-sonnet-4.6", provider_id="zai") is None + + +# ── supports_thinking_toggle: chip visibility contract ────────────────────────── + + +def _reasoning_status(model_id, provider_id="zai"): + """Helper: call get_reasoning_status with a stub config so it does not depend + on the active profile's config.yaml.""" + import unittest.mock as mock + with mock.patch("api.config._load_yaml_config_file", return_value={}): + return cfg.get_reasoning_status( + model_id=model_id, provider_id=provider_id + ) + + +def test_glm_5_2_status_offers_effort_ladder_and_toggle(): + """GLM-5.2: full effort ladder AND thinking toggle both available.""" + st = _reasoning_status("glm-5.2") + assert st["supports_reasoning_effort"] is True + assert set(st["supported_efforts"]) == { + "minimal", "low", "medium", "high", "xhigh", "max" + } + assert st["supports_thinking_toggle"] is True + + +@pytest.mark.parametrize( + "model_id", + ["glm-4.6", "glm-4.5", "glm-4.5-flash", "glm-5", "glm-5.1", "glm-5-turbo"], +) +def test_sub_5_2_glm_status_keeps_thinking_toggle_without_effort_ladder(model_id): + """#6219 round-2: GLM-4.5–5.1 must keep the thinking on/off toggle even though + the effort ladder is empty — otherwise the composer hides the whole chip and + silently regresses the working thinking control.""" + st = _reasoning_status(model_id) + assert st["supported_efforts"] == [], ( + f"{model_id} must not advertise an effort ladder" + ) + assert st["supports_reasoning_effort"] is False + assert st["supports_thinking_toggle"] is True, ( + f"{model_id} accepts thinking:{type:enabled|disabled} per Z.AI docs; the " + "composer must still render an On/None control when the ladder is empty" + ) + + +def test_glm_4_7_status_offers_neither_toggle_nor_ladder(): + """GLM-4.7 forced thinking: the composer must hide the chip entirely.""" + st = _reasoning_status("glm-4.7") + assert st["supported_efforts"] == [] + assert st["supports_reasoning_effort"] is False + assert st["supports_thinking_toggle"] is False, ( + "glm-4.7 forces thinking on — no on/off toggle should be offered" + ) + + +@pytest.mark.parametrize("alias", ["glm", "z-ai", "z.ai", "zhipu"]) +def test_thinking_toggle_aliases_resolve_through_same_gate(alias): + """All ZAI aliases must produce the same thinking-toggle verdict as native zai.""" + st_glm_4_6 = _reasoning_status("glm-4.6", provider_id=alias) + st_glm_4_7 = _reasoning_status("glm-4.7", provider_id=alias) + assert st_glm_4_6["supports_thinking_toggle"] is True + assert st_glm_4_7["supports_thinking_toggle"] is False + + +def test_non_zai_status_toggle_defaults_to_effort_capability(): + """For non-zai providers, supports_thinking_toggle must mirror effort + capability (the prior behavior) — no separate ZAI gate fires.""" + # An effort-capable model on a non-zai provider. + st = _reasoning_status("gpt-5", provider_id="openai") + assert st["supports_thinking_toggle"] == st["supports_reasoning_effort"] + # A non-effort model on a non-zai provider: toggle also False (no ZAI gate). + st2 = _reasoning_status("gpt-4o", provider_id="openai") + assert st2["supports_thinking_toggle"] is False + assert st2["supports_reasoning_effort"] is False + + +# ── Aliases resolve through the same gate ──────────────────────────────────────── + +@pytest.mark.parametrize("alias", ["glm", "z-ai", "z.ai", "zhipu"]) +def test_zai_aliases_resolve_through_same_gate(alias): + """_resolve_provider_alias must funnel all ZAI aliases to the same gate.""" + efforts_5_2 = cfg.resolve_model_reasoning_efforts("glm-5.2", provider_id=alias) + efforts_5_1 = cfg.resolve_model_reasoning_efforts("glm-5.1", provider_id=alias) + efforts_4_7 = cfg.resolve_model_reasoning_efforts("glm-4.7", provider_id=alias) + assert set(efforts_5_2) == { + "minimal", "low", "medium", "high", "xhigh", "max" + } + assert efforts_5_1 == [] + assert efforts_4_7 == [] + + +# ── Aggregator / custom providers are intentionally UNAFFECTED ─────────────────── +# Z.AI's per-model docs apply to the native zai endpoint only. Third-party routers +# (openrouter, kilocode, custom:...) have their own mappings and must keep the +# existing family-level reasoning capability so the selector still appears there. + +@pytest.mark.parametrize( + "model_id, provider_id", + [ + ("glm-5.1:free", "kilocode"), + ("glm-6-pro", "custom:newapi"), + ("glm-4.5-flash", "openrouter"), + ], +) +def test_aggregator_providers_keep_family_reasoning(model_id, provider_id): + efforts = cfg.resolve_model_reasoning_efforts(model_id, provider_id=provider_id) + assert set(efforts) >= {"low", "medium", "high"}, ( + f"{model_id} via aggregator {provider_id} must keep family-level reasoning" + ) + + +# ── Non-GLM models on zai provider are untouched ───────────────────────────────── + +def test_non_glm_model_on_zai_provider_unaffected(): + # A non-GLM model id routed through the zai provider must not be gated + # by the GLM-specific branch — the gate keys on "glm" in the bare id, so + # non-GLM models fall through unchanged. (The OpenAI-family ceiling does NOT + # fire here because that branch is keyed on provider, not model family.) + efforts = cfg.resolve_model_reasoning_efforts("gpt-5", provider_id="zai") + assert set(efforts) == {"minimal", "low", "medium", "high", "xhigh", "max"} + + +# ── Coercion agrees with advertising (UI/coercion invariant) ───────────────────── +# The ZAI gate returns a KNOWN-empty list for pre-5.2 GLM (distinct from the +# ambiguous empty list returned for genuinely-unknown models, which preserves +# the configured effort verbatim per #3505). So any stored effort level — not +# just 'max' — must coerce to "" (send no reasoning_effort field) for these +# models, matching the UI showing no options. Without this, a stored 'high' or +# 'medium' would be forwarded to Z.AI and silently ignored. + +@pytest.mark.parametrize( + "model_id", + ["glm-5.1", "glm-5", "glm-5-turbo", "glm-4.5", "glm-4.5-flash", "glm-4.7"], +) +def test_coerce_any_stored_level_to_empty_for_pre_5_2_glm(model_id): + """Bug 2 (coercion gap): all levels, not just 'max', must coerce to ''.""" + for level in ["max", "xhigh", "high", "medium", "low", "minimal"]: + coerced = cfg.coerce_reasoning_effort_for_model( + level, model_id, provider_id="zai" + ) + assert coerced == "", ( + f"{model_id} via zai is known not to support reasoning_effort; stored " + f"'{level}' must coerce to '' (send no field), got {coerced!r}" + ) + + +def test_coerce_preserves_levels_for_glm_5_2(): + """GLM-5.2 accepts the full ladder — all stored levels preserve verbatim.""" + for level in ["max", "xhigh", "high", "medium", "low", "minimal"]: + coerced = cfg.coerce_reasoning_effort_for_model( + level, "glm-5.2", provider_id="zai" + ) + assert coerced == level, ( + f"glm-5.2 supports '{level}'; got {coerced!r}" + ) + + +@pytest.mark.parametrize("alias", ["glm", "z-ai", "z.ai", "zhipu"]) +def test_coerce_zai_aliases_resolve_through_same_gate(alias): + """All ZAI aliases must hit the same coercion gate as native 'zai'.""" + coerced = cfg.coerce_reasoning_effort_for_model( + "high", "glm-5.1", provider_id=alias + ) + assert coerced == "", ( + f"alias '{alias}' must resolve to zai and coerce 'high' to '' for glm-5.1" + ) + + +def test_coerce_unchanged_for_unknown_non_zai_models(): + """Regression guard for #3505: a genuinely-unknown model on a non-zai provider + must STILL preserve the configured effort verbatim (the ZAI gate must not + bleed into the ambiguous-empty path).""" + coerced = cfg.coerce_reasoning_effort_for_model( + "high", "some-brand-new-model-9999", provider_id="custom:myrouter" + ) + # custom: provider is not zai → ZAI gate returns None → #3505 preserve path. + assert coerced == "high" + + +# ── Round-3: GLM-4.7 forced + stored 'none' (gap #2) ──────────────────────────── +# A forced-thinking model cannot have reasoning disabled, so a stored 'none' +# must coerce to '' (provider default = thinking on) and the 'none' sentinel +# must NOT appear in supported_efforts even when the raw source lists it. + + +@pytest.mark.parametrize("model_id", ["glm-4.7", "glm-4.7-air"]) +def test_coerce_stored_none_to_empty_for_forced_glm(model_id): + """Gap #2: GLM-4.7 + stored 'none' must coerce to '' (forced thinking).""" + coerced = cfg.coerce_reasoning_effort_for_model( + "none", model_id, provider_id="zai" + ) + assert coerced == "", ( + f"{model_id} forces thinking on — stored 'none' must coerce to '' so " + f"streaming does not build disabled reasoning; got {coerced!r}" + ) + + +@pytest.mark.parametrize("model_id", ["glm-5.2", "glm-4.6", "glm-5.1"]) +def test_coerce_preserves_none_for_non_forced_glm(model_id): + """Regression guard: non-forced GLM models MUST still accept 'none'.""" + coerced = cfg.coerce_reasoning_effort_for_model( + "none", model_id, provider_id="zai" + ) + assert coerced == "none", ( + f"{model_id} is not forced-thinking — 'none' must be preserved; " + f"got {coerced!r}" + ) + + +def test_resolve_does_not_reattach_none_for_forced_glm(): + """Gap #2 part B: when the raw source lists 'none', the resolver must NOT + reattach it to a forced-thinking model's supported options.""" + import unittest.mock as mock + raw_with_none = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + with mock.patch( + "api.config._resolve_model_reasoning_efforts_impl", + return_value=raw_with_none, + ): + for model_id in ["glm-4.7", "glm-4.7-air"]: + sup = cfg.resolve_model_reasoning_efforts(model_id, provider_id="zai") + assert sup == [], ( + f"{model_id} is forced-thinking — supported_efforts must be [] " + f"even when the raw source lists 'none'; got {sup}" + ) + + +def test_resolve_reattaches_none_for_thinking_tier(): + """Regression guard: GLM-4.6 (thinking tier) MUST still get 'none' in its + supported list when the raw source lists it — the thinking tier CAN turn + thinking off, only the forced tier cannot.""" + import unittest.mock as mock + raw_with_none = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + with mock.patch( + "api.config._resolve_model_reasoning_efforts_impl", + return_value=raw_with_none, + ): + sup = cfg.resolve_model_reasoning_efforts("glm-4.6", provider_id="zai") + assert sup == ["none"], ( + f"glm-4.6 (thinking tier) must keep [none] when raw source lists it; " + f"got {sup}" + ) + + +def test_get_reasoning_status_forced_glm_with_stored_none_reports_default(): + """End-to-end gap #2: GLM-4.7 with agent.reasoning_effort=none configured + must report reasoning_effort='' (default = thinking on), not 'none'.""" + import unittest.mock as mock + with mock.patch( + "api.config._load_yaml_config_file", + return_value={"agent": {"reasoning_effort": "none"}}, + ): + st = cfg.get_reasoning_status(model_id="glm-4.7", provider_id="zai") + assert st["reasoning_effort"] == "", ( + f"forced-thinking glm-4.7 with stored 'none' must report '' (default = " + f"thinking on); got {st['reasoning_effort']!r}" + ) + assert st["supports_thinking_toggle"] is False + assert st["supported_efforts"] == [] + + +# ── Round-3: set_reasoning_effort accepts empty (gap #1 backend) ──────────────── +# The Default/On re-enable path POSTs effort:'' to clear the override. The +# backend must accept empty (not 400) and remove the agent.reasoning_effort key. + + +def test_set_reasoning_effort_accepts_empty_as_clear(): + """Gap #1 backend: empty effort clears the override (Default/On path).""" + import unittest.mock as mock + saved = {} + + def fake_save(path, data): + saved.update(data.get("agent", {}) or {}) + + with mock.patch("api.config._save_yaml_config_file", side_effect=fake_save), \ + mock.patch("api.config.reload_config"): + result = cfg.set_reasoning_effort("", model_id="glm-4.6", provider_id="zai") + # Empty effort must remove the key entirely (not write empty string). + assert "reasoning_effort" not in saved, ( + f"empty effort must clear agent.reasoning_effort; saved agent cfg={saved}" + ) + assert result["reasoning_effort"] == "" + + +def test_set_reasoning_effort_still_rejects_invalid(): + """Regression guard: invalid effort values must still raise ValueError.""" + import unittest.mock as mock + with mock.patch("api.config._save_yaml_config_file"), \ + mock.patch("api.config.reload_config"): + with pytest.raises(ValueError): + cfg.set_reasoning_effort("banana", model_id="glm-4.6", provider_id="zai") + + +@pytest.mark.parametrize("effort", ["none", "minimal", "low", "medium", "high", "xhigh", "max"]) +def test_set_reasoning_effort_still_accepts_valid_levels(effort): + """Regression guard: all valid levels + none must still save correctly.""" + import unittest.mock as mock + saved = {} + + def fake_save(path, data): + saved.update(data.get("agent", {}) or {}) + + with mock.patch("api.config._save_yaml_config_file", side_effect=fake_save), \ + mock.patch("api.config.reload_config"): + cfg.set_reasoning_effort(effort, model_id="glm-4.6", provider_id="zai") + assert saved.get("reasoning_effort") == effort