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

124 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

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

2 

3from langchain_openai import ChatOpenAI 

4from ...security.secure_logging import logger 

5 

6# get_setting_from_snapshot and NoSettingsContextError are imported inside 

7# the methods that use them so test patches at the source module 

8# (`local_deep_research.config.thread_settings`) are picked up by the 

9# function-local imports at call time. A module-level binding here would 

10# be unaffected by patching the source module. 

11from ...security.log_sanitizer import redact_secrets 

12from ...security.ssrf_validator import assert_base_url_safe 

13from ...utilities.url_utils import normalize_url 

14from .base import BaseLLMProvider 

15 

16 

17class OpenAICompatibleProvider(BaseLLMProvider): 

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

19 

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

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

22 """ 

23 

24 # Override these in subclasses 

25 provider_name = "openai_endpoint" # Name used in logs 

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

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

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

29 default_model = ( 

30 "" # User must explicitly configure llm.model — no silent fallback 

31 ) 

32 

33 @classmethod 

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

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

36 

37 Args: 

38 model_name: Name of the model to use 

39 temperature: Model temperature (0.0-1.0) 

40 **kwargs: Additional arguments including settings_snapshot 

41 

42 Returns: 

43 A configured ChatOpenAI instance 

44 

45 Raises: 

46 ValueError: If API key is not configured 

47 """ 

48 from ...config.thread_settings import ( 

49 _get_optional_setting, 

50 NoSettingsContextError, 

51 ) 

52 

53 settings_snapshot = kwargs.get("settings_snapshot") 

54 

55 # Resolve API key. resolve_api_key_or_placeholder raises for required 

56 # providers when missing (matches legacy behavior) and falls back to 

57 # the unified OPTIONAL_API_KEY_PLACEHOLDER for optional providers. 

58 api_key = cls.resolve_api_key_or_placeholder(settings_snapshot) 

59 

60 # Require an explicit model — no silent fallback to a hardcoded default. 

61 if not model_name or not model_name.strip(): 

62 logger.error(f"{cls.provider_name} model name not provided") 

63 raise ValueError( 

64 f"{cls.provider_name} model not configured. " 

65 f"Please set llm.model in settings." 

66 ) 

67 

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

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

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

71 

72 # SSRF guard for operator-configurable base_url. Skip when 

73 # url_setting is None (providers like OpenAI/Anthropic with 

74 # hardcoded default_base_url have no operator URL to attack). 

75 # ALWAYS_BLOCKED_METADATA_IPS still fires under the permissive 

76 # flags below, so cloud-credential endpoints stay blocked. 

77 if cls.url_setting: 

78 base_url = assert_base_url_safe( 

79 base_url, setting_key=cls.url_setting 

80 ) 

81 

82 # Build parameters for OpenAI client 

83 llm_params = { 

84 "model": model_name, 

85 "api_key": api_key, 

86 "base_url": base_url, 

87 "temperature": temperature, 

88 } 

89 

90 # Apply context-window-aware max_tokens cap (was previously only 

91 # applied in dead code in llm_config.get_llm). 80% of context window 

92 # leaves room for the prompt itself. 

93 from ._helpers import ( 

94 compute_max_tokens, 

95 get_context_window_for_provider, 

96 ) 

97 

98 try: 

99 context_window_size = get_context_window_for_provider( 

100 getattr(cls, "provider_key", "").lower(), 

101 settings_snapshot=settings_snapshot, 

102 ) 

103 max_tokens = compute_max_tokens( 

104 settings_snapshot=settings_snapshot, 

105 context_window_size=context_window_size, 

106 ) 

107 if max_tokens: # Treat 0 as unset (matches legacy behavior) 

108 llm_params["max_tokens"] = max_tokens 

109 except NoSettingsContextError: 

110 pass # Optional parameter 

111 

112 # Add streaming if specified 

113 _get_optional_setting( 

114 llm_params, 

115 "streaming", 

116 "llm.streaming", 

117 settings_snapshot, 

118 ) 

119 

120 # Add max_retries if specified 

121 _get_optional_setting( 

122 llm_params, 

123 "max_retries", 

124 "llm.max_retries", 

125 settings_snapshot, 

126 ) 

127 

128 # Add request_timeout if specified 

