* fix(reasoning): gate reasoning_effort ladder to GLM-5.2+ on native zai
Z.AI's API (docs.z.ai) defines reasoning_effort as GLM-5.2+ exclusive, but
hemes-webui advertised the full 6-level ladder for all 7 GLM models 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 (glm-5.1, glm-5, glm-5-turbo, glm-4.7, glm-4.5, glm-4.5-flash) showed a
selector whose values the endpoint silently ignores, and GLM-4.7 (forced thinking
that cannot be disabled per Z.AI docs) showed a 'none' option with no effect.
Add a ZAI branch to _filter_reasoning_efforts_for_provider mirroring the existing
OpenAI/Gemini/Anthropic ceiling pattern: strip the whole ladder for pre-5.2 GLM
models and for the forced-thinking GLM-4.7 family; preserve the full ladder for
GLM-5.2+ (whose accepted values match VALID_REASONING_EFFORTS exactly). The gate
is scoped to the native zai provider only (aliases glm/z-ai/z.ai/zhipu all
resolve to zai); aggregator providers are untouched because they route through
their own routers, not Z.AI's native endpoint.
The glm family-detection heuristic in _candidate_supports_reasoning is unchanged
— GLM models DO support the thinking on/off toggle at the family level; this fix
is specifically about the reasoning_effort intensity ladder.
State layer: agent.reasoning_effort config + UI dropdown options derived from
resolve_model_reasoning_efforts. Invariant: UI options and coercion now agree
and match Z.AI's per-model docs (max offered only for GLM-5.2+, none never
offered for forced-thinking models). Out of scope: the thinking:{type:...}
request-field translation lives in the external agent/gateway layer.
* fix(reasoning): close ZAI coercion gap + harden test assertions
Address Greptile review on #6219 (round 1):
1. Vacuous test assertion (test_glm_5_2_preserves_none_sentinel): the 'or'
fallback made the assertion always-true, so a regression stripping 'none'
for GLM-5.2 would go undetected. Rewrote to inject 'none' via the raw source
(mocking _resolve_model_reasoning_efforts_impl) and assert it survives — the
test now genuinely exercises the preservation branch.
2. Coercion gap for non-max stored levels on pre-5.2 GLM: the existing
'if ceiling and raw not in ceiling' guard treats an empty ceiling as 'no
rule' (preserving the configured effort verbatim per #3505), so a stored
'high'/'medium'/'low' for glm-5.1/glm-4.5/glm-4.7 on native zai was forwarded
to Z.AI unchanged and silently ignored — contradicting the PR's stated
UI/coercion agreement invariant.
Root cause: the ZAI gate returns [] to mean 'known-empty' (no
reasoning_effort at all), but the coercion path treated all [] as
'ambiguous/unknown, preserve verbatim'. Fixed by extracting the ZAI decision
into _zai_glm_reasoning_efforts_supported (True/False/None sentinel) shared by
both the filter and coercion, then special-casing the known-False result in
coerce_reasoning_effort_for_model to return '' (send no field). The #3505
preserve-verbatim behavior for genuinely-unknown models on non-zai providers
is unchanged.
Added 10 regression tests (all fail before the coercion fix, pass after):
- All 6 levels (max..minimal) coerce to '' for each pre-5.2 GLM + glm-4.7
- All 4 aliases (glm/z-ai/z.ai/zhipu) resolve through the same coercion gate
- GLM-5.2 preserves all 6 levels verbatim
- Regression guard: unknown model on custom: provider STILL preserves verbatim
State layer: agent.reasoning_effort config + the value forwarded to Z.AI.
Invariant now fully holds: UI offers no options for pre-5.2 GLM AND coercion
sends no reasoning_effort field for any stored level on those models.
* fix(reasoning): preserve ZAI GLM 4.5-5.1 thinking toggle when effort ladder empty
Address nesquena-hermes round-2 review on #6219: returning [] for the effort
ladder on sub-5.2 GLM models hid the entire reasoning chip in the composer
(static/ui.js:4932 treats empty supported_efforts as 'no reasoning control at
all'), silently regressing the working thinking on/off toggle for GLM-4.5/4.6/
5.0/5.1 users. Per Z.AI's own docs, those models accept the thinking
{type:enabled|disabled} toggle even though they do not accept the
reasoning_effort intensity ladder.
Fix decouples thinking-toggle capability from the effort ladder:
Backend (api/config.py):
- Refactor _zai_glm_classification returns one of 'effort' (GLM-5.2+), 'thinking'
(GLM-4.5 up to but not including 5.2), 'forced' (GLM-4.7 family), or None
(non-zai / non-GLM). Single source of truth shared by all three consumers.
- _zai_glm_reasoning_efforts_supported now wraps classification for the coercion
contract (unchanged behavior).
- _zai_glm_thinking_toggle_supported returns True for 'effort' or 'thinking',
False for 'forced', None otherwise.
- get_reasoning_status gains a supports_thinking_toggle field = bool(supported)
OR (zai_thinking is True). Non-zai providers default to bool(supported_efforts)
so their chip-visibility behavior is unchanged.
Frontend (static/ui.js):
- New _currentReasoningToggleSupported state var (default undefined = treat as
true so legacy responses without the field do not newly hide the chip).
- _applyReasoningChip shows the chip when hasEffortLadder OR toggleSupported.
Empty efforts + toggle=True keeps the chip visible with just the None/On
control (the existing _applyReasoningOptions already shows 'none' when the
ladder is empty). Empty efforts + toggle=False (GLM-4.7 forced) hides it.
- Profile-transition and fetch-failure resets now pass
supports_thinking_toggle:false alongside the empty efforts so the chip hides
during the unknown-state window, matching the prior reset contract.
Tests (30 new):
- _zai_glm_classification parametrized across all three tiers + aliases + defer
- get_reasoning_status supports_thinking_toggle per tier (GLM-5.2 both, GLM-4.6
toggle-only, GLM-4.7 neither, non-zai defaults to effort capability)
- Frontend _applyReasoningChip behavior via node driver: empty efforts +
toggle=True stays visible, toggle=False hides, effort ladder alone is
sufficient, absent field keeps prior behavior
State layer: agent.reasoning_effort config + supports_thinking_toggle field in
/api/reasoning + composer chip visibility. Invariant: the chip is hidden ONLY
when the model supports neither the effort ladder nor the thinking toggle
(GLM-4.7 forced, or genuinely non-reasoning models); GLM-4.5-5.1 retain the
working On/None control they had before the round-1 effort gate.
* fix(reasoning): make ZAI thinking toggle two-way + force GLM-4.7 stored none
Address nesquena-hermes round-3 review on #6219 — two SILENT gaps in the
thinking-toggle path, plus a click-handler sibling I found while auditing.
Gap #1 — ONE-WAY toggle for GLM-4.5/4.6/5.0/5.1 (api/config.py:4247,
static/ui.js:4902, static/index.html:757):
The round-2 fix kept the chip visible for thinking-tier models but the only
rendered dropdown option was 'None' (the HTML had no Default option, and
set_reasoning_effort rejected empty effort with 400). So a GLM-4.6 user could
turn thinking OFF but never back ON — worse than the original bug.
Fix:
- set_reasoning_effort now accepts empty effort as 'clear the override' (removes
agent.reasoning_effort so the provider default takes effect). Invalid values
still raise ValueError.
- static/index.html gains a <div data-effort=''>Default</div> option.
- _applyReasoningOptions always shows both Default ('') and None alongside the
effort ladder, so a thinking-tier model (empty ladder + toggle=true) renders
an operable Default+None two-state control.
- Click handler (ui.js:5106) checks option presence (if(opt)) not truthiness
(if(effort)) — the old check silently ignored data-effort='' clicks, which
would have left the Default button dead even after the HTML/backend changes.
Gap #2 — GLM-4.7 not forced when 'none' stored (api/config.py:3984, :3857):
When GLM-4.7 had agent.reasoning_effort=none configured, coercion preserved
'none' via the early return at line 3985, so streaming built disabled reasoning
for a model that forces thinking on regardless. Separately, when the raw
capability source listed 'none', resolve_model_reasoning_efforts reattached it
to GLM-4.7's supported_efforts (['none']), leaking an 'off' option to the UI
for a forced-thinking model.
Fix:
- coerce_reasoning_effort_for_model checks _zai_glm_classification == 'forced'
BEFORE the generic 'none' early-return, coercing stored 'none' to '' (default
= thinking on) for forced models.
- resolve_model_reasoning_efforts returns [] early for forced-tier models,
skipping the 'none' reattachment entirely.
Tests (17 new):
- Gap #2: coerce('none', glm-4.7) -> '', regression guard that non-forced GLM
still accepts 'none'; resolve does not reattach 'none' for forced but DOES
for thinking-tier; end-to-end get_reasoning_status for forced+stored-none.
- Gap #1 backend: set('') clears the key (no raise), set('garbage') still
raises, all 7 valid levels still save.
- Gap #1 frontend: three new node-driver tests asserting the dropdown exposes
both Default and None for thinking-tier (two-state), Default+None+ladder for
effort-tier, and the off->on->off round trip keeps both visible throughout.
- Updated test_reasoning_show_hide.test_set_reasoning_effort_rejects_invalid to
reflect the new contract (empty accepted, garbage rejected).
Regression gate: gap#2 coerce tests fail without the forced-tier check (3
failures); gap#1 backend test fails with 'ValueError: effort is required'
without the empty-acceptance change. 219 passed, 1 pre-existing skip across
the full reasoning + chip + config-cache surface.
State layer: agent.reasoning_effort config + supports_thinking_toggle field +
composer dropdown options. Invariant: the thinking toggle is now genuinely
two-way for GLM-4.5-5.1 (Default=on, None=off, both always visible) and
GLM-4.7 forced-thinking never offers 'none' in any path (coerce, resolve,
status, or UI).
* CHANGELOG: GLM per-version reasoning controls (#6219)
---------
Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
460 lines
20 KiB
Python
460 lines
20 KiB
Python
"""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
|