Coverage for src/local_deep_research/llm/providers/implementations/lmstudio.py: 100%
53 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
1"""LM Studio LLM provider for Local Deep Research."""
3from ....config.constants import DEFAULT_LMSTUDIO_URL
4from ....utilities.url_utils import normalize_url
5from ..openai_base import OpenAICompatibleProvider
8class LMStudioProvider(OpenAICompatibleProvider):
9 """LM Studio provider using OpenAI-compatible endpoint.
11 LM Studio provides a local OpenAI-compatible API for running models.
12 Recent LM Studio versions can require an API key on the local server;
13 the key is optional here so unauthenticated instances keep working.
14 """
16 provider_name = "LM Studio"
17 # api_key_setting=None tells the parent class no key is *required*; the
18 # create_llm override below still reads `llm.lmstudio.api_key` for the
19 # optional auth-enabled case and falls back to a placeholder otherwise.
20 api_key_setting = None # type: ignore[assignment]
21 url_setting = "llm.lmstudio.url" # type: ignore[assignment] # Settings key for URL
22 default_base_url = DEFAULT_LMSTUDIO_URL
23 default_model = (
24 "" # User must specify the model they loaded — no silent fallback
25 )
27 # Metadata for auto-discovery
28 provider_key = "LMSTUDIO"
29 company_name = "LM Studio"
30 is_cloud = False # Local provider
32 # Hardcoded since `api_key_setting` is None at the class level (the route
33 # reads via `cls.api_key_setting`; LM Studio handles the key inside its
34 # own methods instead, so the route's path stays neutral).
35 _API_KEY_PATH = "llm.lmstudio.api_key"
37 @classmethod
38 def _get_auth_headers(cls, settings_snapshot=None):
39 """Build Authorization header from the optional API key setting.
41 Returns an empty dict when no key is configured so unauthenticated
42 LM Studio instances continue to work.
43 """
44 from ....config.thread_settings import get_setting_from_snapshot
46 headers: dict[str, str] = {}
47 api_key = get_setting_from_snapshot(
48 cls._API_KEY_PATH,
49 "",
50 settings_snapshot=settings_snapshot,
51 )
52 if api_key and str(api_key).strip():
53 headers["Authorization"] = f"Bearer {str(api_key).strip()}"
54 return headers
56 @classmethod
57 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
58 """Override to handle LM Studio specifics."""
59 from ....config.thread_settings import get_setting_from_snapshot
61 settings_snapshot = kwargs.get("settings_snapshot")
63 # Get LM Studio URL from settings (default includes /v1 for backward compatibility)
64 lmstudio_url = get_setting_from_snapshot(
65 "llm.lmstudio.url",
66 cls.default_base_url,
67 settings_snapshot=settings_snapshot,
68 )
69 api_key = get_setting_from_snapshot(
70 cls._API_KEY_PATH,
71 "",
72 settings_snapshot=settings_snapshot,
73 )
75 # Use URL as-is (user should provide complete URL including /v1 if needed)
76 kwargs["base_url"] = normalize_url(lmstudio_url)
78 # If user configured a real API key (LM Studio with auth enabled), use
79 # it. Otherwise pass a placeholder ChatOpenAI accepts; a no-auth
80 # LM Studio ignores it.
81 kwargs["api_key"] = api_key or "not-required" # gitleaks:allow
83 # Use parent's create_llm but bypass API key check
84 return super()._create_llm_instance(model_name, temperature, **kwargs)
86 @classmethod
87 def is_available(cls, settings_snapshot=None):
88 """Check if LM Studio is available.
90 Sends ``Authorization: Bearer`` when a key is configured so
91 authenticated LM Studio instances are correctly detected as available.
92 Empty key → no auth header → unauthenticated installs still work.
93 """
94 try:
95 from ....config.thread_settings import get_setting_from_snapshot
96 from ....security import safe_get
98 lmstudio_url = get_setting_from_snapshot(
99 "llm.lmstudio.url",
100 cls.default_base_url,
101 settings_snapshot=settings_snapshot,
102 )
103 # Use URL as-is (default already includes /v1)
104 base_url = normalize_url(lmstudio_url)
105 response = safe_get(
106 f"{base_url}/models",
107 timeout=1,
108 headers=cls._get_auth_headers(settings_snapshot),
109 allow_localhost=True,
110 allow_private_ips=True,
111 )
112 return response.status_code == 200
113 except Exception:
114 return False
116 @classmethod
117 def requires_auth_for_models(cls):
118 """LM Studio doesn't require authentication for listing models.
120 Returning False keeps unauthenticated installs working (parent
121 ``list_models_for_api`` substitutes a dummy key when the real key is
122 falsy). Authenticated installs are handled by the override of
123 ``list_models_for_api`` below, which reads the user's key from
124 settings when no key is passed in directly by the caller.
125 """
126 return False
128 @classmethod
129 def list_models_for_api(cls, api_key=None, base_url=None):
130 """List models, attaching the optional API key when configured.
132 When ``api_key`` is provided directly (e.g., from the settings route),
133 it is used as-is. When the caller doesn't supply a key, the key is
134 read from the thread-local settings here so authenticated installs are
135 handled correctly on both paths. Empty/whitespace falls through to the
136 parent's dummy-key path, preserving backward compat for
137 unauthenticated installs.
138 """
139 from ....config.thread_settings import get_setting_from_snapshot
141 if not api_key:
142 raw = get_setting_from_snapshot(
143 cls._API_KEY_PATH,
144 "",
145 settings_snapshot=None,
146 )
147 api_key = str(raw or "").strip() or None
149 if not base_url:
150 base_url = get_setting_from_snapshot(
151 cls.url_setting,
152 cls.default_base_url,
153 settings_snapshot=None,
154 )
156 return super().list_models_for_api(api_key=api_key, base_url=base_url)