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