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

1"""LM Studio LLM provider for Local Deep Research.""" 

2 

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 

8 

9 

10class LMStudioProvider(OpenAICompatibleProvider): 

11 """LM Studio provider using OpenAI-compatible endpoint. 

12 

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 """ 

17 

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 ) 

30 

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 

38 

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 

45 

46 @classmethod 

47 def _get_auth_headers(cls, settings_snapshot=None): 

48 """Build Authorization header from the optional API key setting. 

49 

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) 

54 

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 

59 

60 settings_snapshot = kwargs.get("settings_snapshot") 

61 

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 ) 

68 

69 kwargs["base_url"] = cls._ensure_v1_suffix(lmstudio_url) 

70 

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 

77 

78 # Use parent's create_llm but bypass API key check 

79 return super()._create_llm_instance(model_name, temperature, **kwargs) 

80 

81 @classmethod 

82 def is_available(cls, settings_snapshot=None): 

83 """Check if LM Studio is available. 

84 

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 

92 

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 

111 

112 @classmethod 

113 def requires_auth_for_models(cls): 

114 """LM Studio doesn't require authentication for listing models. 

115 

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 

123 

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. 

127 

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 

136 

137 if not api_key: 

138 api_key = cls.resolve_api_key() 

139 

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 ) 

146 

147 base_url = cls._ensure_v1_suffix(base_url) 

148 return super().list_models_for_api(api_key=api_key, base_url=base_url)