Files
hermes-webui/tests/test_issue1527_lmstudio_base_url_classification.py
e3a7c93dc6 [HELD — independent review pending] Release v0.51.294 — stage-3401 (live-to-final redesign #3401 + 4 deep-review fixes) (#3741)
* Harden interrupted recovery control filtering

* Redesign live-to-final assistant replies

* Fix live activity anchor test fixture

* Fix CI lint issues for live reply tests

* Strengthen live progress prompt contract

* Recover PR #3401 refresh on origin/master

* Repair live-to-final refresh regressions

* Fix live worklog refresh regressions

* Show live footer timer on initial stream start

* Restore live stream shell after reload

* Preserve per-frame live SSE replay cursors

* Preserve reasoning as Worklog Thinking cards

* Quiet Worklog Thinking card styling

* Align Worklog Thinking card styling

* Scope live Worklog Thinking cards by segment

* Suppress exact duplicate settled Thinking

* Close #3401 merge review test gaps

* fix(#3401): resolve 4 deep-review regressions (inline-think, reconnect-dup, neon skin, busy-gate worklog)

Deep review (Codex diff-vs-master + live-browser drive) of the live-to-final refactor
surfaced 4 regressions vs master that the rewritten suite no longer guarded:

1. Inline <think>…</think>answer reasoning vanished — _assistantReasoningPayloadText
   used $-anchored regexes so a leading think block + visible answer extracted nothing
   and the Thinking card never rendered. Removed the 3 $ anchors to match the
   (non-anchored) display stripper. Live: inline-think thinking-only turn now renders.
2. (CORE) reconnect/reload duplicated the live reply — _rememberRunJournalCursor advanced
   a closure-local seq but never wrote INFLIGHT[activeSid].lastRunJournalSeq, so a reload
   replayed the journal from after_seq=0 over restored lastAssistantText. Now mirrors the
   cursor onto INFLIGHT + schedules a throttled persist.
3. Neon skin silently broke — PR deleted the :root[data-skin="neon"] CSS but left Neon in
   the picker. Restored the neon CSS block from master.
4. Settled tool-worklog rebuild gated purely on !S.busy — dropped every prior settled
   turn's worklog when renderMessages re-ran during an active stream (switch-back to an
   in-progress session). Restored master's !S.busy || (S.toolCalls && S.toolCalls.length).
   Live: busy re-render now preserves tool cards (4→4, was 4→0).

Live-verified all 4 + confirmed #3709/#3592 invariants still hold (1 thinking card, none
below footer; distinct siblings preserved). + tests/test_issue3401_deep_review_fixes.py (7).

