Files
hermes-webui/tests/test_zai_reasoning_effort_gating.py
d195b094bb Release: GLM per-version reasoning controls (#6219, @rh-id) (#6243)
* 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>
2026-07-18 05:44:33 -07:00

460 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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