129 _get_optional_setting( 

130 llm_params, 

131 "request_timeout", 

132 "llm.request_timeout", 

133 settings_snapshot, 

134 ) 

135 

136 # Request usage stats on streamed responses (stream_options. 

137 # include_usage). Opt-in via subclass kwargs because some 

138 # OpenAI-compatible endpoints reject unknown request fields. 

139 if kwargs.get("stream_usage") is not None: 

140 llm_params["stream_usage"] = kwargs["stream_usage"] 

141 

142 logger.info( 

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

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

145 ) 

146 

147 return ChatOpenAI(**llm_params) 

148 

149 @classmethod 

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

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

152 

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

154 """ 

155 from ...config.thread_settings import NoSettingsContextError 

156 

157 settings_snapshot = kwargs.get("settings_snapshot") 

158 

159 # Require an explicit model — no silent fallback to a hardcoded default. 

160 if not model_name or not model_name.strip(): 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true

161 logger.error(f"{cls.provider_name} model name not provided") 

162 raise ValueError( 

163 f"{cls.provider_name} model not configured. " 

164 f"Please set llm.model in settings." 

165 ) 

166 

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

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

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

170 

171 # SSRF guard (same posture as create_llm above). 

172 if cls.url_setting: 

173 base_url = assert_base_url_safe( 

174 base_url, setting_key=cls.url_setting 

175 ) 

176 

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

178 # Defensive default uses the unified OPTIONAL_API_KEY_PLACEHOLDER so 

179 # any future direct caller of _create_llm_instance without an 

180 # explicit api_key sees the same string as everywhere else. 

181 from .base import OPTIONAL_API_KEY_PLACEHOLDER 

182 

183 api_key = kwargs.get("api_key", OPTIONAL_API_KEY_PLACEHOLDER) 

184 

185 # Build parameters for OpenAI client 

186 llm_params = { 

187 "model": model_name, 

188 "api_key": api_key, 

189 "base_url": base_url, 

190 "temperature": temperature, 

191 } 

192 

193 # Apply context-window-aware max_tokens cap (matches create_llm above). 

194 from ._helpers import ( 

195 compute_max_tokens, 

196 get_context_window_for_provider, 

197 ) 

198 

199 try: 

200 context_window_size = get_context_window_for_provider( 

201 getattr(cls, "provider_key", "").lower(), 

202 settings_snapshot=settings_snapshot, 

203 ) 

204 max_tokens = compute_max_tokens( 

205 settings_snapshot=settings_snapshot, 

206 context_window_size=context_window_size, 

207 ) 

208 if max_tokens: # Treat 0 as unset (matches legacy behavior) 

209 llm_params["max_tokens"] = max_tokens 

210 except NoSettingsContextError: 

211 pass 

212 

213 return ChatOpenAI(**llm_params) 

214 

215 @classmethod 

216 def is_available(cls, settings_snapshot=None): 

217 """Check if this provider is available. 

218 

219 This base implementation is a *configuration* check only — it does 

220 not probe the server. Local optional-key providers (LM Studio, 

221 llama.cpp) override this with an HTTP reachability probe, so the 

222 ``api_key_optional`` branch below is effectively reached only by an 

223 optional-key provider that does NOT override is_available(); for 

224 such a provider "configured" reduces to "available" and the 

225 placeholder key is used at construction time. 

226 

227 Args: 

228 settings_snapshot: Optional settings snapshot to use 

229 

230 Returns: 

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

232 """ 

233 # Provider has no key concept at all → available. 

234 # Provider with optional key but no key configured → available 

235 # (the placeholder will be used at construction time). 

236 # Provider with required key → available iff a real key is set. 

237 if not cls.api_key_setting or cls.api_key_optional: 

238 return True 

239 return cls.has_api_key(settings_snapshot=settings_snapshot) 

240 

241 @classmethod 

242 def requires_auth_for_models(cls): 

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

244 

245 Override in subclasses that don't require auth. 

246 

247 Returns: 

248 True if authentication is required, False otherwise 

249 """ 

250 return True 

251 

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

253 @classmethod 

254 def _get_base_url_for_models(cls, settings_snapshot=None): 

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

256 

257 Reads from url_setting if defined, otherwise uses default_base_url. 

258 

259 Args: 

260 settings_snapshot: Optional settings snapshot dict 

