Coverage for src/local_deep_research/llm/providers/implementations/lmstudio.py: 100%
58 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""LM Studio LLM provider for Local Deep Research."""
3from ....config.constants import DEFAULT_LMSTUDIO_URL
4from ....settings.exceptions import SettingsValueError
5from ....utilities.url_utils import normalize_url
6from ..base import Exposure
7from ..openai_base import OpenAICompatibleProvider
10class LMStudioProvider(OpenAICompatibleProvider):
11 """LM Studio provider using OpenAI-compatible endpoint.
13 LM Studio provides a local OpenAI-compatible API for running models.
14 Recent LM Studio versions can require an API key on the local server;
15 the key is optional here so unauthenticated instances keep working.
16 """
18 provider_name = "LM Studio"
19 # LM Studio HAS an API key setting; it's just optional (newer LM Studio
20 # versions can require auth on the local server). The api_key_optional
21 # flag tells the base resolver to fall back to a placeholder when no
22 # key is configured, instead of raising.
23 api_key_setting = "llm.lmstudio.api_key"
24 api_key_optional = True
25 url_setting = "llm.lmstudio.url" # type: ignore[assignment] # Settings key for URL
26 default_base_url = DEFAULT_LMSTUDIO_URL
27 default_model = (
28 "" # User must specify the model they loaded — no silent fallback
29 )
31 @classmethod
32 def _ensure_v1_suffix(cls, url: str) -> str:
33 """Ensure URL ends with /v1 — LM Studio always uses this path prefix."""
34 normalized = normalize_url(url)
35 if not normalized.rstrip("/").endswith("/v1"):
36 normalized = normalized.rstrip("/") + "/v1"
37 return normalized
39 # Metadata for auto-discovery
40 provider_key = "LMSTUDIO"
41 company_name = "LM Studio"
42 is_cloud = False # Local provider
43 # Egress exposure (ADR-0007): local inference sink — data stays on the box.
44 egress_exposure = Exposure.CONTAINED
46 @classmethod
47 def _get_auth_headers(cls, settings_snapshot=None):
48 """Build Authorization header from the optional API key setting.
50 Returns an empty dict when no key is configured so unauthenticated
51 LM Studio instances continue to work.
52 """
53 return cls.build_bearer_header(settings_snapshot=settings_snapshot)
55 @classmethod
56 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
57 """Override to handle LM Studio specifics."""
58 from ....config.thread_settings import get_setting_from_snapshot
60 settings_snapshot = kwargs.get("settings_snapshot")
62 # Get LM Studio URL from settings (default includes /v1 for backward compatibility)
63 lmstudio_url = get_setting_from_snapshot(
64 "llm.lmstudio.url",
65 cls.default_base_url,
66 settings_snapshot=settings_snapshot,
67 )
69 kwargs["base_url"] = cls._ensure_v1_suffix(lmstudio_url)
71 # Real key when configured (LM Studio with auth enabled), otherwise
72 # the unified placeholder ChatOpenAI accepts; a no-auth LM Studio
73 # ignores it.
74 kwargs["api_key"] = cls.resolve_api_key_or_placeholder(
75 settings_snapshot
76 ) # gitleaks:allow
78 # Use parent's create_llm but bypass API key check
79 return super()._create_llm_instance(model_name, temperature, **kwargs)
81 @classmethod
82 def is_available(cls, settings_snapshot=None):
83 """Check if LM Studio is available.
85 Sends ``Authorization: Bearer`` when a key is configured so
86 authenticated LM Studio instances are correctly detected as available.
87 Empty key → no auth header → unauthenticated installs still work.
88 """
89 try:
90 from ....config.thread_settings import get_setting_from_snapshot
91 from ....security import safe_get
93 lmstudio_url = get_setting_from_snapshot(
94 "llm.lmstudio.url",
95 cls.default_base_url,
96 settings_snapshot=settings_snapshot,
97 )
98 base_url = cls._ensure_v1_suffix(lmstudio_url)
99 response = safe_get(
100 f"{base_url}/models",
101 timeout=1,
102 headers=cls._get_auth_headers(settings_snapshot),
103 allow_localhost=True,
104 allow_private_ips=True,
105 )
106 return response.status_code == 200
107 except SettingsValueError:
108 raise
109 except Exception:
110 return False
112 @classmethod
113 def requires_auth_for_models(cls):
114 """LM Studio doesn't require authentication for listing models.
116 Returning False keeps unauthenticated installs working (parent
117 ``list_models_for_api`` substitutes a dummy key when the real key is
118 falsy). Authenticated installs are handled by the override of
119 ``list_models_for_api`` below, which reads the user's key from
120 settings when no key is passed in directly by the caller.
121 """
122 return False
124 @classmethod
125 def list_models_for_api(cls, api_key=None, base_url=None):
126 """List models, attaching the optional API key when configured.
128 When ``api_key`` is provided directly (e.g., from the settings route),
129 it is used as-is. When the caller doesn't supply a key, the key is
130 read from the thread-local settings here so authenticated installs are
131 handled correctly on both paths. Empty/whitespace falls through to the
132 parent's dummy-key path, preserving backward compat for
133 unauthenticated installs.
134 """
135 from ....config.thread_settings import get_setting_from_snapshot
137 if not api_key:
138 api_key = cls.resolve_api_key()
140 if not base_url:
141 base_url = get_setting_from_snapshot(
142 cls.url_setting,
143 cls.default_base_url,
144 settings_snapshot=None,
145 )
147 base_url = cls._ensure_v1_suffix(base_url)
148 return super().list_models_for_api(api_key=api_key, base_url=base_url)