fix: follow-up for salvaged PR #17061

- Remove dead _lmstudio_loaded_context attribute from run_agent.py (set
  but never read — the loaded context is pushed to context_compressor.update_model
  which is the actual consumer)
- Cache empty reasoning options with 60s TTL to avoid per-turn HTTP probe
  for non-reasoning LM Studio models. Non-empty results cached permanently.
- Extract _lmstudio_server_root(), _lmstudio_request_headers(), and
  _lmstudio_fetch_raw_models() shared helpers in models.py — eliminates
  URL-strip + auth-header + HTTP-call duplication across probe_lmstudio_models,
  ensure_lmstudio_model_loaded, and lmstudio_model_reasoning_options
- Revert runtime_provider.py base_url precedence change: preserve the
  established contract (saved config.base_url > env var > default) for all
  api_key providers
- Remove unnecessary config version bump 22→23
- Fix TUI test: relax target_model assertion to avoid module-cache flake
- AUTHOR_MAP: added rugved@lmstudio.ai → rugvedS07
This commit is contained in:
kshitijk4poor
2026-04-28 23:27:50 +05:30
committed by kshitij
parent 433d38da09
commit 5d2f9b5d7d
7 changed files with 92 additions and 102 deletions

View File

@@ -1123,7 +1123,7 @@ DEFAULT_CONFIG = {
},
# Config schema version - bump this when adding new required fields
"_config_version": 23,
"_config_version": 22,
}
# =============================================================================
@@ -3123,28 +3123,6 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
"Use `hermes plugins enable <name>` to activate."
)
# ── Version 22 → 23: ensure LM_API_KEY is set when provider is lmstudio ──
# LM Studio's documented default is no-auth, but our API-key registry
# path needs *some* non-empty value to satisfy auxiliary_client and
# runtime resolution. Self-heal users whose config.yaml has
# provider:lmstudio but no LM_API_KEY in .env (cross-machine sync,
# manual edit, profile move).
if current_ver < 23:
try:
from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
config = load_config()
model_cfg = config.get("model")
if isinstance(model_cfg, dict) and str(model_cfg.get("provider") or "").strip().lower() == "lmstudio":
if not get_env_value("LM_API_KEY"):
save_env_value("LM_API_KEY", LMSTUDIO_NOAUTH_PLACEHOLDER)
results["env_added"].append(
f"LM_API_KEY={LMSTUDIO_NOAUTH_PLACEHOLDER} (placeholder for no-auth LM Studio)"
)
if not quiet:
print(" ✓ Added placeholder LM_API_KEY for LM Studio (no-auth default)")
except Exception:
pass
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")

View File

