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

134 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

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

2 

3from langchain_openai import ChatOpenAI 

4 

5from ...security.secure_logging import logger 

6 

7# get_setting_from_snapshot and NoSettingsContextError are imported inside 

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

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

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

11# be unaffected by patching the source module. 

12from ...security.log_sanitizer import redact_secrets, scrub_error 

13from ...security.ssrf_validator import assert_base_url_safe 

14from ...security.url_redaction import redact_url_credentials 

15from ...settings.exceptions import SettingsValueError 

16from ...utilities.url_utils import normalize_url 

17from ._helpers import ( 

18 MODEL_DISCOVERY_TIMEOUT_SECONDS, 

19 build_httpx_timeout, 

20 build_timeout, 

21 resolve_max_retries, 

22 resolve_request_timeout, 

23) 

24from .base import BaseLLMProvider 

25 

26 

27class OpenAICompatibleProvider(BaseLLMProvider): 

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

29 

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

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

32 """ 

33 

34 # Override these in subclasses 

35 # Context-window / auto-discovery key (e.g. "OPENAI"). Annotated without 

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

37 # construction instead of silently resolving the cloud context window 

38 # (issue #4546). 

39 provider_key: str 

40 provider_name = "openai_endpoint" # Name used in logs 

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

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

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

44 default_model = ( 

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

46 ) 

47 

48 @classmethod 

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

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

51 

52 Args: 

53 model_name: Name of the model to use 

54 temperature: Model temperature (0.0-1.0) 

55 **kwargs: Additional arguments including settings_snapshot 

56 

57 Returns: 

58 A configured ChatOpenAI instance 

59 

60 Raises: 

61 ValueError: If API key is not configured 

62 """ 

63 from ...config.thread_settings import ( 

64 _get_optional_setting, 

65 NoSettingsContextError, 

66 ) 

67 

68 settings_snapshot = kwargs.get("settings_snapshot") 

69 

70 # Resolve API key. resolve_api_key_or_placeholder raises for required 

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

72 # the unified OPTIONAL_API_KEY_PLACEHOLDER for optional providers. 

73 api_key = cls.resolve_api_key_or_placeholder(settings_snapshot) 

74 

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

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

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

78 raise ValueError( 

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

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

81 ) 

82 

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

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

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

86 

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

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

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

90 # ALWAYS_BLOCKED_METADATA_IPS still fires under the permissive 

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

92 if cls.url_setting: 

93 base_url = assert_base_url_safe( 

94 base_url, setting_key=cls.url_setting 

95 ) 

96 

97 # Build parameters for OpenAI client 

98 llm_params = { 

99 "model": model_name, 

100 "api_key": api_key, 

101 "base_url": base_url, 

102 "temperature": temperature, 

103 } 

104 

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

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

107 # leaves room for the prompt itself. 

108 from ._helpers import ( 

109 compute_max_tokens, 

110 get_context_window_for_provider, 

111 ) 

112 

113 try: 

114 context_window_size = get_context_window_for_provider( 

115 cls.provider_key.lower(), 

116 settings_snapshot=settings_snapshot, 

117 ) 

118 max_tokens = compute_max_tokens( 

119 settings_snapshot=settings_snapshot, 

120 context_window_size=context_window_size, 

121 ) 

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

123 llm_params["max_tokens"] = max_tokens 

124 except NoSettingsContextError: 

125 pass # Optional parameter 

126 

127 # Add streaming if specified 

128 _get_optional_setting( 

129 llm_params, 

130 "streaming", 

131 "llm.streaming", 

132 settings_snapshot, 

133 ) 

134 

135 # Bounded per-operation timeout and retry count. Both are always 

136 # set: langchain otherwise forwards an explicit ``timeout=None`` 

137 # that overrides the SDK's own default, leaving reads unbounded. 

138 llm_params["request_timeout"] = build_timeout( 

139 resolve_request_timeout(settings_snapshot) 

140 ) 

141 llm_params["max_retries"] = resolve_max_retries(settings_snapshot) 

142 

143 # Request usage stats on streamed responses (stream_options. 

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

145 # OpenAI-compatible endpoints reject unknown request fields. 

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

147 llm_params["stream_usage"] = kwargs["stream_usage"] 

148 

149 logger.info( 

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

151 f"temperature: {temperature}, " 

152 f"endpoint: {redact_url_credentials(base_url) if base_url else None}" 

153 ) 

154 

155 return ChatOpenAI(**llm_params) 

156 

157 @classmethod 

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

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

160 

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

162 """ 

163 from ...config.thread_settings import NoSettingsContextError 

164 

165 settings_snapshot = kwargs.get("settings_snapshot") 

166 

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

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

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

170 raise ValueError( 

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

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

173 ) 

174 

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

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

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

178 

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

180 if cls.url_setting: 

181 base_url = assert_base_url_safe( 

182 base_url, setting_key=cls.url_setting 

183 ) 

184 

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

186 # Defensive default uses the unified OPTIONAL_API_KEY_PLACEHOLDER so 

187 # any future direct caller of _create_llm_instance without an 

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

189 from .base import OPTIONAL_API_KEY_PLACEHOLDER 

190 

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

192 

193 # Build parameters for OpenAI client 

194 llm_params = { 

195 "model": model_name, 

196 "api_key": api_key, 

197 "base_url": base_url, 

198 "temperature": temperature, 

199 } 

200 

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

202 from ._helpers import ( 

203 compute_max_tokens, 

204 get_context_window_for_provider, 

205 ) 

206 

207 try: 

208 context_window_size = get_context_window_for_provider( 

209 cls.provider_key.lower(), 

210 settings_snapshot=settings_snapshot, 

211 ) 

212 max_tokens = compute_max_tokens( 

213 settings_snapshot=settings_snapshot, 

214 context_window_size=context_window_size, 

215 ) 

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

217 llm_params["max_tokens"] = max_tokens 

218 except NoSettingsContextError: 

219 pass 

220 

221 llm_params["request_timeout"] = build_timeout( 

222 resolve_request_timeout(settings_snapshot) 

223 ) 

224 llm_params["max_retries"] = resolve_max_retries(settings_snapshot) 

225 

226 return ChatOpenAI(**llm_params) 

227 

228 @classmethod 

229 def is_available(cls, settings_snapshot=None): 

230 """Check if this provider is available. 