* test(#3401): realign 3 stale source-shape assertions to the deep-review fixes

Fix commit changed two source literals that existing stage tests scanned for:
- test_live_activity_timeline.py (x2): split anchor 'if(!S.busy){' → the restored
  'if(!S.busy || (S.toolCalls&&S.toolCalls.length)){' guard (fix 4).
- test_run_journal_frontend_static.py: 'after_seq=0' not in source — fix 2's comment
  contained that literal; rephrased the comment to 'the zero floor (after_seq of 0)'.
Intent of all three assertions unchanged; only the matched string updated. No code
behavior change.

* docs(changelog): v0.51.294 — Release JJ (stage-3401, #3401 live-to-final redesign)

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: Nathan-Hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena-hermes <[email protected]>
2026-06-06 12:12:37 -07:00

189 lines
4.8 KiB
Python

"""Regression tests for #1527/#1530 LM Studio base_url ownership.
When a local OpenAI-compatible endpoint is configured as LM Studio, model
discovery must trust the configured provider before guessing from the URL host.
LAN IPs, Tailscale names, and reverse proxies do not contain "lmstudio" in the
hostname, but the config block already says which provider owns that base_url.
"""
from __future__ import annotations
import json
import socket
import urllib.request
import pytest
import api.config as config
import api.profiles as profiles
_API_KEY_ENV_VARS = (
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"GOOGLE_API_KEY",
"GEMINI_API_KEY",
"GLM_API_KEY",
"KIMI_API_KEY",
"DEEPSEEK_API_KEY",
"OPENCODE_ZEN_API_KEY",
"OPENCODE_GO_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY",
"XAI_API_KEY",
"MISTRAL_API_KEY",
"LM_API_KEY",
"LMSTUDIO_API_KEY",
"OLLAMA_API_KEY",
"LOCAL_API_KEY",
"API_KEY",
)
class _ModelsResponse:
def __init__(self, model_ids: list[str]):
self._model_ids = model_ids
def __enter__(self):
return self
def __exit__(self, *_args):
return None
def read(self) -> bytes:
return json.dumps({"data": [{"id": mid} for mid in self._model_ids]}).encode()
@pytest.fixture(autouse=True)
def _isolate_config(monkeypatch, tmp_path):
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
for var in _API_KEY_ENV_VARS:
monkeypatch.delenv(var, raising=False)
config.invalidate_models_cache()
yield
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config.invalidate_models_cache()
def _write_config(tmp_path, monkeypatch, text: str) -> None:
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(text, encoding="utf-8")
monkeypatch.setattr(config, "_get_config_path", lambda: cfgfile)
config.reload_config()
config.invalidate_models_cache()
def _mock_model_discovery(monkeypatch, model_ids: list[str], resolved_ip: str) -> None:
monkeypatch.setattr(
urllib.request,
"urlopen",
lambda *_args, **_kwargs: _ModelsResponse(model_ids),
)
monkeypatch.setattr(
socket,
"getaddrinfo",
lambda *_args, **_kwargs: [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", (resolved_ip, 0))
],
)
def _groups_by_id() -> dict[str, dict]:
return {
group["provider_id"]: group
for group in config.get_available_models()["groups"]
}
@pytest.mark.parametrize(
("base_url", "resolved_ip"),
[
("http://192.168.1.22:1234/v1", "192.168.1.22"),
("http://my-mac.tailnet.example:1234/v1", "192.168.1.22"),
("https://lm.internal.example.com/v1", "192.168.1.22"),
],
)
def test_lmstudio_configured_base_url_keeps_discovered_models(
tmp_path,
monkeypatch,
base_url: str,
resolved_ip: str,
):
_write_config(
tmp_path,
monkeypatch,
f"""
model:
provider: lmstudio
default: qwen3.6-35b-a3b@q6_k
base_url: {base_url}
providers:
lmstudio:
api_key: local-key
""",
)
_mock_model_discovery(
monkeypatch,
["qwen3.6-35b-a3b@q6_k", "second-lmstudio-model"],
resolved_ip,
)
groups = _groups_by_id()
assert "custom" not in groups
assert "lmstudio" in groups
model_ids = {model["id"] for model in groups["lmstudio"]["models"]}
bare_model_ids = {mid.removeprefix("@lmstudio:") for mid in model_ids}
assert {"qwen3.6-35b-a3b@q6_k", "second-lmstudio-model"} <= bare_model_ids
def test_custom_configured_base_url_is_not_reclassified_as_ollama(tmp_path, monkeypatch):
_write_config(
tmp_path,
monkeypatch,
"""
model:
provider: custom
default: custom-model
base_url: http://localhost:4000/v1
providers:
custom:
api_key: local-key
""",
)
_mock_model_discovery(monkeypatch, ["custom-model", "custom-extra"], "127.0.0.1")
groups = _groups_by_id()
assert "ollama" not in groups
assert "custom" in groups
model_ids = {model["id"] for model in groups["custom"]["models"]}
assert {"custom-model", "custom-extra"} <= model_ids
def test_lmstudio_session_model_resolves_to_configured_base_url(tmp_path, monkeypatch):
_write_config(
tmp_path,
monkeypatch,
"""
model:
provider: lmstudio
default: qwen3.6-35b-a3b@q6_k
base_url: http://192.168.1.22:1234/v1
providers:
lmstudio:
api_key: local-key
""",
)
model, provider, base_url = config.resolve_model_provider(
"qwen3.6-35b-a3b@q6_k"
)
assert model == "qwen3.6-35b-a3b@q6_k"
assert provider == "lmstudio"
assert base_url == "http://192.168.1.22:1234/v1"