diff --git a/CHANGELOG.md b/CHANGELOG.md index b82dca14..2e1fde41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Hermes Web UI -- Changelog +## [Unreleased] + +### Fixed (1 PR) + +- **LM Studio shows in Settings → Providers when configured** (#1420, partial fix; reporters @chwps and @AdoneyGalvan) — after running the onboarding wizard with LM Studio selected, users saw the provider in the model picker and could chat normally, but Settings → Providers showed no LM Studio entry or marked it as `has_key=False / configurable=False` even when `LMSTUDIO_API_KEY` was already in `~/.hermes/.env`. Root cause: the `_PROVIDER_ENV_VAR` map in `api/providers.py` is missing an `lmstudio: "LMSTUDIO_API_KEY"` entry. That dict drives both `_provider_has_key()` (env-var-based key detection — falls through to `has_key=False / key_source=none` when the provider id isn't there) and `get_providers()` line 364 (`configurable = pid in _PROVIDER_ENV_VAR` — falls through to `False`, hiding the "Add API key" UI surface). Same bug shape as #1410 (Ollama Cloud / local Ollama env-var collision). **Fix:** add the single mapping. Unlike #1410's collision concern, `LMSTUDIO_API_KEY` is not shared with any other provider's runtime, so adding the mapping has no side effects. **Scope discipline:** issue #1420's broader thread surfaces a sibling bug (the onboarding wizard never probes the configured `/v1/models` endpoint before persisting — the wizard accepts unreachable URLs silently, with no model-list dropdown population). That sibling bug is being filed separately and is **not** addressed by this PR — adding a probe touches the wizard UX flow, has timeout / error-handling implications, and warrants its own design pass. 5 regression tests in `tests/test_issue1420_lmstudio_provider_env_var.py` pin: dict literally contains the mapping, env-var path flips `has_key=True` + `configurable=True` + `key_source` reflects env source, config.yaml `providers.lmstudio.api_key` fallback also flips `has_key=True`, no-key path still renders `configurable=True` (so the user has a UI surface to add a key), and `LMSTUDIO_API_KEY` doesn't cross-detect any sibling provider. 4 of 5 tests verified to fail (catching the bug) when the new map entry is reverted. (`api/providers.py`, `tests/test_issue1420_lmstudio_provider_env_var.py`) + ## [v0.50.272] — 2026-05-03 ### Fixed (3 PRs) diff --git a/api/providers.py b/api/providers.py index 424a7927..fdba0e30 100644 --- a/api/providers.py +++ b/api/providers.py @@ -53,6 +53,13 @@ _PROVIDER_ENV_VAR: dict[str, str] = { # via providers.ollama.api_key in config.yaml — that path remains supported # by _provider_has_key(). "ollama-cloud": "OLLAMA_API_KEY", + # NOTE: bare "lmstudio" maps to LMSTUDIO_API_KEY (no conflict — this env var is + # only consumed by the lmstudio runtime, not by ollama-cloud or any sibling). + # Without this entry, Settings → Providers would render LM Studio as + # has_key=False / configurable=False even when LMSTUDIO_API_KEY is set in + # ~/.hermes/.env (e.g. via a successful onboarding wizard run), and the user + # would have no UI surface to add or update the key. See #1420. + "lmstudio": "LMSTUDIO_API_KEY", "nvidia": "NVIDIA_API_KEY", } diff --git a/tests/test_issue1420_lmstudio_provider_env_var.py b/tests/test_issue1420_lmstudio_provider_env_var.py new file mode 100644 index 00000000..ac8c1321 --- /dev/null +++ b/tests/test_issue1420_lmstudio_provider_env_var.py @@ -0,0 +1,259 @@ +"""Regression: LM Studio missing from Settings → Providers after onboarding (#1420). + +The bug: users who completed the onboarding wizard with the LM Studio provider — +either via the "Open / self-hosted" category in the wizard, or by hand-editing +config.yaml + .env to point at an LM Studio instance — would see LM Studio +listed in the model picker and could chat just fine, but Settings → Providers +showed *no* LM Studio entry, or showed it with `has_key=False, configurable=False` +even when LMSTUDIO_API_KEY was already in `~/.hermes/.env`. + +Root cause (verified by reproduction in the original investigation, then by +the regression tests below): + +`api/providers.py:_PROVIDER_ENV_VAR` is the dict that maps each provider id +to the .env / os.environ key the WebUI should look for. It's used by: + + 1. `_provider_has_key(pid)` — does an env-var-based detection lookup, returns + False (and `key_source='none'`) if the provider id isn't in the dict. + 2. `get_providers()` line 364 — sets `configurable = pid in _PROVIDER_ENV_VAR` + so the UI knows whether to render the "Add API key" form for that provider. + +Without an `lmstudio: "LMSTUDIO_API_KEY"` entry, both checks miss: the env var +is invisible to the Settings panel, AND the UI hides the surface that would +let the user fix the situation by typing a new key. + +This is the same bug-shape as #1410 (Ollama Cloud / local Ollama env var +collision) and is fixed by the same kind of edit: add the missing mapping in +`_PROVIDER_ENV_VAR`. Unlike #1410, there's no collision concern for LM Studio +because LMSTUDIO_API_KEY isn't shared with any other provider's runtime. + +Reporters: @chwps, @AdoneyGalvan (#1420 thread). +""" + +import sys +import types + +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. + + Mirrors the helper in test_provider_management.py — kept inline here so + this regression test stays self-contained and survives refactors there. + """ + 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): + """Snapshot config.cfg, replace with a minimal test config; return restore-fn.""" + 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 TestIssue1420LMStudioProviderEnvVar: + """LM Studio's env var must be in `_PROVIDER_ENV_VAR` so Settings detects it. + + Three angles of the same fix: + 1. The dict literally contains the mapping (catches accidental removal). + 2. With `LMSTUDIO_API_KEY` in env, get_providers() reports has_key=True. + 3. Without env but with `providers.lmstudio.api_key` in config.yaml, + get_providers() also reports has_key=True (defense for users who + configured via config.yaml directly). + 4. LM Studio is rendered as `configurable=True` so the UI shows the + "Add API key" form when no key is configured. + """ + + def test_lmstudio_in_provider_env_var_dict(self): + """`_PROVIDER_ENV_VAR['lmstudio']` must equal `'LMSTUDIO_API_KEY'`.""" + from api.providers import _PROVIDER_ENV_VAR + assert "lmstudio" in _PROVIDER_ENV_VAR, ( + "_PROVIDER_ENV_VAR is missing the 'lmstudio' entry — Settings → " + "Providers will render LM Studio as has_key=False / " + "configurable=False even with LMSTUDIO_API_KEY set in .env. See #1420." + ) + assert _PROVIDER_ENV_VAR["lmstudio"] == "LMSTUDIO_API_KEY", ( + f"_PROVIDER_ENV_VAR['lmstudio'] = {_PROVIDER_ENV_VAR['lmstudio']!r}, " + f"expected 'LMSTUDIO_API_KEY'. The onboarding wizard writes the " + f"key to LMSTUDIO_API_KEY in .env (api/onboarding.py:73-80) and " + f"the runtime in hermes_cli reads it under that name; the Settings " + f"detection path must look at the same name." + ) + + def test_lmstudio_has_key_true_when_env_var_set(self, monkeypatch, tmp_path): + """LMSTUDIO_API_KEY in env should mark LM Studio configured in Settings.""" + _install_fake_hermes_cli(monkeypatch) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.setenv("LMSTUDIO_API_KEY", "lm-studio") + + 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 "lmstudio" in by_id, ( + "lmstudio should appear in the provider list " + "(it's in _PROVIDER_DISPLAY)" + ) + assert by_id["lmstudio"]["has_key"] is True, ( + "Settings → Providers must report LM Studio as has_key=True when " + "LMSTUDIO_API_KEY is set in .env / os.environ. Pre-fix, the " + "lmstudio entry was missing from _PROVIDER_ENV_VAR so the env-var " + "check was skipped entirely. See #1420." + ) + assert by_id["lmstudio"]["configurable"] is True, ( + "Settings → Providers must render LM Studio as configurable=True " + "(so the 'Add API key' UI surface is shown). Pre-fix, " + "configurable was False because lmstudio wasn't in " + "_PROVIDER_ENV_VAR." + ) + assert by_id["lmstudio"]["key_source"] in {"env_file", "env_var"}, ( + f"key_source should reflect that the key came from env, " + f"got {by_id['lmstudio']['key_source']!r}" + ) + finally: + restore() + + def test_lmstudio_has_key_true_via_config_yaml(self, monkeypatch, tmp_path): + """providers.lmstudio.api_key in config.yaml should also count. + + This is the fallback for users who ran onboarding before the fix + landed — their .env may have LMSTUDIO_API_KEY=... but the reload path + could have lost it on a profile switch. The config.yaml fallback in + `_provider_has_key` should still detect them. + """ + _install_fake_hermes_cli(monkeypatch) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("LMSTUDIO_API_KEY", raising=False) + + restore = _swap_in_test_config({ + "model": {"provider": "lmstudio"}, + "providers": {"lmstudio": {"api_key": "lm-studio"}}, + }) + 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, ( + "providers.lmstudio.api_key in config.yaml must also flip " + "has_key=True for the Settings panel." + ) + assert by_id["lmstudio"]["key_source"] == "config_yaml", ( + f"key_source should be 'config_yaml', got " + f"{by_id['lmstudio']['key_source']!r}" + ) + finally: + restore() + + def test_lmstudio_has_key_false_when_no_signal(self, monkeypatch, tmp_path): + """No env var, no config — has_key=False but card is still shown + configurable. + + Pre-fix this case ALSO showed has_key=False, so this test alone doesn't + catch the bug. It pins the interaction with the other tests: with the + fix, the card is `configurable=True` (so the user can add a key), which + is exactly what was missing pre-fix. + """ + _install_fake_hermes_cli(monkeypatch) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("LMSTUDIO_API_KEY", raising=False) + + 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 False + assert by_id["lmstudio"]["configurable"] is True, ( + "Even with no key configured, the LM Studio card must be " + "configurable=True so the user can add a key from the UI. " + "Pre-fix this was False — the user had no UI surface to " + "configure LM Studio after onboarding (#1420)." + ) + finally: + restore() + + def test_lmstudio_does_not_collide_with_other_providers(self, monkeypatch, tmp_path): + """LMSTUDIO_API_KEY must NOT cross-detect any other provider. + + Sibling-defense modeled on the #1410 (OLLAMA_API_KEY / Ollama Cloud / + local Ollama) test: confirm LM Studio's env var doesn't accidentally + mark any other provider as configured. LMSTUDIO_API_KEY isn't shared + with another provider's runtime, but pinning this prevents a future + edit that does share it from regressing. + """ + _install_fake_hermes_cli(monkeypatch) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + # Strip every other detection signal so LMSTUDIO_API_KEY is the only + # input — any other provider showing has_key=True must be a leak. + for var in ( + "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", + "GH_TOKEN", "GITHUB_TOKEN", "OLLAMA_API_KEY", "GOOGLE_API_KEY", + "GEMINI_API_KEY", "DEEPSEEK_API_KEY", "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", "MISTRAL_API_KEY", "XAI_API_KEY", + "GLM_API_KEY", "KIMI_API_KEY", "OPENCODE_ZEN_API_KEY", + "OPENCODE_GO_API_KEY", "NVIDIA_API_KEY", + ): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("LMSTUDIO_API_KEY", "lm-studio") + + 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, "lmstudio itself should be configured" + for pid, entry in by_id.items(): + if pid == "lmstudio": + continue + # OAuth providers may report has_key=True via the gh_token / + # auth fallback; that's not LMSTUDIO_API_KEY's fault. + if entry.get("is_oauth"): + continue + # custom_providers isn't relevant here (we set none in the cfg). + if pid.startswith("custom"): + continue + assert entry["has_key"] is False, ( + f"LMSTUDIO_API_KEY in env caused {pid!r} to flip " + f"has_key=True (cross-detection leak). Pre-fix this was " + f"never an issue because lmstudio wasn't in " + f"_PROVIDER_ENV_VAR at all; with the fix in place we have " + f"to verify the new entry doesn't bleed into a sibling." + ) + finally: + restore()