Files
hermes-webui/tests/test_glm_5_3_catalog.py
T
d13e922120 Release batch B: Docker single-container UID fix + GLM-5.3 catalog (experimental) (#7090)
* feat: add GLM-5.3 to Z.AI model list

Add glm-5.3 as the newest zai entry in _PROVIDER_MODELS and a matching
zai/glm-5.3 entry in _FALLBACK_MODELS, and bump the Z.AI onboarding
default_model from glm-5.1 to glm-5.3 (Z.ai's current flagship; legacy
GLM-5.2/5.1 requests are routed to GLM-5.3 per docs.z.ai).

Reasoning gating needs no change: _zai_glm_classification() treats
GLM >= 5.2 as effort-ladder capable, so glm-5.3 is already covered and
pinned by tests/test_zai_reasoning_effort_gating.py.

New regression coverage in tests/test_glm_5_3_catalog.py: catalog
presence, newest-first ordering, fallback entry, onboarding default,
full reasoning_effort ladder, and the get_available_models() payload.

* test: restore _cfg_fingerprint in catalog test fixture

Review follow-up (#7017): the isolation fixture snapshot restored cfg,
_cfg_mtime, and _cfg_path but left _cfg_fingerprint pointing at the
temporary config loaded by the payload test. api/config.py uses that
fingerprint to distinguish in-memory overrides from changed files
(config.py:371), so a stale value could make later same-process tests
skip reloading a changed config. Snapshot and restore it like the rest.

* fix: keep Z.AI onboarding default at glm-5.1 until direct API serves GLM-5.3

Review follow-up (#7017): GLM-5.3 is live on Z.ai's Coding Plan
endpoint only; the direct api.z.ai endpoint the zai provider uses
still lists the GLM-5.3 API as coming soon. Defaulting new direct-API
users onto glm-5.3 would fail their first message, so the catalog
addition stays (opt-in) and the default stays glm-5.1. Bump the
default in a follow-up once the direct endpoint serves GLM-5.3.

* fix(docker): probe the configured state dir before /workspace for UID/GID (#7027)

UID/GID auto-detection probed /workspace before the configured
HERMES_WEBUI_STATE_DIR. In a stock single-container image none of the
priority-1 candidates exist, but /workspace does — owned by the image's
build-time 1024:1024. Detection therefore returned the image's own owner,
which carries no information about the host, while the one directory whose
owner *is* the host UID by definition — the state-dir bind mount — was never
probed. With a host-owned state mount and no explicit WANTED_UID the
container remapped to 1024, failed its own state-dir writability check, and
restart-looped.

The log line made this expensive to debug: 1024 is also the fallback default,
so "Auto-detected workspace UID: 1024" read as if detection had found nothing.

- probe ${HERMES_WEBUI_STATE_DIR:-/app/data} first, for both UID and GID
- keep /workspace as a lower-priority signal (unchanged for setups that
  actually bind-mount it), and keep the hermes-home probes from #668
- stop treating an explicitly supplied 1024 as "unset": the sentinel and a
  valid UID were the same number, so an operator who deliberately ran as 1024
  got it overwritten by detection. The explicit/detected origin is persisted
  next to the value because `su` drops the environment when the script
  re-enters as the runtime user, so the second pass would otherwise see an
  explicit choice as a detected one.

Tests: tests/test_7027_state_dir_uid_probe.py runs the real resolution block
under bash with `stat` stubbed, covering the non-1024 state mount, the
explicit 1024 override (including across the privilege drop), and the
pre-existing /workspace + hermes-home fallback paths. A new state-dir-uid job
in the Docker smoke workflow boots a real container on a host-owned state
mount and gates on /health.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Release batch B: Docker UID fix + GLM-5.3 catalog (experimental)

Two independently gate-passed contributor PRs, rebased fresh onto master and
re-gated as a combined stage (Codex SAFE TO SHIP; full suite green except the 8
pre-existing approval-test failures that fail identically on clean origin/master
— CI green on the same commit; tracked separately for a fix).

- #7027 (@jorgejiro) probe configured state-dir before /workspace for Docker
  UID/GID; persist explicit marker so a supplied 1024 survives root->su re-entry
  (fixes the single-container restart loop) (#7034)
- #7017 (@rh-id) add GLM-5.3 to the Z.AI model list (onboarding default stays glm-5.1)

Co-authored-by: jorgejiro <jorgejiro@users.noreply.github.com>
Co-authored-by: rh-id <rh-id@users.noreply.github.com>

---------

Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: Jorge <jorgejiro@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: n <a@n>
Co-authored-by: jorgejiro <jorgejiro@users.noreply.github.com>
Co-authored-by: rh-id <rh-id@users.noreply.github.com>
2026-08-16 21:52:14 +00:00

221 lines
8.4 KiB
Python

"""Regression tests: GLM-5.3 catalog and onboarding integration.
GLM-5.3 is Z.ai's current flagship model (successor to GLM-5.2). These tests
pin catalog presence (opt-in selection), fallback entry, reasoning ladder,
and payload propagation — ensuring the model users see matches what's declared
in api/config.py. The onboarding default deliberately REMAINS glm-5.1 until
Z.ai's direct API serves GLM-5.3 (Coding-Plan-only today; see #7017 review).
"""
import unittest.mock as mock
import pytest
import api.config as cfg
import api.onboarding as onboarding
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the TTL model cache before AND after every test.
``get_available_models()`` caches its result keyed on config.yaml mtime.
Tests in this file repoint ``_get_config_path`` to a tmp_path, populate
the cache there, then let monkeypatch restore the original path. The
cache, keyed on the tmp_path's mtime, then poisons downstream tests
(e.g. test_model_resolver) which see stale data and never hit their
mocks. Clearing the cache around each test breaks that linkage.
Also snapshots/restores ``c.cfg``, ``c._cfg_mtime``, ``c._cfg_path``,
and ``c._cfg_fingerprint`` to prevent tests from leaking global config
state to neighboring test files.
"""
import api.config as c
import api.providers as p
import api.profiles as profiles
old_cfg = dict(c.cfg)
old_mtime = c._cfg_mtime
old_path = c._cfg_path
old_fingerprint = c._cfg_fingerprint
try:
c.invalidate_models_cache()
p.invalidate_providers_cache()
profiles._invalidate_root_profile_cache()
from api.plugin_providers import invalidate_plugin_model_provider_cache
invalidate_plugin_model_provider_cache()
except Exception:
pass
yield
c.cfg.clear()
c.cfg.update(old_cfg)
c._cfg_mtime = old_mtime
c._cfg_path = old_path
c._cfg_fingerprint = old_fingerprint
try:
c.invalidate_models_cache()
p.invalidate_providers_cache()
profiles._invalidate_root_profile_cache()
from api.plugin_providers import invalidate_plugin_model_provider_cache
invalidate_plugin_model_provider_cache()
except Exception:
pass
def test_glm_5_3_in_provider_models():
"""GLM-5.3 must appear in the zai provider catalog with the correct label."""
zai_models = cfg._PROVIDER_MODELS.get("zai", [])
model_ids = [m["id"] for m in zai_models]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai provider models; got {model_ids}"
)
# Verify the exact label
glm_5_3_entry = [m for m in zai_models if m["id"] == "glm-5.3"]
assert len(glm_5_3_entry) == 1
assert glm_5_3_entry[0]["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3", got {glm_5_3_entry[0]["label"]!r}'
)
def test_glm_5_3_positioned_before_glm_5_2():
"""GLM-5.3 must appear BEFORE GLM-5.2 (lists are newest-first)."""
zai_models = cfg._PROVIDER_MODELS.get("zai", [])
glm_5_3_index = None
glm_5_2_index = None
for i, model in enumerate(zai_models):
if model["id"] == "glm-5.3":
glm_5_3_index = i
elif model["id"] == "glm-5.2":
glm_5_2_index = i
assert glm_5_3_index is not None, "glm-5.3 not found in zai models"
assert glm_5_2_index is not None, "glm-5.2 not found in zai models"
assert glm_5_3_index < glm_5_2_index, (
f"glm-5.3 (index {glm_5_3_index}) must appear before glm-5.2 (index {glm_5_2_index})"
)
def test_glm_5_3_in_fallback_models():
"""GLM-5.3 must appear in _FALLBACK_MODELS with correct provider and label."""
fallback_entries = [m for m in cfg._FALLBACK_MODELS if m["id"] == "zai/glm-5.3"]
assert len(fallback_entries) == 1, (
f"Expected exactly one zai/glm-5.3 entry in _FALLBACK_MODELS; "
f"found {len(fallback_entries)}"
)
entry = fallback_entries[0]
assert entry["provider"] == "Z.AI", (
f'Expected provider "Z.AI", got {entry["provider"]!r}'
)
assert entry["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3", got {entry["label"]!r}'
)
def test_glm_5_3_positioned_first_in_zai_fallback_block():
"""GLM-5.3 must appear as the FIRST Z.AI entry in _FALLBACK_MODELS."""
zai_entries = [m for m in cfg._FALLBACK_MODELS if m["provider"] == "Z.AI"]
assert len(zai_entries) > 0, "No Z.AI entries found in _FALLBACK_MODELS"
first_zai_entry = zai_entries[0]
assert first_zai_entry["id"] == "zai/glm-5.3", (
f'Expected first Z.AI entry to be "zai/glm-5.3", got {first_zai_entry["id"]!r}'
)
def test_zai_onboarding_default_stays_glm_5_1_until_direct_api_serves_glm_5_3():
"""Z.AI onboarding default must remain glm-5.1 until the direct endpoint serves GLM-5.3.
The zai provider calls Z.ai's direct endpoint (api.z.ai/api/paas/v4), where the GLM-5.3
API is not live yet. Z.ai's GLM-5.3 guide marks it "coming soon"; GLM-5.3 is
Coding-Plan-only today. Defaulting direct-API users onto glm-5.3 would fail their first
message with model-not-found, so the default stays glm-5.1 — bump it only after the
direct endpoint serves GLM-5.3 (per the #7017 review gate).
"""
zai_setup = onboarding._SUPPORTED_PROVIDER_SETUPS.get("zai", {})
assert zai_setup, "zai setup not found in _SUPPORTED_PROVIDER_SETUPS"
default_model = zai_setup.get("default_model")
assert default_model == "glm-5.1", (
f'Expected default_model "glm-5.1" (not glm-5.3), got {default_model!r}'
)
def test_glm_5_3_in_zai_onboarding_models_list():
"""GLM-5.3 must appear in the zai onboarding setup's models list."""
zai_setup = onboarding._SUPPORTED_PROVIDER_SETUPS.get("zai", {})
assert zai_setup, "zai setup not found in _SUPPORTED_PROVIDER_SETUPS"
models = zai_setup.get("models", [])
model_ids = [m["id"] for m in models]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai onboarding models list; got {model_ids}"
)
# Verify the exact label in the onboarding list
glm_5_3_entry = [m for m in models if m["id"] == "glm-5.3"]
assert len(glm_5_3_entry) == 1
assert glm_5_3_entry[0]["label"] == "GLM-5.3", (
f'Expected label "GLM-5.3" in onboarding, got {glm_5_3_entry[0]["label"]!r}'
)
def test_glm_5_3_reasoning_efforts():
"""GLM-5.3 must support the full reasoning_effort ladder (GLM-5.2+ tier)."""
efforts = cfg.resolve_model_reasoning_efforts("glm-5.3", provider_id="zai")
assert set(efforts) == {"minimal", "low", "medium", "high", "xhigh", "max"}, (
f"glm-5.3 must support the full reasoning_effort ladder; got {efforts!r}"
)
def test_glm_5_3_in_models_payload_for_zai_provider(tmp_path, monkeypatch):
"""GLM-5.3 must appear in the /api/models payload when zai is active.
This tests the actual observable behavior — what the WebUI model dropdown
sees — not just module-level structures. It verifies that the catalog
entries flow through get_available_models() to the frontend payload.
The configured default is deliberately glm-5.2 (a different model), so
presence of glm-5.3 in the zai group's models list proves catalog
propagation from _PROVIDER_MODELS, not config echo.
"""
import api.config as c
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(
"model:\n provider: zai\n default: glm-5.2\n",
encoding="utf-8",
)
monkeypatch.setattr(c, "_get_config_path", lambda: cfgfile)
c.reload_config()
# Mock list_available_providers to avoid real network calls
fake_prov = mock.MagicMock()
fake_prov.return_value = []
try:
import hermes_cli.models as hm
monkeypatch.setattr(hm, "list_available_providers", fake_prov)
except Exception:
pass
result = c.get_available_models()
c.reload_config()
# Find the zai group
zai_group = None
for group in result.get("groups", []):
if group.get("provider_id") == "zai" or group.get("provider") == "zai":
zai_group = group
break
assert zai_group is not None, (
"No zai provider group found in get_available_models() output; "
f"got providers: {[g.get('provider_id') or g.get('provider') for g in result.get('groups', [])]}"
)
# Check that glm-5.3 appears in the models list
model_ids = [m["id"] for m in zai_group.get("models", [])]
assert "glm-5.3" in model_ids, (
f"glm-5.3 missing from zai group models; got {model_ids}"
)