* 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>
221 lines
8.4 KiB
Python
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}"
|
|
)
|
|
|