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

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

2 

3from ....config.constants import DEFAULT_LMSTUDIO_URL 

4from ....utilities.url_utils import normalize_url 

5from ..openai_base import OpenAICompatibleProvider 

6 

7 

8class LMStudioProvider(OpenAICompatibleProvider): 

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

10 

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

15 

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 ) 

26 

27 # Metadata for auto-discovery 

28 provider_key = "LMSTUDIO" 

29 company_name = "LM Studio" 

30 is_cloud = False # Local provider 

31 

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" 

36 

37 @classmethod 

38 def _get_auth_headers(cls, settings_snapshot=None): 

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

40 

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 

45 

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 

55 

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 

60 

61 settings_snapshot = kwargs.get("settings_snapshot") 

62 

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 ) 

74 

75 # Use URL as-is (user should provide complete URL including /v1 if needed) 

76 kwargs["base_url"] = normalize_url(lmstudio_url) 

77 

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 

82 

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

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

85 

86 @classmethod 

87 def is_available(cls, settings_snapshot=None): 

88 """Check if LM Studio is available. 

89 

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 

97 

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 

115 

116 @classmethod 

117 def requires_auth_for_models(cls): 

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

119 

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 

127 

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. 

131 

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 

140 

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 

148 

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 ) 

155 

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