mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
195 lines
8.0 KiB
Python
195 lines
8.0 KiB
Python
"""Regression tests for #1384 — ``provider: "local"`` mid-conversation crash.
|
|
|
|
Earlier WebUI builds auto-detected unknown loopback hosts and persisted
|
|
``provider: "local"`` to ``config.yaml``. That value is not a registered
|
|
provider in ``hermes_cli.auth.PROVIDER_REGISTRY``, so the agent's auxiliary
|
|
client (compression, vision, web extraction) raised
|
|
``"Provider 'local' is set in config.yaml but no API key was found"``
|
|
mid-conversation when the context-compression threshold was hit.
|
|
|
|
The fix is three layers deep so a user with an already-broken config gets
|
|
healed automatically:
|
|
|
|
1. The auto-detect block now writes ``provider: "custom"`` for unknown
|
|
loopback hosts (``custom`` is the canonical generic-OpenAI-compat
|
|
provider, and the agent's auxiliary client takes the
|
|
``no-key-required`` path for it).
|
|
2. ``resolve_model_provider()`` rewrites legacy ``"local"`` to ``"custom"``
|
|
at read time, so existing configs route correctly without requiring the
|
|
user to edit ``config.yaml`` by hand.
|
|
3. ``set_hermes_default_model()`` refuses to persist ``"local"`` going
|
|
forward, so any other code path that tries to write it is also healed.
|
|
4. The local alias table has ``"local" → "custom"`` for any consumer that
|
|
normalises through ``_resolve_provider_alias``.
|
|
"""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import api.config as cfg
|
|
|
|
|
|
# ── 1. Auto-detect block writes ``custom``, not ``local`` ────────────────
|
|
|
|
|
|
class TestAutoDetectWritesCustom:
|
|
"""The auto-detect branch in ``_build_available_models_uncached`` must
|
|
never write ``provider = "local"`` because that value breaks the
|
|
auxiliary client mid-conversation."""
|
|
|
|
def test_source_code_no_local_assignment(self):
|
|
"""The string ``provider = "local"`` must not appear in api/config.py."""
|
|
src = Path(cfg.__file__).read_text(encoding="utf-8")
|
|
assert 'provider = "local"' not in src, (
|
|
'api/config.py must not assign provider = "local" — see #1384. '
|
|
"Use ``custom`` instead so the agent's auxiliary client takes the "
|
|
"``no-key-required`` OpenAI-compat path."
|
|
)
|
|
|
|
def test_auto_detect_branch_uses_custom(self):
|
|
"""The else-branch in the auto-detect block resolves to ``custom``."""
|
|
src = Path(cfg.__file__).read_text(encoding="utf-8")
|
|
# Find the auto-detect block (host-keyword classifier).
|
|
m = re.search(
|
|
r'if "ollama" in host or "127\.0\.0\.1" in host or "localhost" in host:\s*\n'
|
|
r'\s*provider = "ollama"\s*\n'
|
|
r'\s*elif "lmstudio" in host or "lm-studio" in host:\s*\n'
|
|
r'\s*provider = "lmstudio"\s*\n'
|
|
r'\s*else:',
|
|
src,
|
|
)
|
|
assert m, "Auto-detect host-classifier block not found in api/config.py"
|
|
# Find the next provider assignment after the else.
|
|
tail = src[m.end() : m.end() + 1500]
|
|
provider_assign = re.search(r'provider = "([a-z-]+)"', tail)
|
|
assert provider_assign, "No provider assignment found after auto-detect else"
|
|
assert provider_assign.group(1) == "custom", (
|
|
f"Auto-detect else branch must assign provider = \"custom\", "
|
|
f"got {provider_assign.group(1)!r}"
|
|
)
|
|
|
|
|
|
# ── 2. resolve_model_provider() heals legacy ``local`` configs ───────────
|
|
|
|
|
|
class TestResolveModelProviderHealsLegacyLocal:
|
|
"""Existing ``config.yaml`` files with ``provider: local`` (written by
|
|
earlier WebUI builds) must be normalised to ``custom`` at read time so
|
|
downstream agent calls take the working path."""
|
|
|
|
def test_provider_local_normalised_to_custom(self, tmp_path, monkeypatch):
|
|
cfgfile = tmp_path / "config.yaml"
|
|
cfgfile.write_text(
|
|
"model:\n"
|
|
" default: qwen2.5-coder:14b\n"
|
|
" provider: local\n"
|
|
" base_url: http://127.0.0.1:11434/v1\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: cfgfile)
|
|
cfg.reload_config()
|
|
try:
|
|
model_id, provider, base_url = cfg.resolve_model_provider("qwen2.5-coder:14b")
|
|
assert provider == "custom", (
|
|
f"resolve_model_provider must rewrite legacy 'local' to 'custom', "
|
|
f"got {provider!r}"
|
|
)
|
|
assert base_url == "http://127.0.0.1:11434/v1"
|
|
assert model_id == "qwen2.5-coder:14b"
|
|
finally:
|
|
cfg.reload_config()
|
|
|
|
def test_provider_local_uppercase_also_normalised(self, tmp_path, monkeypatch):
|
|
"""Case-insensitive match — YAML may have ``Local`` or ``LOCAL``."""
|
|
cfgfile = tmp_path / "config.yaml"
|
|
cfgfile.write_text(
|
|
"model:\n"
|
|
" default: my-model\n"
|
|
" provider: Local\n"
|
|
" base_url: http://192.168.1.10:8000/v1\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: cfgfile)
|
|
cfg.reload_config()
|
|
try:
|
|
_, provider, _ = cfg.resolve_model_provider("my-model")
|
|
assert provider == "custom"
|
|
finally:
|
|
cfg.reload_config()
|
|
|
|
def test_other_providers_pass_through_unchanged(self, tmp_path, monkeypatch):
|
|
"""The migration must not touch any other provider name."""
|
|
cfgfile = tmp_path / "config.yaml"
|
|
cfgfile.write_text(
|
|
"model:\n"
|
|
" default: claude-sonnet-4.6\n"
|
|
" provider: anthropic\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: cfgfile)
|
|
cfg.reload_config()
|
|
try:
|
|
_, provider, _ = cfg.resolve_model_provider("claude-sonnet-4.6")
|
|
assert provider == "anthropic"
|
|
finally:
|
|
cfg.reload_config()
|
|
|
|
|
|
# ── 3. set_hermes_default_model never persists 'local' ───────────────────
|
|
|
|
|
|
class TestSetHermesDefaultModelNeverPersistsLocal:
|
|
"""Even if a caller (or a stale resolver) hands us ``provider='local'``,
|
|
we must not write that value back to ``config.yaml``."""
|
|
|
|
def test_existing_local_provider_is_replaced_on_save(self, tmp_path, monkeypatch):
|
|
"""Writing a new default model when previous_provider is 'local'
|
|
must persist 'custom' instead, because previous_provider falls
|
|
through into persisted_provider when resolve_model_provider doesn't
|
|
return a new provider hint."""
|
|
import yaml
|
|
|
|
cfgfile = tmp_path / "config.yaml"
|
|
cfgfile.write_text(
|
|
"model:\n"
|
|
" default: old-model\n"
|
|
" provider: local\n"
|
|
" base_url: http://127.0.0.1:11434/v1\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(cfg, "_get_config_path", lambda: cfgfile)
|
|
cfg.reload_config()
|
|
try:
|
|
cfg.set_hermes_default_model("qwen2.5-coder:14b")
|
|
saved = yaml.safe_load(cfgfile.read_text(encoding="utf-8"))
|
|
persisted = saved.get("model", {}).get("provider", "")
|
|
assert persisted != "local", (
|
|
f"set_hermes_default_model must rewrite 'local' on save — "
|
|
f"got {persisted!r}"
|
|
)
|
|
assert persisted == "custom"
|
|
finally:
|
|
cfg.reload_config()
|
|
|
|
|
|
# ── 4. Alias table has the entry ─────────────────────────────────────────
|
|
|
|
|
|
class TestAliasTableHasLocalEntry:
|
|
"""``_resolve_provider_alias`` must rewrite ``local`` → ``custom`` for
|
|
any other code path that normalises through the alias table."""
|
|
|
|
def test_local_alias_resolves_to_custom(self):
|
|
assert cfg._resolve_provider_alias("local") == "custom"
|
|
|
|
def test_local_alias_case_insensitive(self):
|
|
assert cfg._resolve_provider_alias("LOCAL") == "custom"
|
|
assert cfg._resolve_provider_alias("Local") == "custom"
|
|
|
|
def test_alias_table_contains_local_entry(self):
|
|
assert cfg._PROVIDER_ALIASES.get("local") == "custom", (
|
|
"_PROVIDER_ALIASES must map 'local' → 'custom' for #1384"
|
|
)
|