Coverage for src / local_deep_research / llm / providers / openai_base.py: 97%

120 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:55 +0000

1"""Base OpenAI-compatible endpoint provider for Local Deep Research.""" 

2 

3from langchain_openai import ChatOpenAI 

4from loguru import logger 

5 

6from ...config.thread_settings import ( 

7 get_setting_from_snapshot, 

8 NoSettingsContextError, 

9) 

10from ...utilities.url_utils import normalize_url 

11from .base import BaseLLMProvider 

12 

13 

14class OpenAICompatibleProvider(BaseLLMProvider): 

15 """Base class for OpenAI-compatible API providers. 

16 

17 This class provides a common implementation for any service that offers 

18 an OpenAI-compatible API endpoint (Google, OpenRouter, Groq, Together, etc.) 

19 """ 

20 

21 # Override these in subclasses 

22 provider_name = "openai_endpoint" # Name used in logs 

23 api_key_setting = "llm.openai_endpoint.api_key" # Settings key for API key 

24 url_setting = None # Settings key for URL (e.g., "llm.lmstudio.url") 

25 default_base_url = "https://api.openai.com/v1" # Default endpoint URL 

26 default_model = "gpt-3.5-turbo" # Default model if none specified 

27 

28 @classmethod 

29 def create_llm(cls, model_name=None, temperature=0.7, **kwargs): 

30 """Factory function for OpenAI-compatible LLMs. 

31 

32 Args: 

33 model_name: Name of the model to use 

34 temperature: Model temperature (0.0-1.0) 

35 **kwargs: Additional arguments including settings_snapshot 

36 

37 Returns: 

38 A configured ChatOpenAI instance 

39 

40 Raises: 

41 ValueError: If API key is not configured 

42 """ 

43 settings_snapshot = kwargs.get("settings_snapshot") 

44 

45 # Get API key from settings (if provider requires one) 

46 if cls.api_key_setting: 

47 api_key = get_setting_from_snapshot( 

48 cls.api_key_setting, 

49 default=None, 

50 settings_snapshot=settings_snapshot, 

51 ) 

52 

53 if not api_key: 

54 logger.error( 

55 f"{cls.provider_name} API key not found in settings" 

56 ) 

57 raise ValueError( 

58 f"{cls.provider_name} API key not configured. " 

59 f"Please set {cls.api_key_setting} in settings." 

60 ) 

61 else: 

62 # Provider doesn't require API key (e.g., LM Studio) 

63 api_key = kwargs.get("api_key", "dummy-key") 

64 

65 # Use default model if none specified 

66 if not model_name: 

67 model_name = cls.default_model 

68 

69 # Get endpoint URL (can be overridden in kwargs for flexibility) 

70 base_url = kwargs.get("base_url", cls.default_base_url) 

71 base_url = normalize_url(base_url) if base_url else cls.default_base_url 

72 

73 # Build parameters for OpenAI client 

74 llm_params = { 

75 "model": model_name, 

76 "api_key": api_key, 

77 "base_url": base_url, 

78 "temperature": temperature, 

79 } 

80 

81 # Add max_tokens if specified in settings 

82 try: 

83 max_tokens = get_setting_from_snapshot( 

84 "llm.max_tokens", 

85 default=None, 

86 settings_snapshot=settings_snapshot, 

87 ) 

88 if max_tokens: 

89 llm_params["max_tokens"] = int(max_tokens) 

90 except NoSettingsContextError: 

91 pass # Optional parameter 

92 

93 # Add streaming if specified 

94 try: 

95 streaming = get_setting_from_snapshot( 

96 "llm.streaming", 

97 default=None, 

98 settings_snapshot=settings_snapshot, 

99 ) 

100 if streaming is not None: 

101 llm_params["streaming"] = streaming 

102 except NoSettingsContextError: 

103 pass # Optional parameter 

104 

105 # Add max_retries if specified 

106 try: 

107 max_retries = get_setting_from_snapshot( 

108 "llm.max_retries", 

109 default=None, 

110 settings_snapshot=settings_snapshot, 

111 ) 

112 if max_retries is not None: 

113 llm_params["max_retries"] = max_retries 

114 except NoSettingsContextError: 

115 pass # Optional parameter 

116 

117 # Add request_timeout if specified 

118 try: 

119 request_timeout = get_setting_from_snapshot( 

120 "llm.request_timeout", 

121 default=None, 

122 settings_snapshot=settings_snapshot, 

123 ) 

124 if request_timeout is not None: 

125 llm_params["request_timeout"] = request_timeout 

126 except NoSettingsContextError: 

127 pass # Optional parameter 

128 

129 logger.info( 

130 f"Creating {cls.provider_name} LLM with model: {model_name}, " 

131 f"temperature: {temperature}, endpoint: {base_url}" 

132 ) 

133 

134 return ChatOpenAI(**llm_params) 

135 

136 @classmethod 

137 def _create_llm_instance(cls, model_name=None, temperature=0.7, **kwargs): 

138 """Internal method to create LLM instance with provided parameters. 

139 

140 This bypasses API key checking for providers that handle auth differently. 

141 """ 

142 settings_snapshot = kwargs.get("settings_snapshot") 

143 

144 # Use default model if none specified 

145 if not model_name: 

146 model_name = cls.default_model 

147 

148 # Get endpoint URL (can be overridden in kwargs for flexibility) 

149 base_url = kwargs.get("base_url", cls.default_base_url) 

150 base_url = normalize_url(base_url) if base_url else cls.default_base_url 

151 

152 # Get API key from kwargs (caller is responsible for providing it) 

153 api_key = kwargs.get("api_key", "dummy-key") 

154 

