Files
hermes-webui/tests/test_issue1500_lmstudio_env_var_alignment.py
T
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

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