Files
hermes-webui/tests/test_issue1106_custom_providers_models.py
T
nesquena-hermes f8007d43f3 v0.50.257: 4 Opus pre-release follow-ups + CHANGELOG + test fixes for #1415
stage-257 batch (PRs #1402 + #1415):

Opus pre-release advisor caught 4 issues in stage-257:

1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace()
   preserves the temp file umask (0644 default), so OAuth access/refresh
   tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600)
   BEFORE rename, with try/except OSError that warns but does not abort.

2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted
   job_id as a path component without validation. Mirrors the rollback
   path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT
   normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit
   . / .. rejection.

3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised
   ValueError on malformed input → confusing 500. Now try/except + clamp
   to (max(0, offset), max(1, min(500, limit))).

4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth
   even though path-resolve check would catch it downstream).

PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and
test_custom_provider_display_name asserted bare model IDs but #1415
changes named-custom-provider IDs to @custom:NAME:model form when active
provider differs. Tests updated to use _strip_at_prefix helper to keep
checking the same invariant in the new shape.

4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing
tests. Full suite: 3602 passed, 0 failed.
2026-05-01 18:30:41 +00:00

244 lines
8.9 KiB
Python

"""Tests for #1106 — custom_providers[].models dict keys populate model dropdown."""
import pytest
import api.config as config
def _reset():
try:
config.invalidate_models_cache()
except Exception:
pass
def _models_with_cfg(model_cfg=None, custom_providers=None, active_provider=None):
"""Temporarily patch config.cfg, call get_available_models(), restore.
Also pins _cfg_mtime to prevent reload_config() from overwriting patches.
"""
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
config.cfg.clear()
if model_cfg:
config.cfg["model"] = model_cfg
if custom_providers is not None:
config.cfg["custom_providers"] = custom_providers
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except Exception:
config._cfg_mtime = 0.0
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
def _all_model_ids(result):
"""Extract all model IDs from all groups (raw form — may include @provider: prefix)."""
ids = []
for g in result.get("groups", []):
for m in g.get("models", []):
ids.append(m["id"])
return ids
def _strip_at_prefix(model_id):
"""Strip the optional ``@provider:`` (or ``@provider:subname:``) prefix
from a model id so legacy assertions can compare against the bare form.
PR #1415 introduced provider-qualified IDs (``@custom:NAME:model``) for
named custom providers when the active provider differs. The bare-ID
assertions in this test module pre-date that change and need normalization
to keep checking the same invariant: "does model X appear in the picker".
"""
s = str(model_id or "")
if s.startswith("@") and ":" in s:
return s.rsplit(":", 1)[1]
return s
def _all_model_ids_bare(result):
"""Same as _all_model_ids but with @provider: prefixes stripped."""
return [_strip_at_prefix(mid) for mid in _all_model_ids(result)]
def _group_for(result, provider_name):
"""Get a group by provider name."""
for g in result.get("groups", []):
if g.get("provider") == provider_name:
return g
return None
class TestCustomProvidersModelsDict:
"""custom_providers entries with a 'models' dict should populate all keys in the dropdown."""
def test_models_dict_keys_appear_in_dropdown(self):
"""Each key in custom_providers[].models should appear as a selectable model."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "Llama-swap",
"base_url": "http://llama-swap:8880/v1",
"model": "unsloth-qwen3.6-35b-a3b",
"models": {
"unsloth-qwen3.6-35b-a3b": {"context_length": 262144},
"gemma4-26b": {},
"qwen3.5-27b": {},
"qwen3-coder-30b": {},
},
}
],
)
ids = _all_model_ids_bare(result)
for expected in ["unsloth-qwen3.6-35b-a3b", "gemma4-26b", "qwen3.5-27b", "qwen3-coder-30b"]:
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
def test_models_dict_without_model_field_still_works(self):
"""If only 'models' dict is present (no singular 'model'), all dict keys should appear."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "Local-LLM",
"base_url": "http://localhost:8080/v1",
"models": {
"llama-3-8b": {},
"mistral-7b": {},
},
}
],
)
ids = _all_model_ids_bare(result)
assert "llama-3-8b" in ids
assert "mistral-7b" in ids
def test_no_duplicates_when_model_and_models_overlap(self):
"""If 'model' value also appears in 'models' dict, it should not be duplicated."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "MyServer",
"base_url": "http://myserver:8000/v1",
"model": "base-model",
"models": {
"base-model": {},
"other-model": {},
},
}
],
)
ids = _all_model_ids_bare(result)
assert ids.count("base-model") == 1, f"'base-model' should appear exactly once, got {ids.count('base-model')}"
assert "other-model" in ids
def test_unnamed_provider_models_dict_works(self):
"""custom_providers without 'name' should still populate 'Custom' group."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"model": "my-model",
"models": {
"extra-model-a": {},
"extra-model-b": {},
},
}
],
)
ids = _all_model_ids(result)
for expected in ["my-model", "extra-model-a", "extra-model-b"]:
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
def test_empty_models_dict_is_ignored(self):
"""An empty 'models' dict should not break anything."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "TestServer",
"model": "only-model",
"models": {},
}
],
)
ids = _all_model_ids_bare(result)
assert "only-model" in ids
def test_non_string_models_keys_are_skipped(self):
"""Non-string keys in models dict should be silently skipped."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "TestServer",
"model": "valid-model",
"models": {
"another-valid": {},
123: {}, # non-string key
None: {}, # non-string key
},
}
],
)
ids = _all_model_ids_bare(result)
assert "valid-model" in ids
assert "another-valid" in ids
def test_multiple_custom_providers_each_keep_models_separate(self):
"""Multiple named custom_providers should each have their own models."""
result = _models_with_cfg(
model_cfg={"provider": "custom"},
custom_providers=[
{
"name": "Server-A",
"model": "model-a1",
"models": {"model-a2": {}},
},
{
"name": "Server-B",
"model": "model-b1",
"models": {"model-b2": {}},
},
],
)
group_a = _group_for(result, "Server-A")
group_b = _group_for(result, "Server-B")
assert group_a is not None, "Server-A group missing"
assert group_b is not None, "Server-B group missing"
# PR #1415 prefixes model IDs with @custom:NAME: when the active provider
# is different from the named slug — strip for the bare-id invariant.
ids_a = [_strip_at_prefix(m["id"]) for m in group_a["models"]]
ids_b = [_strip_at_prefix(m["id"]) for m in group_b["models"]]
assert "model-a1" in ids_a and "model-a2" in ids_a
assert "model-b1" in ids_b and "model-b2" in ids_b
# No cross-contamination
assert "model-b1" not in ids_a
assert "model-a1" not in ids_b
def test_named_custom_models_are_prefixed_when_not_active_provider(self):
"""Named custom provider models must carry a routing prefix when DeepSeek is active."""
result = _models_with_cfg(
model_cfg={"provider": "deepseek", "default": "deepseek-v4-pro"},
custom_providers=[
{
"name": "sub2api",
"base_url": "http://127.0.0.1:8080/v1",
"model": "gpt-5.4-mini",
"models": {
"gpt-5.4-mini": {},
"gpt-5.4": {},
},
}
],
)
group = _group_for(result, "sub2api")
assert group is not None, "sub2api group missing"
assert group["provider_id"] == "custom:sub2api"
ids = [m["id"] for m in group["models"]]
assert "@custom:sub2api:gpt-5.4-mini" in ids
assert "@custom:sub2api:gpt-5.4" in ids