155 # Build parameters for OpenAI client 

156 llm_params = { 

157 "model": model_name, 

158 "api_key": api_key, 

159 "base_url": base_url, 

160 "temperature": temperature, 

161 } 

162 

163 # Add optional parameters (same as in create_llm) 

164 try: 

165 max_tokens = get_setting_from_snapshot( 

166 "llm.max_tokens", 

167 default=None, 

168 settings_snapshot=settings_snapshot, 

169 ) 

170 if max_tokens: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

171 llm_params["max_tokens"] = int(max_tokens) 

172 except NoSettingsContextError: 

173 pass 

174 

175 return ChatOpenAI(**llm_params) 

176 

177 @classmethod 

178 def is_available(cls, settings_snapshot=None): 

179 """Check if this provider is available. 

180 

181 Args: 

182 settings_snapshot: Optional settings snapshot to use 

183 

184 Returns: 

185 True if API key is configured (or not needed), False otherwise 

186 """ 

187 try: 

188 # If provider doesn't require API key, it's available 

189 if not cls.api_key_setting: 

190 return True 

191 

192 # Check if API key is configured 

193 api_key = get_setting_from_snapshot( 

194 cls.api_key_setting, 

195 default=None, 

196 settings_snapshot=settings_snapshot, 

197 ) 

198 return bool(api_key and str(api_key).strip()) 

199 except Exception: 

200 return False 

201 

202 @classmethod 

203 def requires_auth_for_models(cls): 

204 """Check if this provider requires authentication for listing models. 

205 

206 Override in subclasses that don't require auth. 

207 

208 Returns: 

209 True if authentication is required, False otherwise 

210 """ 

211 return True 

212 

213 # Resolves base URL from settings; called by list_models(). 

214 @classmethod 

215 def _get_base_url_for_models(cls, settings_snapshot=None): 

216 """Get the base URL to use for listing models. 

217 

218 Reads from url_setting if defined, otherwise uses default_base_url. 

219 

220 Args: 

221 settings_snapshot: Optional settings snapshot dict 

222 

223 Returns: 

224 The base URL string to use for model listing 

225 """ 

226 if cls.url_setting: 

227 # Use get_setting_from_snapshot which handles both settings_snapshot 

228 # and thread-local context, with proper fallback 

229 url = get_setting_from_snapshot( 

230 cls.url_setting, 

231 default=None, 

232 settings_snapshot=settings_snapshot, 

233 ) 

234 if url: 234 ↛ 237line 234 didn't jump to line 237 because the condition on line 234 was always true

235 return url.rstrip("/") 

236 

237 return cls.default_base_url 

238 

239 @classmethod 

240 def list_models_for_api(cls, api_key=None, base_url=None): 

241 """List available models for API endpoint use. 

242 

243 This method is designed to be called from Flask routes. 

244 

245 Args: 

246 api_key: Optional API key (if None and required, returns empty list) 

247 base_url: Optional base URL to use (if None, uses cls.default_base_url) 

248 

249 Returns: 

250 List of model dictionaries with 'value' and 'label' keys 

251 """ 

252 try: 

253 # Check if auth is required 

254 if cls.requires_auth_for_models(): 

255 if not api_key: 

256 logger.debug( 

257 f"{cls.provider_name} requires API key for model listing" 

258 ) 

259 return [] 

260 else: 

261 # Use a dummy key for providers that don't require auth 

262 api_key = api_key or "dummy-key-for-models-list" 

263 

264 from openai import OpenAI 

265 

266 # Use provided base_url or fall back to class default 

267 if not base_url: 

268 base_url = cls.default_base_url 

269 

270 # Create OpenAI client (uses library defaults for timeout) 

271 client = OpenAI(api_key=api_key, base_url=base_url) 

272 

273 # Fetch models 

274 logger.debug( 

275 f"Fetching models from {cls.provider_name} at {base_url}" 

276 ) 

277 models_response = client.models.list() 

278 

279 models = [] 

280 for model in models_response.data: 

281 if model.id: 281 ↛ 280line 281 didn't jump to line 280 because the condition on line 281 was always true

282 models.append( 

283 { 

284 "value": model.id, 

285 "label": model.id, 

286 } 

287 ) 

288 

289 logger.info(f"Found {len(models)} models from {cls.provider_name}") 

290 return models 

291 

292 except Exception: 

293 # Use warning level since connection failures are expected 

294 # when the provider is not running (e.g., LM Studio not started) 

295 logger.warning(f"Could not list models from {cls.provider_name}") 

296 return [] 

297 

298 # High-level settings-aware wrapper around list_models_for_api(). 

299 # Documented in docs/developing/EXTENDING.md as the provider interface 

300 # for custom providers. 

301 @classmethod 

302 def list_models(cls, settings_snapshot=None): 

303 """List available models from this provider. 

304 

305 Args: 

306 settings_snapshot: Optional settings snapshot to use 

307 

308 Returns: 

309 List of model dictionaries with 'value' and 'label' keys 

310 """ 

311 try: 

312 # Get API key from settings if auth is required 

313 api_key = None 

314 if cls.requires_auth_for_models(): 314 ↛ 322line 314 didn't jump to line 322 because the condition on line 314 was always true

315 api_key = get_setting_from_snapshot( 

316 cls.api_key_setting, 

317 default=None, 

318 settings_snapshot=settings_snapshot, 

319 ) 

320 

321 # Get base URL from settings if provider has configurable URL 

322 base_url = cls._get_base_url_for_models(settings_snapshot) 

323 

324 return cls.list_models_for_api(api_key, base_url) 

325 

326 except Exception: 

327 logger.exception(f"Error listing models from {cls.provider_name}") 

328 return []