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

@@ -1826,9 +1826,6 @@ class AIAgent:
)
_config_context_length = None
# Store for reuse in switch_model (so config override persists across model switches)
self._config_context_length = _config_context_length
# Resolve custom_providers list once for reuse below (startup
# context-length override and plugin context-engine init).
try:
@@ -1887,7 +1884,14 @@ class AIAgent:
file=sys.stderr,
)
break
# Persist for reuse on switch_model / fallback activation. Must come
# AFTER the custom_providers branch so per-model overrides aren't lost.
self._config_context_length = _config_context_length
self._ensure_lmstudio_runtime_loaded(_config_context_length)
# Select context engine: config-driven (like memory providers).
# 1. Check config.yaml context.engine setting
# 2. Check plugins/context_engine/<name>/ directory (repo-shipped)
@@ -2129,6 +2133,24 @@ class AIAgent:
if hasattr(self, "context_compressor") and self.context_compressor:
self.context_compressor.on_session_reset()
def _ensure_lmstudio_runtime_loaded(self, config_context_length: Optional[int] = None) -> None:
"""
Preload the LM Studio model with at least Hermes' minimum context.
"""
if (self.provider or "").strip().lower() != "lmstudio":
return
try:
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
from hermes_cli.models import ensure_lmstudio_model_loaded
if config_context_length is None:
config_context_length = getattr(self, "_config_context_length", None)
target_ctx = max(config_context_length or 0, MINIMUM_CONTEXT_LENGTH)
ensure_lmstudio_model_loaded(
self.model, self.base_url, getattr(self, "api_key", ""), target_ctx,
)
except Exception as err:
logger.debug("LM Studio preload skipped: %s", err)
def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mode=''):
"""Switch the model/provider in-place for a live agent.
@@ -2224,6 +2246,9 @@ class AIAgent:
)
)
# ── LM Studio: preload before probing context length ──
self._ensure_lmstudio_runtime_loaded()
# ── Update context compressor ──
if hasattr(self, "context_compressor") and self.context_compressor:
from agent.model_metadata import get_model_context_length
@@ -7327,6 +7352,9 @@ class AIAgent:
)
)
# LM Studio: preload before probing the fallback's context length.
self._ensure_lmstudio_runtime_loaded()
# Update context compressor limits for the fallback model.
# Without this, compression decisions use the primary model's
# context window (e.g. 200K) instead of the fallback's (e.g. 32K),
@@ -8047,6 +8075,7 @@ class AIAgent:
or base_url_host_matches(self.base_url, "moonshot.cn")
)
_is_tokenhub = base_url_host_matches(self._base_url_lower, "tokenhub.tencentmaas.com")
_is_lmstudio = (self.provider or "").strip().lower() == "lmstudio"
# Temperature: _fixed_temperature_for_model may return OMIT_TEMPERATURE
# sentinel (temperature omitted entirely), a numeric override, or None.
@@ -8119,6 +8148,7 @@ class AIAgent:
is_nvidia_nim=_is_nvidia,
is_kimi=_is_kimi,
is_tokenhub=_is_tokenhub,
is_lmstudio=_is_lmstudio,
is_custom_provider=self.provider == "custom",
ollama_num_ctx=self._ollama_num_ctx,
provider_preferences=_prefs or None,
@@ -8129,6 +8159,7 @@ class AIAgent:
omit_temperature=_omit_temp,
supports_reasoning=self._supports_reasoning_extra_body(),
github_reasoning_extra=self._github_models_reasoning_extra_body() if _is_gh else None,
lmstudio_reasoning_options=self._lmstudio_reasoning_options_cached() if _is_lmstudio else None,
anthropic_max_output=_ant_max,
provider_name=self.provider,
)
@@ -8154,6 +8185,10 @@ class AIAgent:
return bool(github_model_reasoning_efforts(self.model))
except Exception:
return False
if (self.provider or "").strip().lower() == "lmstudio":
opts = self._lmstudio_reasoning_options_cached()
# "off-only" (or absent) means no real reasoning capability.
return any(opt and opt != "off" for opt in opts)
if "openrouter" not in self._base_url_lower:
return False
if "api.mistral.ai" in self._base_url_lower:
@@ -8171,6 +8206,48 @@ class AIAgent:
)
return any(model.startswith(prefix) for prefix in reasoning_model_prefixes)
def _lmstudio_reasoning_options_cached(self) -> list[str]:
"""Probe LM Studio's published reasoning ``allowed_options`` once per
(model, base_url). The list (e.g. ``["off","on"]`` or
``["off","minimal","low"]``) is needed both for the supports-reasoning
gate and for clamping the emitted ``reasoning_effort`` so toggle-style
models don't 400 on ``high``. Cache is keyed on (model, base_url) so
``/model`` swaps and base-URL changes don't reuse a stale list, and an
empty result (transient probe failure) is *not* cached so the next call
retries instead of silently disabling reasoning for the rest of the
session.
"""
cache = getattr(self, "_lm_reasoning_opts_cache", None)
if cache is None:
cache = self._lm_reasoning_opts_cache = {}
key = (self.model, self.base_url)
cached = cache.get(key)
if cached:
return cached
try:
from hermes_cli.models import lmstudio_model_reasoning_options
opts = lmstudio_model_reasoning_options(
self.model, self.base_url, getattr(self, "api_key", ""),
)
except Exception:
opts = []
if opts:
cache[key] = opts
return opts
def _resolve_lmstudio_summary_reasoning_effort(self) -> Optional[str]:
"""Resolve a safe top-level ``reasoning_effort`` for LM Studio.
The iteration-limit summary path calls ``chat.completions.create()``
directly, bypassing the transport. Share the helper so the two paths
can't drift on effort resolution and clamping.
"""
from agent.lmstudio_reasoning import resolve_lmstudio_effort
return resolve_lmstudio_effort(
self.reasoning_config,
self._lmstudio_reasoning_options_cached(),
)
def _github_models_reasoning_extra_body(self) -> dict | None:
"""Format reasoning payload for GitHub Models/OpenAI-compatible routes."""
try:
@@ -9692,7 +9769,19 @@ class AIAgent:
_omit_summary_temperature = _raw_summary_temp is _OMIT_TEMP
_summary_temperature = None if _omit_summary_temperature else _raw_summary_temp
_is_nous = "nousresearch" in self._base_url_lower
if self._supports_reasoning_extra_body():
# LM Studio uses top-level `reasoning_effort` (not extra_body.reasoning).
# Mirror ChatCompletionsTransport.build_kwargs() so the summary path
# — which calls chat.completions.create() directly without going
# through the transport — sends the same shape the transport does.
_is_lmstudio_summary = (
(self.provider or "").strip().lower() == "lmstudio"
and self._supports_reasoning_extra_body()
)
_lm_reasoning_effort: str | None = (
self._resolve_lmstudio_summary_reasoning_effort()
if _is_lmstudio_summary else None
)
if not _is_lmstudio_summary and self._supports_reasoning_extra_body():
if self.reasoning_config is not None:
summary_extra_body["reasoning"] = self.reasoning_config
else:
@@ -9719,6 +9808,8 @@ class AIAgent:
summary_kwargs["temperature"] = _summary_temperature
if self.max_tokens is not None:
summary_kwargs.update(self._max_tokens_param(self.max_tokens))
if _lm_reasoning_effort is not None:
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
# Include provider routing preferences
provider_preferences = {}
@@ -9784,6 +9875,8 @@ class AIAgent:
summary_kwargs["temperature"] = _summary_temperature
if self.max_tokens is not None:
summary_kwargs.update(self._max_tokens_param(self.max_tokens))
if _lm_reasoning_effort is not None:
summary_kwargs["reasoning_effort"] = _lm_reasoning_effort
if summary_extra_body:
summary_kwargs["extra_body"] = summary_extra_body