@@ -2199,31 +2199,41 @@ def _is_github_models_base_url(base_url: Optional[str]) -> bool:
)
def probe_lmstudio_models(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: float = 5.0,
) -> Optional[list[str]]:
"""Probe LM Studio's model listing.
def _lmstudio_server_root(base_url: Optional[str]) -> Optional[str]:
"""Strip ``/v1`` suffix from an LM Studio base URL to get the native API root.
Returns chat-capable model keys on success, including the valid empty-list
case when the server is reachable but has no non-embedding models.
Returns ``None`` on network errors, malformed responses, or empty/invalid
base URLs.
Raises ``AuthError`` on HTTP 401/403 so callers can surface token issues
separately from reachability problems.
Returns ``None`` when the base URL is empty/invalid.
"""
server_root = (base_url or "").strip().rstrip("/")
if server_root.endswith("/v1"):
server_root = server_root[:-3].rstrip("/")
if not server_root:
return None
root = (base_url or "").strip().rstrip("/")
if root.endswith("/v1"):
root = root[:-3].rstrip("/")
return root or None
def _lmstudio_request_headers(api_key: Optional[str] = None) -> dict:
"""Build HTTP headers for LM Studio native API requests."""
headers = {"User-Agent": _HERMES_USER_AGENT}
token = str(api_key or "").strip()
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _lmstudio_fetch_raw_models(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: float = 5.0,
) -> Optional[list[dict]]:
"""Fetch the raw model list from LM Studio's ``/api/v1/models``.
Returns the ``models`` list of dicts on success, ``None`` on network
errors or malformed responses. Raises ``AuthError`` on HTTP 401/403.
"""
server_root = _lmstudio_server_root(base_url)
if not server_root:
return None
headers = _lmstudio_request_headers(api_key)
request = urllib.request.Request(server_root + "/api/v1/models", headers=headers)
try:
with urllib.request.urlopen(request, timeout=timeout) as resp:
@@ -2256,6 +2266,27 @@ def probe_lmstudio_models(
server_root,
)
return None
return raw_models
def probe_lmstudio_models(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: float = 5.0,
) -> Optional[list[str]]:
"""Probe LM Studio's model listing.
Returns chat-capable model keys on success, including the valid empty-list
case when the server is reachable but has no non-embedding models.
Returns ``None`` on network errors, malformed responses, or empty/invalid
base URLs.
Raises ``AuthError`` on HTTP 401/403 so callers can surface token issues
separately from reachability problems.
"""
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout)
if raw_models is None:
return None
keys: list[str] = []
for raw in raw_models:
@@ -2302,28 +2333,17 @@ def ensure_lmstudio_model_loaded(
at the model's ``max_context_length``. Returns the resolved loaded context
length, or ``None`` when the probe / load failed.
"""
server_root = (base_url or "").strip().rstrip("/")
if server_root.endswith("/v1"):
server_root = server_root[:-3].rstrip("/")
server_root = _lmstudio_server_root(base_url)
if not server_root:
return None
headers = {"User-Agent": _HERMES_USER_AGENT}
token = str(api_key or "").strip()
if token:
headers["Authorization"] = f"Bearer {token}"
headers = _lmstudio_request_headers(api_key)
try:
with urllib.request.urlopen(
urllib.request.Request(server_root + "/api/v1/models", headers=headers),
timeout=10,
) as resp:
payload = json.loads(resp.read().decode())
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=10)
except Exception:
return None
raw_models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(raw_models, list):
raw_models = None
if raw_models is None:
return None
target_entry = None
@@ -2380,28 +2400,11 @@ def lmstudio_model_reasoning_options(
Returns ``[]`` when the model is unknown, the endpoint is unreachable,
or the model does not declare a reasoning capability.
"""
server_root = (base_url or "").strip().rstrip("/")
if server_root.endswith("/v1"):
server_root = server_root[:-3].rstrip("/")
if not server_root:
return []
headers = {"User-Agent": _HERMES_USER_AGENT}
token = str(api_key or "").strip()
if token:
headers["Authorization"] = f"Bearer {token}"
try:
with urllib.request.urlopen(
urllib.request.Request(server_root + "/api/v1/models", headers=headers),
timeout=timeout,
) as resp:
payload = json.loads(resp.read().decode())
raw_models = _lmstudio_fetch_raw_models(api_key=api_key, base_url=base_url, timeout=timeout)
except Exception:
return []
raw_models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(raw_models, list):
raw_models = None
if not raw_models:
return []
for raw in raw_models:

View File

@@ -1245,20 +1245,14 @@ def resolve_runtime_provider(
if pconfig and pconfig.auth_type == "api_key":
creds = resolve_api_key_provider_credentials(provider)
# Honour model.base_url from config.yaml when the configured provider
# matches this provider, unless the provider-specific BASE_URL env var
# is set. That keeps temporary env overrides (e.g. LM_BASE_URL) in sync
# with picker-time probing while still preserving saved config URLs when
# no override is present.
# matches this provider — mirrors the Anthropic path above. Without
# this, users who set model.base_url to e.g. api.minimaxi.com/anthropic
# (China endpoint) still get the hardcoded api.minimax.io default (#6039).
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
cfg_base_url = ""
if cfg_provider == provider:
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
env_base_url = ""
if pconfig.base_url_env_var:
env_base_url = os.getenv(pconfig.base_url_env_var, "").strip().rstrip("/")
base_url = creds.get("base_url", "").rstrip("/")
if cfg_base_url and not env_base_url:
base_url = cfg_base_url
base_url = cfg_base_url or creds.get("base_url", "").rstrip("/")
api_mode = "chat_completions"
if provider == "copilot":
api_mode = _copilot_runtime_api_mode(model_cfg, creds.get("api_key", ""))