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

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 ..base import Exposure 

6from ..openai_base import OpenAICompatibleProvider 

7 

8 

9class LMStudioProvider(OpenAICompatibleProvider): 

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

11 

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

16 

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 ) 

29 

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 

37 

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 

44 

45 @classmethod 

46 def _get_auth_headers(cls, settings_snapshot=None): 

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

48 

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) 

53 

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 

58 

59 settings_snapshot = kwargs.get("settings_snapshot") 

60 

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 ) 

67 

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

69 

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 

76 

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

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

79 

80 @classmethod 

81 def is_available(cls, settings_snapshot=None): 

82 """Check if LM Studio is available. 

83 

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 

91 

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 

108 

109 @classmethod 

110 def requires_auth_for_models(cls): 

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

112 

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 

120 

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. 

124 

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 

133 

134 if not api_key: 

135 api_key = cls.resolve_api_key() 

136 

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 ) 

143 

144 base_url = cls._ensure_v1_suffix(base_url) 

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