mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 01:41:43 +00:00
feat(agent): add lmstudio integration
This commit is contained in:
parent
7d4648461a
commit
214ca943ac
26 changed files with 1137 additions and 40 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue