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

124 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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 # Context-window / auto-discovery key (e.g. "OPENAI"). Annotated without 

26 # a value on purpose: a subclass that omits it raises AttributeError at 

27 # construction instead of silently resolving the cloud context window 

28 # (issue #4546). 

29 provider_key: str 

30 provider_name = "openai_endpoint" # Name used in logs 

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

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

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

34 default_model = ( 

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

36 ) 

37 

38 @classmethod 

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

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

41 

42 Args: 

43 model_name: Name of the model to use 

44 temperature: Model temperature (0.0-1.0) 

45 **kwargs: Additional arguments including settings_snapshot 

46 

47 Returns: 

48 A configured ChatOpenAI instance 

49 

50 Raises: 

51 ValueError: If API key is not configured 

52 """ 

53 from ...config.thread_settings import ( 

54 _get_optional_setting, 

55 NoSettingsContextError, 

56 ) 

57 

58 settings_snapshot = kwargs.get("settings_snapshot") 

59 

60 # Resolve API key. resolve_api_key_or_placeholder raises for required 

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

62 # the unified OPTIONAL_API_KEY_PLACEHOLDER for optional providers. 

63 api_key = cls.resolve_api_key_or_placeholder(settings_snapshot) 

64 

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

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

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

68 raise ValueError( 

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

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

71 ) 

72 

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

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

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

76 

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

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

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

80 # ALWAYS_BLOCKED_METADATA_IPS still fires under the permissive 

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

82 if cls.url_setting: 

83 base_url = assert_base_url_safe( 

84 base_url, setting_key=cls.url_setting 

85 ) 

86 

87 # Build parameters for OpenAI client 

88 llm_params = { 

89 "model": model_name, 

90 "api_key": api_key, 

91 "base_url": base_url, 

92 "temperature": temperature, 

93 } 

94 

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

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

97 # leaves room for the prompt itself. 

98 from ._helpers import ( 

99 compute_max_tokens, 

100 get_context_window_for_provider, 

101 ) 

102 

103 try: 

104 context_window_size = get_context_window_for_provider( 

105 cls.provider_key.lower(), 

106 settings_snapshot=settings_snapshot, 

107 ) 

108 max_tokens = compute_max_tokens( 

109 settings_snapshot=settings_snapshot, 

110 context_window_size=context_window_size, 

111 ) 

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

113 llm_params["max_tokens"] = max_tokens 

114 except NoSettingsContextError: 

115 pass # Optional parameter 

116 

117 # Add streaming if specified 

118 _get_optional_setting( 

119 llm_params, 

120 "streaming", 

121 "llm.streaming", 

122 settings_snapshot, 

123 ) 

124 

125 # Add max_retries if specified 

126 _get_optional_setting( 

127 llm_params, 

128 "max_retries", 

129 "llm.max_retries", 

130 settings_snapshot, 

131 ) 

132 

133 # Add request_timeout if specified 

134 _get_optional_setting( 

135 llm_params, 

136 "request_timeout", 

137 "llm.request_timeout", 

138 settings_snapshot, 

139 ) 

140 

141 # Request usage stats on streamed responses (stream_options. 

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

143 # OpenAI-compatible endpoints reject unknown request fields. 

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

145 llm_params["stream_usage"] = kwargs["stream_usage"] 

146 

147 logger.info( 

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

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

150 ) 

151 

152 return ChatOpenAI(**llm_params) 

153 

154 @classmethod 

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

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

157 

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

159 """ 

160 from ...config.thread_settings import NoSettingsContextError 

161 

162 settings_snapshot = kwargs.get("settings_snapshot") 

163 

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

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

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

167 raise ValueError( 

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

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

170 ) 

171 

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

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

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

175 

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

177 if cls.url_setting: 

178 base_url = assert_base_url_safe( 

179 base_url, setting_key=cls.url_setting 

180 ) 

181 

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

183 # Defensive default uses the unified OPTIONAL_API_KEY_PLACEHOLDER so 

184 # any future direct caller of _create_llm_instance without an 

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

186 from .base import OPTIONAL_API_KEY_PLACEHOLDER 

187 

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

189 

190 # Build parameters for OpenAI client 

191 llm_params = { 

192 "model": model_name, 

193 "api_key": api_key, 

194 "base_url": base_url, 

195 "temperature": temperature, 

196 } 

197 

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

199 from ._helpers import ( 

200 compute_max_tokens, 

201 get_context_window_for_provider, 

202 ) 

203 

204 try: 

205 context_window_size = get_context_window_for_provider( 

206 cls.provider_key.lower(), 

207 settings_snapshot=settings_snapshot, 

208 ) 

209 max_tokens = compute_max_tokens( 

210 settings_snapshot=settings_snapshot, 

211 context_window_size=context_window_size, 

212 ) 

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

214 llm_params["max_tokens"] = max_tokens 

215 except NoSettingsContextError: 

216 pass 

217 

218 return ChatOpenAI(**llm_params) 

219 

220 @classmethod 

221 def is_available(cls, settings_snapshot=None): 

222 """Check if this provider is available. 

223 

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

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

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

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

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

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

230 placeholder key is used at construction time. 

231 

232 Args: 

233 settings_snapshot: Optional settings snapshot to use 

234 

235 Returns: 

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

237 """ 

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

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

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

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

242 if not cls.api_key_setting or cls.api_key_optional: 

243 return True 

244 return cls.has_api_key(settings_snapshot=settings_snapshot) 

245 

246 @classmethod 

247 def requires_auth_for_models(cls): 

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

249 

250 Override in subclasses that don't require auth. 

251 

252 Returns: 

253 True if authentication is required, False otherwise 

254 """ 

255 return True 

256 

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

258 @classmethod 

259 def _get_base_url_for_models(cls, settings_snapshot=None): 

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

261 

262 Reads from url_setting if defined, otherwise uses default_base_url. 

263 

264 Args: 

265 settings_snapshot: Optional settings snapshot dict 

266 

267 Returns: 

268 The base URL string to use for model listing 

269 """ 

270 from ...config.thread_settings import get_setting_from_snapshot 

271 

272 if cls.url_setting: 

273 # Use get_setting_from_snapshot which handles both settings_snapshot 

274 # and thread-local context, with proper fallback 

275 url = get_setting_from_snapshot( 

276 cls.url_setting, 

277 default=None, 

278 settings_snapshot=settings_snapshot, 

279 ) 

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

281 return url.rstrip("/") 

282 

283 return cls.default_base_url 

284 

285 @classmethod 

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

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

288 

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

290 

291 Args: 

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

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

294 

295 Returns: 

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

297 """ 

298 try: 

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

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

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

302 # endpoint we're listing models from. 

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

304 logger.error( 

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

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

307 f"refusing to send." 

308 ) 

309 return [] 

310 

311 # Check if auth is required 

312 if cls.requires_auth_for_models(): 

313 if not api_key: 

314 logger.debug( 

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

316 ) 

317 return [] 

318 else: 

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

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

321 

322 from openai import OpenAI 

323 

324 # Use provided base_url or fall back to class default 

325 if not base_url: 

326 base_url = cls.default_base_url 

327 

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

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

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

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

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

333 # None (providers with a hardcoded default_base_url have no 

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

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

336 if base_url and cls.url_setting: 

337 try: 

338 base_url = assert_base_url_safe( 

339 base_url, setting_key=cls.url_setting 

340 ) 

341 except ValueError: 

342 logger.warning( 

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

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

345 ) 

346 return [] 

347 

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

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

350 

351 # Fetch models 

352 logger.debug( 

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

354 ) 

355 models_response = client.models.list() 

356 

357 models = [] 

358 for model in models_response.data: 

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

360 models.append( 

361 { 

362 "value": model.id, 

363 "label": model.id, 

364 } 

365 ) 

366 

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

368 return models 

369 

370 except Exception: 

371 # Use warning level since connection failures are expected 

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

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

374 return [] 

375 

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

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

378 # for custom providers. 

379 @classmethod 

380 def list_models(cls, settings_snapshot=None): 

381 """List available models from this provider. 

382 

383 Args: 

384 settings_snapshot: Optional settings snapshot to use 

385 

386 Returns: 

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

388 """ 

389 from ...config.thread_settings import get_setting_from_snapshot 

390 

391 try: 

392 # Get API key from settings if auth is required 

393 api_key = None 

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

395 api_key = get_setting_from_snapshot( 

396 cls.api_key_setting, 

397 default=None, 

398 settings_snapshot=settings_snapshot, 

399 ) 

400 

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

402 base_url = cls._get_base_url_for_models(settings_snapshot) 

403 

404 return cls.list_models_for_api(api_key, base_url) 

405 

406 except Exception as e: 

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

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

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

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

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

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

413 logger.warning( 

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

415 ) 

416 return []