Merge remote-tracking branch 'origin/master' into paperclip/pr-6222
This commit is contained in:
commit
5c4f38a518
7 changed files with 997 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
155
api/config.py
155
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 <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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
46
static/ui.js
46
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();
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
459
tests/test_zai_reasoning_effort_gating.py
Normal file
459
tests/test_zai_reasoning_effort_gating.py
Normal 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
|
||||
Loading…
Reference in a new issue