261 

262 Returns: 

263 The base URL string to use for model listing 

264 """ 

265 from ...config.thread_settings import get_setting_from_snapshot 

266 

267 if cls.url_setting: 

268 # Use get_setting_from_snapshot which handles both settings_snapshot 

269 # and thread-local context, with proper fallback 

270 url = get_setting_from_snapshot( 

271 cls.url_setting, 

272 default=None, 

273 settings_snapshot=settings_snapshot, 

274 ) 

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

276 return url.rstrip("/") 

277 

278 return cls.default_base_url 

279 

280 @classmethod 

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

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

283 

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

285 

286 Args: 

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

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

289 

290 Returns: 

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

292 """ 

293 try: 

294 # Defense-in-depth: never send a non-string credential to the SDK. 

295 # The OpenAI client coerces the api_key into "Authorization: Bearer 

296 # <repr(api_key)>" — passing a dict would leak its contents to the 

297 # endpoint we're listing models from. 

298 if api_key is not None and not isinstance(api_key, str): 

299 logger.error( 

300 f"{cls.provider_name}.list_models_for_api received " 

301 f"non-string api_key of type {type(api_key).__name__}; " 

302 f"refusing to send." 

303 ) 

304 return [] 

305 

306 # Check if auth is required 

307 if cls.requires_auth_for_models(): 

308 if not api_key: 

309 logger.debug( 

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

311 ) 

312 return [] 

313 else: 

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

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

316 

317 from openai import OpenAI 

318 

319 # Use provided base_url or fall back to class default 

320 if not base_url: 

321 base_url = cls.default_base_url 

322 

323 # SSRF guard for operator-configurable base_url, symmetric with 

324 # the create_llm guard above. The OpenAI SDK client uses its own 

325 # httpx transport that bypasses safe_requests, so an attacker who 

326 # can edit cls.url_setting could otherwise point model-listing at 

327 # internal/cloud-credential endpoints. Skip when url_setting is 

328 # None (providers with a hardcoded default_base_url have no 

329 # operator URL to attack). On rejection, degrade gracefully and 

330 # return [] — model-listing should not 500. 

331 if base_url and cls.url_setting: 

332 try: 

333 base_url = assert_base_url_safe( 

334 base_url, setting_key=cls.url_setting 

335 ) 

336 except ValueError: 

337 logger.warning( 

338 f"{cls.provider_name} base_url failed SSRF " 

339 f"validation; check {cls.url_setting} config" 

340 ) 

341 return [] 

342 

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

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

345 

346 # Fetch models 

347 logger.debug( 

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

349 ) 

350 models_response = client.models.list() 

351 

352 models = [] 

353 for model in models_response.data: 

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

355 models.append( 

356 { 

357 "value": model.id, 

358 "label": model.id, 

359 } 

360 ) 

361 

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

363 return models 

364 

365 except Exception: 

366 # Use warning level since connection failures are expected 

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

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

369 return [] 

370 

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

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

373 # for custom providers. 

374 @classmethod 

375 def list_models(cls, settings_snapshot=None): 

376 """List available models from this provider. 

377 

378 Args: 

379 settings_snapshot: Optional settings snapshot to use 

380 

381 Returns: 

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

383 """ 

384 from ...config.thread_settings import get_setting_from_snapshot 

385 

386 try: 

387 # Get API key from settings if auth is required 

388 api_key = None 

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

390 api_key = get_setting_from_snapshot( 

391 cls.api_key_setting, 

392 default=None, 

393 settings_snapshot=settings_snapshot, 

394 ) 

395 

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

397 base_url = cls._get_base_url_for_models(settings_snapshot) 

398 

399 return cls.list_models_for_api(api_key, base_url) 

400 

401 except Exception as e: 

402 # Upstream exception messages (e.g., requests.HTTPError, OpenAI 

403 # SDK errors from a subclass that builds the URL with the key in 

404 # a query parameter) can embed the api_key value. Use 

405 # logger.warning rather than logger.exception so the cause chain 

406 # (which may also carry the URL) is not written to log sinks. 

407 safe_msg = redact_secrets(str(e), api_key) 

408 logger.warning( 

409 f"Error listing models from {cls.provider_name}: {safe_msg}" 

410 ) 

411 return []