200 lines
8.9 KiB
Python
200 lines
8.9 KiB
Python
|
|
"""Regression: webui aligns LM Studio env var with the agent CLI (#1500).
|
||
|
|
|
||
|
|
Pre-#1500 the WebUI used `LMSTUDIO_API_KEY` everywhere — onboarding wrote it,
|
||
|
|
Settings detection read it. The agent CLI runtime (hermes_cli/auth.py:182,
|
||
|
|
api_key_env_vars=("LM_API_KEY",)) reads `LM_API_KEY`. So a user who configured
|
||
|
|
auth on their LM Studio instance and entered the key in the WebUI got:
|
||
|
|
|
||
|
|
- Settings → Providers reporting has_key=True (because WebUI saw its own
|
||
|
|
LMSTUDIO_API_KEY)
|
||
|
|
- Agent runtime ignoring the key (because it reads LM_API_KEY)
|
||
|
|
- Chat falling back to LMSTUDIO_NOAUTH_PLACEHOLDER → 401 from the
|
||
|
|
auth-enabled LM Studio server
|
||
|
|
|
||
|
|
Masked in practice for the no-auth majority. Real bug for anyone with
|
||
|
|
auth enabled.
|
||
|
|
|
||
|
|
This file pins the post-#1500 contract:
|
||
|
|
|
||
|
|
1. Onboarding writes the canonical `LM_API_KEY` (NOT `LMSTUDIO_API_KEY`).
|
||
|
|
2. Settings detection reads the canonical first.
|
||
|
|
3. Settings detection ALSO reads the legacy `LMSTUDIO_API_KEY` as a
|
||
|
|
read-only alias, so users with the old name in their .env don't see
|
||
|
|
Settings flip to "no key" on upgrade.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import sys
|
||
|
|
import types
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import api.config as config
|
||
|
|
import api.profiles as profiles
|
||
|
|
|
||
|
|
|
||
|
|
def _install_fake_hermes_cli(monkeypatch):
|
||
|
|
"""Stub hermes_cli modules so tests are deterministic and offline."""
|
||
|
|
fake_pkg = types.ModuleType("hermes_cli")
|
||
|
|
fake_pkg.__path__ = []
|
||
|
|
fake_models = types.ModuleType("hermes_cli.models")
|
||
|
|
fake_models.list_available_providers = lambda: []
|
||
|
|
fake_models.provider_model_ids = lambda pid: []
|
||
|
|
fake_auth = types.ModuleType("hermes_cli.auth")
|
||
|
|
fake_auth.get_auth_status = lambda _pid: {}
|
||
|
|
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
|
||
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
|
||
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
|
||
|
|
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
|
||
|
|
monkeypatch.delitem(sys.modules, "agent", raising=False)
|
||
|
|
try:
|
||
|
|
from api.config import invalidate_models_cache
|
||
|
|
invalidate_models_cache()
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def _swap_in_test_config(extra_cfg):
|
||
|
|
old_cfg = dict(config.cfg)
|
||
|
|
old_mtime = config._cfg_mtime
|
||
|
|
config.cfg.clear()
|
||
|
|
config.cfg["model"] = {}
|
||
|
|
config.cfg.update(extra_cfg)
|
||
|
|
try:
|
||
|
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||
|
|
except Exception:
|
||
|
|
config._cfg_mtime = 0.0
|
||
|
|
|
||
|
|
def _restore():
|
||
|
|
config.cfg.clear()
|
||
|
|
config.cfg.update(old_cfg)
|
||
|
|
config._cfg_mtime = old_mtime
|
||
|
|
|
||
|
|
return _restore
|
||
|
|
|
||
|
|
|
||
|
|
class TestIssue1500EnvVarAlignment:
|
||
|
|
def test_onboarding_supported_provider_setup_uses_lm_api_key(self):
|
||
|
|
"""The wizard's lmstudio entry must declare the canonical env var name."""
|
||
|
|
from api.onboarding import _SUPPORTED_PROVIDER_SETUPS
|
||
|
|
assert "lmstudio" in _SUPPORTED_PROVIDER_SETUPS
|
||
|
|
meta = _SUPPORTED_PROVIDER_SETUPS["lmstudio"]
|
||
|
|
assert meta["env_var"] == "LM_API_KEY", (
|
||
|
|
f"Onboarding's lmstudio.env_var must be the canonical 'LM_API_KEY' "
|
||
|
|
f"(matching hermes_cli/auth.py:182 api_key_env_vars=('LM_API_KEY',)). "
|
||
|
|
f"Got {meta['env_var']!r}."
|
||
|
|
)
|
||
|
|
# Legacy alias preserved for read-only fallback.
|
||
|
|
aliases = list(meta.get("env_var_aliases") or [])
|
||
|
|
assert "LMSTUDIO_API_KEY" in aliases, (
|
||
|
|
f"Onboarding's lmstudio.env_var_aliases must include the legacy "
|
||
|
|
f"'LMSTUDIO_API_KEY' name so existing users' detection keeps "
|
||
|
|
f"working. Got aliases={aliases!r}."
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_onboarding_writes_canonical_name_only(self, monkeypatch, tmp_path):
|
||
|
|
"""`apply_onboarding_setup` must write LM_API_KEY (not LMSTUDIO_API_KEY)."""
|
||
|
|
_install_fake_hermes_cli(monkeypatch)
|
||
|
|
|
||
|
|
# Redirect every write target to the tmp_path so we don't touch the real
|
||
|
|
# ~/.hermes — pattern from webui-onboarding-provider-readiness skill.
|
||
|
|
from api import onboarding as ob
|
||
|
|
monkeypatch.setattr(ob, "_get_active_hermes_home", lambda: tmp_path)
|
||
|
|
cfg_path = tmp_path / "config.yaml"
|
||
|
|
monkeypatch.setattr(ob, "_get_config_path", lambda: cfg_path)
|
||
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||
|
|
monkeypatch.delenv("HERMES_WEBUI_SKIP_ONBOARDING", raising=False)
|
||
|
|
monkeypatch.delenv("LM_API_KEY", raising=False)
|
||
|
|
monkeypatch.delenv("LMSTUDIO_API_KEY", raising=False)
|
||
|
|
|
||
|
|
ob.apply_onboarding_setup({
|
||
|
|
"provider": "lmstudio",
|
||
|
|
"model": "qwen3-27b",
|
||
|
|
"base_url": "http://example.local:1234/v1",
|
||
|
|
"api_key": "fresh-canon",
|
||
|
|
})
|
||
|
|
|
||
|
|
env_path = tmp_path / ".env"
|
||
|
|
assert env_path.exists(), "onboarding must write .env"
|
||
|
|
env_text = env_path.read_text(encoding="utf-8")
|
||
|
|
|
||
|
|
assert "LM_API_KEY=" in env_text, (
|
||
|
|
f"Onboarding must write the canonical LM_API_KEY name. .env now reads:\n{env_text}"
|
||
|
|
)
|
||
|
|
assert "LMSTUDIO_API_KEY=" not in env_text, (
|
||
|
|
f"Onboarding must NOT write the legacy LMSTUDIO_API_KEY name "
|
||
|
|
f"(should only be canonical going forward). .env now reads:\n{env_text}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_legacy_lmstudio_env_var_still_detected(self, monkeypatch, tmp_path):
|
||
|
|
"""Pre-#1500 users with LMSTUDIO_API_KEY still see has_key=True after upgrade."""
|
||
|
|
_install_fake_hermes_cli(monkeypatch)
|
||
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||
|
|
monkeypatch.delenv("LM_API_KEY", raising=False)
|
||
|
|
monkeypatch.setenv("LMSTUDIO_API_KEY", "lm-studio-legacy")
|
||
|
|
|
||
|
|
restore = _swap_in_test_config({"model": {"provider": "lmstudio"}})
|
||
|
|
try:
|
||
|
|
from api.providers import get_providers
|
||
|
|
result = get_providers()
|
||
|
|
by_id = {p["id"]: p for p in result["providers"]}
|
||
|
|
assert by_id["lmstudio"]["has_key"] is True, (
|
||
|
|
"Pre-#1500 users with the legacy LMSTUDIO_API_KEY env var must "
|
||
|
|
"continue to see has_key=True after upgrade — that's the whole "
|
||
|
|
"point of the alias fallback in _PROVIDER_ENV_VAR_ALIASES."
|
||
|
|
)
|
||
|
|
assert by_id["lmstudio"]["key_source"] in {"env_file", "env_var"}, (
|
||
|
|
f"Legacy alias detection should report env_file / env_var as "
|
||
|
|
f"key_source (the key really IS in .env), got "
|
||
|
|
f"{by_id['lmstudio']['key_source']!r} — this is the post-#1500 "
|
||
|
|
f"key_source-via-alias path."
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
restore()
|
||
|
|
|
||
|
|
def test_canonical_takes_precedence_over_legacy(self, monkeypatch, tmp_path):
|
||
|
|
"""When both env vars are set, canonical wins (rare migration edge)."""
|
||
|
|
_install_fake_hermes_cli(monkeypatch)
|
||
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||
|
|
monkeypatch.setenv("LM_API_KEY", "canonical-wins")
|
||
|
|
monkeypatch.setenv("LMSTUDIO_API_KEY", "legacy-loses")
|
||
|
|
|
||
|
|
restore = _swap_in_test_config({"model": {"provider": "lmstudio"}})
|
||
|
|
try:
|
||
|
|
from api.providers import get_providers, _provider_has_key
|
||
|
|
assert _provider_has_key("lmstudio") is True
|
||
|
|
# Both lead to has_key=True; the contract is that canonical is
|
||
|
|
# checked first (so it's definitely returning True and not just
|
||
|
|
# falling through to the alias). We can't easily assert ordering
|
||
|
|
# from this layer, but the existence of both detection paths is
|
||
|
|
# captured by test_legacy_lmstudio_env_var_still_detected and
|
||
|
|
# test_lmstudio_has_key_true_when_env_var_set in the #1420 file.
|
||
|
|
result = get_providers()
|
||
|
|
by_id = {p["id"]: p for p in result["providers"]}
|
||
|
|
assert by_id["lmstudio"]["has_key"] is True
|
||
|
|
assert by_id["lmstudio"]["configurable"] is True
|
||
|
|
finally:
|
||
|
|
restore()
|
||
|
|
|
||
|
|
def test_provider_api_key_present_reads_aliases(self, monkeypatch, tmp_path):
|
||
|
|
"""`_provider_api_key_present` (onboarding-side) reads aliases too.
|
||
|
|
|
||
|
|
The onboarding readiness pipeline (_status_from_runtime → chat_ready)
|
||
|
|
relies on this function. If aliases aren't honored here, an upgrading
|
||
|
|
user gets a re-firing wizard even though their LM Studio is configured.
|
||
|
|
"""
|
||
|
|
from api.onboarding import _provider_api_key_present
|
||
|
|
cfg = {"model": {"provider": "lmstudio"}}
|
||
|
|
|
||
|
|
# Only the legacy name set in .env values — onboarding must still see it.
|
||
|
|
env_values = {"LMSTUDIO_API_KEY": "x"}
|
||
|
|
assert _provider_api_key_present("lmstudio", cfg, env_values) is True
|
||
|
|
|
||
|
|
# Only the canonical name set — also detected.
|
||
|
|
env_values = {"LM_API_KEY": "x"}
|
||
|
|
assert _provider_api_key_present("lmstudio", cfg, env_values) is True
|
||
|
|
|
||
|
|
# Neither set — not detected.
|
||
|
|
env_values = {"OPENAI_API_KEY": "x"}
|
||
|
|
assert _provider_api_key_present("lmstudio", cfg, env_values) is False
|