HERMES-GIELIE/tests/test_issue1420_lmstudio_provider_env_var.py
Hermes Bot 8616033605 fix(onboarding,providers): probe LM Studio /models + align env var with agent CLI (#1499 #1500)
Addresses both #1499 (onboarding wizard never probes the configured base URL)
and #1500 (cross-tool env-var name divergence between webui and agent CLI).
Surfaced together because they're both LM-Studio onboarding bugs that pile
on top of each other — fixing only one leaves the broken UX.

#1499 — Onboarding wizard probes <base_url>/models before persisting

Pre-fix, `apply_onboarding_setup` accepted whatever `base_url` the user typed
without ever fetching `<base_url>/models`. @chwps's log timeline in #1420
showed the wizard finishing in 239ms with zero outbound HTTP — onboarding
silently persisted unreachable URLs and left users with empty model
dropdowns they had to populate by hand-editing config.yaml.

Backend:
* New `probe_provider_endpoint(provider, base_url, api_key, timeout=5.0)`
  in `api/onboarding.py`. Stdlib-only (urllib + socket — no httpx dep).
  Returns `{ok, models}` on success; `{ok: False, error: <code>, detail}`
  on failure with stable error codes the frontend can switch on:
  invalid_url, dns, connect_refused, timeout, http_4xx, http_5xx, parse,
  unreachable. 256 KB response cap and 5s timeout keep a hostile or mis-
  pointed endpoint from blocking the wizard.
* New `POST /api/onboarding/probe` route — thin JSON wrapper around the
  function above. Same local-network gate as `/api/onboarding/setup`
  because the body carries an `api_key` the user typed.
* The probe response is NEVER persisted. Only the user's typed selection
  ends up in config.yaml; the probed model list just populates the
  wizard's dropdown.
* SSRF: deliberately does NOT block private-IP ranges. The wizard is
  gated behind WebUI auth and the legitimate target IS a local LM Studio
  / Ollama / vLLM server. A "block private IPs" SSRF defense would make
  the feature useless for its primary use case.

Frontend:
* `static/onboarding.js`:
  - New `ONBOARDING.probe` state ({status, error, detail, models, probedKey}).
  - `_runOnboardingProbe()` — POSTs to /api/onboarding/probe, idempotent
    & cached on (provider, baseUrl, apiKey).
  - Debounced (400ms) on `oninput` of the base URL field.
  - Explicit "Test connection" button.
  - `nextOnboardingStep` blocks Continue at the setup step for any
    provider with `requires_base_url=True` until the probe succeeds.
    Same localized error renders inline.
* `static/i18n.js`: 13 new keys × 9 locales (canonical English in `en`,
  English fallback with `// TODO: translate` markers in the other 8 —
  same convention as v0.50.271 #1488 voice-buttons).
* `static/style.css`: probe banner + Test button styling (red-tinted
  error variant, green-tinted success variant, neutral probing state).

Verified via manual repro on port 8789:
* connect_refused → red banner, helpful "from Docker, try the host IP"
  hint, blocks Continue.
* DNS failure → red banner, "could not resolve host '...'", blocks Continue.
* Success against a mock /v1/models server → green banner, model dropdown
  populates from the probed list, Continue advances normally.

#1500 — webui env var aligned with agent CLI (LM_API_KEY)

The webui has long used `LMSTUDIO_API_KEY` for LM Studio's API key in
both onboarding and Settings detection. The agent CLI runtime
(hermes_cli/auth.py:177-183) reads `LM_API_KEY`. So a user who configured
auth on their LM Studio instance got Settings → Providers reporting
has_key=True (because webui saw its own LMSTUDIO_API_KEY) but the agent
runtime ignored the key and fell back to LMSTUDIO_NOAUTH_PLACEHOLDER →
401 against the auth-enabled LM Studio server. Masked in practice for
the no-auth majority.

Picked Option B from the issue (defer to the agent — single source of
truth) but mitigated the migration cliff by reading the legacy name as
a fallback:

* `api/onboarding.py:_SUPPORTED_PROVIDER_SETUPS["lmstudio"]`:
  - `env_var: "LM_API_KEY"` (canonical, what onboarding writes going forward).
  - `env_var_aliases: ["LMSTUDIO_API_KEY"]` (read-only fallback for
    pre-#1500 users so detection keeps working without forcing an
    .env rewrite).
* `api/onboarding.py:_provider_api_key_present` reads aliases too.
* `api/providers.py:_PROVIDER_ENV_VAR["lmstudio"] = "LM_API_KEY"`.
* `api/providers.py:_PROVIDER_ENV_VAR_ALIASES["lmstudio"] = ("LMSTUDIO_API_KEY",)`
  — new dict, used by `_provider_has_key` and `get_providers`'s
  key_source resolution. Drops in cleanly when other providers later
  rename their env vars too.

Verified:

```
before fix:  webui writes LMSTUDIO_API_KEY → agent ignores it → 401 on chat
 after fix:  webui writes LM_API_KEY → agent picks it up → chat works
             pre-#1500 .env with LMSTUDIO_API_KEY → still has_key=True in Settings
                                                  → key_source='env_file'
```

Tests

* `tests/test_issue1499_onboarding_probe.py` — 17 tests:
  3 invalid_url variants, dns, connect_refused, success (OpenAI shape),
  success (bare-list shape), http_4xx, http_5xx, parse non-JSON, parse
  wrong-shape, api_key authorization header passthrough, "probe must
  not write to config.yaml or .env", PROBE_ERROR_CODES contract pin,
  3 end-to-end route-level smoke tests against the live server fixture.
* `tests/test_issue1500_lmstudio_env_var_alignment.py` — 5 tests:
  onboarding declares LM_API_KEY canonical with LMSTUDIO_API_KEY alias,
  onboarding writes ONLY the canonical name, legacy env var still
  detected post-migration, canonical takes precedence when both are
  set, _provider_api_key_present reads aliases.
* `tests/test_issue1420_lmstudio_provider_env_var.py` — updated:
  the original 5-test #1420 suite now pins LM_API_KEY as canonical
  and LMSTUDIO_API_KEY as alias.

Full suite: 3879 → 3901 passing (+22), 0 failures.

Out of scope (explicitly NOT addressed here)

The third LM Studio onboarding sub-bug from #1420's thread — that
`apply_onboarding_setup` requires a non-empty api_key for lmstudio
even though most LM Studio installs run keyless — remains. The agent's
`LMSTUDIO_NOAUTH_PLACEHOLDER` substitution kicks in at runtime, but
the onboarding wizard rejects the empty-key case at submit. Fixing
this requires a UX decision (auto-write a sentinel? loosen the
required-key check for self-hosted providers?) and is left as a
separate follow-up.

Closes #1499
Closes #1500

Co-authored-by: chwps <106549456+chwps@users.noreply.github.com>
Co-authored-by: AdoneyGalvan <25235323+AdoneyGalvan@users.noreply.github.com>
2026-05-03 02:46:24 +00:00

280 lines
13 KiB
Python

"""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 `'LM_API_KEY'` (canonical, agent-aligned).
The original #1420 fix used `'LMSTUDIO_API_KEY'`. After #1500 (cross-tool
env-var alignment with the agent CLI) the canonical name is `LM_API_KEY`,
and `LMSTUDIO_API_KEY` is preserved as a read-only legacy alias in
`_PROVIDER_ENV_VAR_ALIASES` so existing users don't lose detection.
"""
from api.providers import _PROVIDER_ENV_VAR, _PROVIDER_ENV_VAR_ALIASES
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. See #1420."
)
assert _PROVIDER_ENV_VAR["lmstudio"] == "LM_API_KEY", (
f"_PROVIDER_ENV_VAR['lmstudio'] = {_PROVIDER_ENV_VAR['lmstudio']!r}, "
f"expected 'LM_API_KEY' to match the agent CLI's "
f"hermes_cli/auth.py:lmstudio.api_key_env_vars. See #1500."
)
# The legacy alias must still be registered so users with the pre-#1500
# env var don't lose detection on upgrade.
assert "lmstudio" in _PROVIDER_ENV_VAR_ALIASES, (
"_PROVIDER_ENV_VAR_ALIASES['lmstudio'] missing — pre-#1500 users "
"with LMSTUDIO_API_KEY in their .env will see Settings flip to "
"'no key' on upgrade. Keep the alias for at least a few releases."
)
assert "LMSTUDIO_API_KEY" in _PROVIDER_ENV_VAR_ALIASES["lmstudio"], (
f"Expected 'LMSTUDIO_API_KEY' as a legacy alias for lmstudio, got "
f"{_PROVIDER_ENV_VAR_ALIASES['lmstudio']!r}."
)
def test_lmstudio_has_key_true_when_env_var_set(self, monkeypatch, tmp_path):
"""`LM_API_KEY` in env should mark LM Studio configured in Settings (canonical, post-#1500)."""
_install_fake_hermes_cli(monkeypatch)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
monkeypatch.delenv("LMSTUDIO_API_KEY", raising=False)
monkeypatch.delenv("LM_API_KEY", raising=False)
monkeypatch.setenv("LM_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)
monkeypatch.delenv("LM_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)
monkeypatch.delenv("LM_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", "LMSTUDIO_API_KEY",
):
monkeypatch.delenv(var, raising=False)
# Set the canonical post-#1500 env var; sibling providers must not
# cross-detect.
monkeypatch.setenv("LM_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"LM_API_KEY in env caused {pid!r} to flip "
f"has_key=True (cross-detection leak). LM_API_KEY is "
f"unique to lmstudio in _PROVIDER_ENV_VAR; this test "
f"future-proofs against a regression that adds it to "
f"a sibling."
)
finally:
restore()