feat(agent): add lmstudio integration

This commit is contained in:
Rugved Somwanshi 2026-04-25 12:30:55 -04:00 committed by kshitij
parent 7d4648461a
commit 214ca943ac
26 changed files with 1137 additions and 40 deletions

View file

@ -145,6 +145,7 @@ class TestProviderRegistry:
PROVIDER_ENV_VARS = (
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"LM_API_KEY", "LM_BASE_URL",
"GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
"KIMI_API_KEY", "KIMI_BASE_URL", "STEPFUN_API_KEY", "STEPFUN_BASE_URL",
"MINIMAX_API_KEY", "MINIMAX_CN_API_KEY",
@ -428,6 +429,29 @@ class TestResolveApiKeyProviderCredentials:
assert creds["base_url"] == "https://api.githubcopilot.com"
assert creds["source"] == "gh auth token"
def test_resolve_lmstudio_uses_token_and_base_url_from_env(self, monkeypatch):
monkeypatch.setenv("LM_API_KEY", "lm-token")
monkeypatch.setenv("LM_BASE_URL", "http://lmstudio.remote:4321/v1")
creds = resolve_api_key_provider_credentials("lmstudio")
assert creds["provider"] == "lmstudio"
assert creds["api_key"] == "lm-token"
assert creds["base_url"] == "http://lmstudio.remote:4321/v1"
def test_resolve_lmstudio_no_api_key_substitutes_placeholder(self, monkeypatch):
# No-auth LM Studio: when LM_API_KEY isn't set, runtime credentials
# carry a placeholder so gateway/TUI/cron paths see the local server
# as configured. get_api_key_provider_status still reports unconfigured.
monkeypatch.delenv("LM_API_KEY", raising=False)
monkeypatch.delenv("LM_BASE_URL", raising=False)
creds = resolve_api_key_provider_credentials("lmstudio")
assert creds["provider"] == "lmstudio"
assert creds["api_key"] == "dummy-lm-api-key"
assert creds["base_url"] == "http://127.0.0.1:1234/v1"
def test_try_gh_cli_token_uses_homebrew_path_when_not_on_path(self, monkeypatch):
monkeypatch.setattr("hermes_cli.copilot_auth.shutil.which", lambda command: None)
monkeypatch.setattr(

View file

@ -260,6 +260,33 @@ class TestProviderPersistsAfterModelSave:
assert model.get("default") == "minimax-m2.5"
assert model.get("api_mode") == "anthropic_messages"
def test_lmstudio_provider_saved_when_selected(self, config_home, monkeypatch):
from hermes_cli.config import load_config
from hermes_cli.main import _model_flow_api_key_provider
monkeypatch.setenv("LM_API_KEY", "lm-token")
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda models, current_model="": "publisher/model-a",
)
monkeypatch.setattr("hermes_cli.auth.deactivate_provider", lambda: None)
monkeypatch.setattr(
"hermes_cli.models.fetch_lmstudio_models",
lambda api_key=None, base_url=None, timeout=5.0: ["publisher/model-a"],
)
with patch("builtins.input", side_effect=[""]):
_model_flow_api_key_provider(load_config(), "lmstudio", "old-model")
import yaml
config = yaml.safe_load((config_home / "config.yaml").read_text()) or {}
model = config.get("model")
assert isinstance(model, dict)
assert model.get("provider") == "lmstudio"
assert model.get("base_url") == "http://127.0.0.1:1234/v1"
assert model.get("default") == "publisher/model-a"
class TestBaseUrlValidation:
"""Reject non-URL values in the base URL prompt (e.g. shell commands)."""

View file

@ -398,3 +398,84 @@ def test_list_authenticated_providers_total_models_reflects_grouped_count(monkey
assert group["total_models"] == 6
# All six models are preserved in the grouped row.
assert sorted(group["models"]) == sorted(f"model-{i}" for i in range(6))
def test_lmstudio_picker_probes_active_config_base_url(monkeypatch):
"""When `provider: lmstudio` is saved with a remote base_url and no
LM_BASE_URL env var, the picker must probe the saved base_url not
127.0.0.1. Regression: prior behavior always probed localhost, so users
with LM Studio on a lab box saw the wrong (or empty) model list.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.delenv("LM_BASE_URL", raising=False)
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
captured["api_key"] = api_key
return ["qwen/qwen3-coder-30b"]
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="lmstudio",
current_base_url="http://192.168.1.10:1234/v1",
current_model="qwen/qwen3-coder-30b",
)
assert captured["base_url"] == "http://192.168.1.10:1234/v1"
def test_lmstudio_picker_lm_base_url_env_wins_over_active_config(monkeypatch):
"""LM_BASE_URL env var must still take precedence over the saved
base_url so users can temporarily redirect the picker without editing
config.yaml.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1")
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
return []
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="lmstudio",
current_base_url="http://192.168.1.10:1234/v1",
)
assert captured["base_url"] == "http://override.local:9999/v1"
def test_lmstudio_picker_skips_probe_when_not_configured(monkeypatch):
"""If the user has never configured LM Studio (no LM_API_KEY / LM_BASE_URL
and not on lmstudio), the picker must not pay the localhost probe cost
just to discover LM Studio is unavailable.
"""
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
monkeypatch.delenv("LM_BASE_URL", raising=False)
monkeypatch.delenv("LM_API_KEY", raising=False)
captured: dict = {}
def _fake_fetch(api_key=None, base_url=None, timeout=5.0):
captured["base_url"] = base_url
return []
monkeypatch.setattr("hermes_cli.models.fetch_lmstudio_models", _fake_fetch)
list_authenticated_providers(
current_provider="openrouter",
current_base_url="https://openrouter.ai/api/v1",
)
assert "base_url" not in captured

View file

@ -1,6 +1,6 @@
"""Tests for provider-aware `/model` validation in hermes_cli.models."""
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from hermes_cli.models import (
azure_foundry_model_api_mode,
@ -8,6 +8,7 @@ from hermes_cli.models import (
fetch_github_model_catalog,
curated_models_for_provider,
fetch_api_models,
fetch_lmstudio_models,
github_model_reasoning_efforts,
normalize_copilot_model_id,
normalize_opencode_model_id,
@ -638,6 +639,110 @@ class TestValidateApiFallback:
assert "http://localhost:8000/v1/models" in result["message"]
assert "http://localhost:8000/v1" in result["message"]
def test_fetch_lmstudio_models_filters_embedding_type(self):
mock_resp = MagicMock()
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = False
mock_resp.read.return_value = (
b'{"models":['
b'{"key":"publisher/chat-model","id":"publisher/chat-model","type":"llm"},'
b'{"key":"publisher/embed-model","id":"publisher/embed-model","type":"embedding"}'
b']}'
)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=mock_resp):
models = fetch_lmstudio_models(base_url="http://localhost:1234/v1")
assert models == ["publisher/chat-model"]
def test_validate_lmstudio_rejects_embedding_models(self):
mock_resp = MagicMock()
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = False
mock_resp.read.return_value = (
b'{"models":['
b'{"key":"publisher/chat-model","id":"publisher/chat-model","type":"llm"},'
b'{"key":"publisher/embed-model","id":"publisher/embed-model","type":"embedding"}'
b']}'
)
with patch("hermes_cli.models.urllib.request.urlopen", return_value=mock_resp):
result = validate_requested_model(
"publisher/embed-model",
"lmstudio",
base_url="http://localhost:1234/v1",
)
assert result["accepted"] is False
assert result["recognized"] is False
assert "not found in LM Studio's model listing" in result["message"]
def test_fetch_lmstudio_models_raises_auth_error_on_401(self):
import urllib.error
from hermes_cli.auth import AuthError
import pytest
http_error = urllib.error.HTTPError(
url="http://localhost:1234/api/v1/models",
code=401,
msg="Unauthorized",
hdrs=None,
fp=None,
)
with patch("hermes_cli.models.urllib.request.urlopen", side_effect=http_error):
with pytest.raises(AuthError) as excinfo:
fetch_lmstudio_models(base_url="http://localhost:1234/v1")
assert excinfo.value.provider == "lmstudio"
assert excinfo.value.code == "auth_rejected"
assert "401" in str(excinfo.value)
def test_fetch_lmstudio_models_returns_empty_on_network_error(self):
with patch(
"hermes_cli.models.urllib.request.urlopen",
side_effect=ConnectionRefusedError(),
):
models = fetch_lmstudio_models(base_url="http://localhost:1234/v1")
assert models == []
def test_validate_lmstudio_distinguishes_auth_failure(self):
import urllib.error
http_error = urllib.error.HTTPError(
url="http://localhost:1234/api/v1/models",
code=401,
msg="Unauthorized",
hdrs=None,
fp=None,
)
with patch("hermes_cli.models.urllib.request.urlopen", side_effect=http_error):
result = validate_requested_model(
"publisher/chat-model",
"lmstudio",
base_url="http://localhost:1234/v1",
)
assert result["accepted"] is False
assert "401" in result["message"]
assert "LM_API_KEY" in result["message"]
def test_validate_lmstudio_distinguishes_unreachable(self):
with patch(
"hermes_cli.models.urllib.request.urlopen",
side_effect=ConnectionRefusedError(),
):
result = validate_requested_model(
"publisher/chat-model",
"lmstudio",
base_url="http://localhost:1234/v1",
)
assert result["accepted"] is False
assert "Could not reach LM Studio" in result["message"]
# -- validate — Codex auto-correction ------------------------------------------

View file

@ -240,6 +240,110 @@ def test_resolve_runtime_provider_ai_gateway(monkeypatch):
assert resolved["requested_provider"] == "ai-gateway"
def test_resolve_runtime_provider_lmstudio_uses_token_when_present(monkeypatch):
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "lmstudio",
"base_url": "http://127.0.0.1:1234/v1",
"default": "publisher/model-a",
},
)
monkeypatch.setattr(
rp,
"load_pool",
lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
)
monkeypatch.setattr(
rp,
"resolve_api_key_provider_credentials",
lambda provider: {
"provider": "lmstudio",
"api_key": "lm-token",
"base_url": "http://127.0.0.1:1234/v1",
"source": "LM_API_KEY",
},
)
resolved = rp.resolve_runtime_provider(requested="lmstudio")
assert resolved["provider"] == "lmstudio"
assert resolved["api_key"] == "lm-token"
assert resolved["api_mode"] == "chat_completions"
assert resolved["base_url"] == "http://127.0.0.1:1234/v1"
def test_resolve_runtime_provider_lmstudio_honors_saved_base_url(monkeypatch):
"""Pre-existing configs with `provider: lmstudio` + custom base_url must keep working.
Before this PR, `lmstudio` aliased to `custom`, so a user with a remote
LM Studio (e.g. lab box) could write `provider: "lmstudio"` plus
`base_url: "http://192.168.1.10:1234/v1"` and the custom path honored it.
Now that `lmstudio` is first-class with `inference_base_url=127.0.0.1`,
the saved `base_url` from `model_cfg` must still win otherwise this
PR is a silent breaking change for those users.
"""
monkeypatch.delenv("LM_API_KEY", raising=False)
monkeypatch.delenv("LM_BASE_URL", raising=False)
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "lmstudio",
"base_url": "http://192.168.1.10:1234/v1",
"default": "qwen/qwen3-coder-30b",
},
)
monkeypatch.setattr(
rp,
"load_pool",
lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
)
# Don't mock resolve_api_key_provider_credentials — exercise the real
# function so we test the end-to-end precedence between model_cfg and
# the pconfig default.
resolved = rp.resolve_runtime_provider(requested="lmstudio")
assert resolved["provider"] == "lmstudio"
assert resolved["api_mode"] == "chat_completions"
# The saved base_url must NOT be shadowed by the 127.0.0.1 default.
assert resolved["base_url"] == "http://192.168.1.10:1234/v1"
# No-auth LM Studio: missing LM_API_KEY substitutes the placeholder.
assert resolved["api_key"] == "dummy-lm-api-key"
def test_resolve_runtime_provider_lmstudio_base_url_env_wins_over_saved_base_url(monkeypatch):
"""LM_BASE_URL should override the saved lmstudio base_url for temporary redirects."""
monkeypatch.delenv("LM_API_KEY", raising=False)
monkeypatch.setenv("LM_BASE_URL", "http://override.local:9999/v1")
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "lmstudio")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {
"provider": "lmstudio",
"base_url": "http://192.168.1.10:1234/v1",
"default": "qwen/qwen3-coder-30b",
},
)
monkeypatch.setattr(
rp,
"load_pool",
lambda provider: type("Pool", (), {"has_credentials": lambda self: False})(),
)
resolved = rp.resolve_runtime_provider(requested="lmstudio")
assert resolved["provider"] == "lmstudio"
assert resolved["api_mode"] == "chat_completions"
assert resolved["base_url"] == "http://override.local:9999/v1"
assert resolved["api_key"] == "dummy-lm-api-key"
def test_resolve_runtime_provider_ai_gateway_explicit_override_skips_pool(monkeypatch):
def _unexpected_pool(provider):
raise AssertionError(f"load_pool should not be called for {provider}")
@ -1237,6 +1341,21 @@ def test_resolve_provider_openrouter_unchanged():
assert resolve_provider("openrouter") == "openrouter"
def test_resolve_provider_lmstudio_returns_lmstudio(monkeypatch):
"""resolve_provider('lmstudio') must return 'lmstudio', not 'custom'.
Regression for the alias-map bug where 'lmstudio' was rewritten to
'custom' before the PROVIDER_REGISTRY lookup, bypassing the first-class
LM Studio provider entirely at runtime.
"""
from hermes_cli.auth import resolve_provider
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
assert resolve_provider("lmstudio") == "lmstudio"
assert resolve_provider("lm-studio") == "lmstudio"
assert resolve_provider("lm_studio") == "lmstudio"
def test_custom_provider_runtime_preserves_provider_name(monkeypatch):
"""resolve_runtime_provider with provider='custom' must return provider='custom'."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)

View file

@ -122,3 +122,34 @@ def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(mo
out = capsys.readouterr().out
assert "Nous Tool Gateway" not in out
def test_show_status_reports_empty_lmstudio_listing_as_reachable(monkeypatch, capsys, tmp_path):
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
monkeypatch.setattr(
status_mod,
"load_config",
lambda: {
"model": {
"default": "qwen/qwen3-coder-30b",
"provider": "lmstudio",
"base_url": "http://127.0.0.1:1234/v1",
}
},
raising=False,
)
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "lmstudio", raising=False)
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "lmstudio", raising=False)
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "LM Studio", raising=False)
monkeypatch.setattr(
"hermes_cli.models.probe_lmstudio_models",
lambda api_key=None, base_url=None, timeout=5.0: [],
)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "LM Studio" in out
assert "reachable (0 model(s)) at http://127.0.0.1:1234/v1" in out