Merge remote-tracking branch 'origin/master' into paperclip/pr-6222

This commit is contained in:
Frank Song 2026-07-18 20:54:27 +08:00
commit 5c4f38a518
7 changed files with 997 additions and 13 deletions

View file

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

View file

@ -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 <level>``: 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()

View file

@ -754,6 +754,7 @@
</div>
<div class="ws-dropdown ws-dropdown-footer" id="composerWsDropdown" hidden aria-hidden="true" inert></div>
<div class="composer-reasoning-dropdown" id="composerReasoningDropdown">
<div class="reasoning-option" data-effort="">Default</div>
<div class="reasoning-option" data-effort="none">None</div>
<div class="reasoning-option" data-effort="minimal">Minimal</div>
<div class="reasoning-option" data-effort="low">Low</div>

View file

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

View file

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

View file

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

View file

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