fix(providers): map lmstudio to LMSTUDIO_API_KEY in _PROVIDER_ENV_VAR (#1420)
After completing the onboarding wizard with the LM Studio provider, users
saw LM Studio in the model picker and could chat normally, but Settings →
Providers showed no LM Studio entry — or rendered it with has_key=False
and configurable=False even when LMSTUDIO_API_KEY was already in
~/.hermes/.env. There was no UI surface to add or update the key.
Root cause:
api/providers.py:_PROVIDER_ENV_VAR — the dict that maps each provider id
to its env-var name — is missing an "lmstudio: LMSTUDIO_API_KEY" entry.
That dict drives two things:
1. _provider_has_key(pid) — env-var-based key detection. Returns False
and sets key_source='none' if the pid isn't in the dict, regardless
of what's in .env or os.environ.
2. get_providers() line 364:
"configurable": not is_oauth and pid in _PROVIDER_ENV_VAR,
Without the entry, configurable=False, hiding the "Add API key"
form in the UI.
So with no map entry, an LM Studio user with a working LMSTUDIO_API_KEY
gets has_key=False (wrong) AND no UI to fix it (wrong-er).
Same bug shape as #1410 (Ollama Cloud / local Ollama env-var collision).
The #1410 fix dropped bare "ollama" from _PROVIDER_ENV_VAR because
OLLAMA_API_KEY was shared with ollama-cloud and the runtime semantics
made the local key detection ambiguous. LMSTUDIO_API_KEY has no such
collision — it's only consumed by the lmstudio runtime.
Verified via reproduction:
before fix: lmstudio.has_key=False, configurable=False, key_source='none'
after fix: lmstudio.has_key=True, configurable=True, key_source='env_file'
5 regression tests in tests/test_issue1420_lmstudio_provider_env_var.py:
1. _PROVIDER_ENV_VAR['lmstudio'] == 'LMSTUDIO_API_KEY'
2. LMSTUDIO_API_KEY in env → has_key=True + configurable=True
3. providers.lmstudio.api_key in config.yaml → has_key=True (fallback path)
4. No env, no config → has_key=False but configurable=True (UI fix surface)
5. LMSTUDIO_API_KEY doesn't cross-detect any other provider
Mutation-verified: reverting the map entry causes 4 of 5 tests to fail
with clear assertion messages naming the bug (the 5th — config.yaml
fallback — is independent of the env-var path and intentionally remains
green to pin that the existing path keeps working).
Scope discipline:
#1420's broader thread surfaces a sibling bug — the onboarding wizard
never probes the configured <base_url>/v1/models endpoint before
persisting (the wizard accepts unreachable URLs silently with no
model-list dropdown population). That bug is being filed separately
and is NOT addressed here. Adding a probe touches the wizard UX flow,
has timeout / error-handling implications, and warrants its own design
pass.
Closes #1420 (the "LM Studio missing from Settings" half — feature-
request half about provider catalog support is already shipped: LM
Studio has been a first-class provider in api/onboarding.py since long
before this issue).
Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
This commit is contained in:
1 parent
6c3ff3ff47
commit
d3c7ac182b
3 files changed
+272
No files matched your search
@@ -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 `<base_url>/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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user