231 

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

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

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

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

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

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

238 placeholder key is used at construction time. 

239 

240 Args: 

241 settings_snapshot: Optional settings snapshot to use 

242 

243 Returns: 

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

245 """ 

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

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

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

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

250 if not cls.api_key_setting or cls.api_key_optional: 

251 return True 

252 return cls.has_api_key(settings_snapshot=settings_snapshot) 

253 

254 @classmethod 

255 def requires_auth_for_models(cls): 

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

257 

258 Override in subclasses that don't require auth. 

259 

260 Returns: 

261 True if authentication is required, False otherwise 

262 """ 

263 return True 

264 

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

266 @classmethod 

267 def _get_base_url_for_models(cls, settings_snapshot=None): 

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

269 

270 Reads from url_setting if defined, otherwise uses default_base_url. 

271 

272 Args: 

273 settings_snapshot: Optional settings snapshot dict 

274 

275 Returns: 

276 The base URL string to use for model listing 

277 """ 

278 from ...config.thread_settings import get_setting_from_snapshot 

279 

280 if cls.url_setting: 

281 # Use get_setting_from_snapshot which handles both settings_snapshot 

282 # and thread-local context, with proper fallback 

283 url = get_setting_from_snapshot( 

284 cls.url_setting, 

285 default=None, 

286 settings_snapshot=settings_snapshot, 

287 ) 

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

289 return url.rstrip("/") 

290 

291 return cls.default_base_url 

292 

293 @classmethod 

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

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

296 

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

298 

299 Args: 

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

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

302 

303 Returns: 

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

305 """ 

306 try: 

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

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

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

310 # endpoint we're listing models from. 

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

312 logger.error( 

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

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

315 f"refusing to send." 

316 ) 

317 return [] 

318 

319 # Trim a pasted key the same way create_llm's resolve_api_key 

320 # does — otherwise a key with surrounding whitespace works for 

321 # research but fails model discovery with a bare auth error. 

322 if isinstance(api_key, str): 

323 api_key = api_key.strip() 

324 

325 # Check if auth is required 

326 if cls.requires_auth_for_models(): 

327 if not api_key: 

328 logger.debug( 

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

330 ) 

331 return [] 

332 else: 

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

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

335 

336 from openai import OpenAI, Timeout 

337 

338 # Use provided base_url or fall back to class default 

339 if not base_url: 

340 base_url = cls.default_base_url 

341 

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

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

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

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

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

347 # None (providers with a hardcoded default_base_url have no 

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

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

350 if base_url and cls.url_setting: 

351 try: 

352 base_url = assert_base_url_safe( 

353 base_url, setting_key=cls.url_setting 

354 ) 

355 except ValueError as e: 

356 # The message carries no URL, only the setting key, 

357 # and tells "could not resolve host" from a blocked 

358 # host. 

359 safe_msg = scrub_error(e) 

360 logger.warning("{} {}", cls.provider_name, safe_msg) 

361 return [] 

362 

363 # A fresh client per call, never routed through langchain's 

364 # cached httpx factory, so the hashable tuple buys nothing 

365 # here — the SDK's own ``Timeout`` object keeps the 

366 # informational ``x-stainless-read-timeout`` header well 

367 # formed. ``openai.Timeout`` is the httpx2 flavour the SDK 

368 # ships; ``httpx.Timeout`` would not be recognised. 

369 client = OpenAI( 

370 api_key=api_key, 

371 base_url=base_url, 

372 timeout=build_httpx_timeout( 

373 MODEL_DISCOVERY_TIMEOUT_SECONDS, Timeout 

374 ), 

375 max_retries=0, 

376 ) 

377 

378 # Fetch models 

379 logger.debug( 

380 f"Fetching models from {cls.provider_name} at " 

381 f"{redact_url_credentials(base_url) if base_url else None}" 

382 ) 

383 models_response = client.models.list() 

384 

385 models = [] 

386 for model in models_response.data: 

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

388 models.append( 

389 { 

390 "value": model.id, 

391 "label": model.id, 

392 } 

393 ) 

394 

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

396 return models 

397 

398 except Exception: 

399 # Use warning level since connection failures are expected 

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

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

402 return [] 

403 

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

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

406 # for custom providers. 

407 @classmethod 

408 def list_models(cls, settings_snapshot=None): 

409 """List available models from this provider. 

410 

411 Args: 

412 settings_snapshot: Optional settings snapshot to use 

413 

414 Returns: 

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

416 """ 

417 from ...config.thread_settings import get_setting_from_snapshot 

418 

419 try: 

420 # Get API key from settings if auth is required 

421 api_key = None 

422 if cls.requires_auth_for_models(): 

423 api_key = get_setting_from_snapshot( 

424 cls.api_key_setting, 

425 default=None, 

426 settings_snapshot=settings_snapshot, 

427 ) 

428 

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

430 base_url = cls._get_base_url_for_models(settings_snapshot) 

431 

432 return cls.list_models_for_api(api_key, base_url) 

433 

434 except SettingsValueError: 

435 raise 

436 except Exception as e: 

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

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

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

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

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

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

443 logger.warning( 

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

445 ) 

446 return []