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
« 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."""
3from langchain_openai import ChatOpenAI
5from ...security.secure_logging import logger
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
27class OpenAICompatibleProvider(BaseLLMProvider):
28 """Base class for OpenAI-compatible API providers.
30 This class provides a common implementation for any service that offers
31 an OpenAI-compatible API endpoint (Google, OpenRouter, Groq, Together, etc.)
32 """
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 )
48 @classmethod
49 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
50 """Factory function for OpenAI-compatible LLMs.
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
57 Returns:
58 A configured ChatOpenAI instance
60 Raises:
61 ValueError: If API key is not configured
62 """
63 from ...config.thread_settings import (
64 _get_optional_setting,
65 NoSettingsContextError,
66 )
68 settings_snapshot = kwargs.get("settings_snapshot")
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)
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 )
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
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 )
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 }
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 )
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
127 # Add streaming if specified
128 _get_optional_setting(
129 llm_params,
130 "streaming",
131 "llm.streaming",
132 settings_snapshot,
133 )
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)
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"]
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 )
155 return ChatOpenAI(**llm_params)
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.
161 This bypasses API key checking for providers that handle auth differently.
162 """
163 from ...config.thread_settings import NoSettingsContextError
165 settings_snapshot = kwargs.get("settings_snapshot")
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 )
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
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 )
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
191 api_key = kwargs.get("api_key", OPTIONAL_API_KEY_PLACEHOLDER)
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 }
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 )
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
221 llm_params["request_timeout"] = build_timeout(
222 resolve_request_timeout(settings_snapshot)
223 )
224 llm_params["max_retries"] = resolve_max_retries(settings_snapshot)
226 return ChatOpenAI(**llm_params)
228 @classmethod
229 def is_available(cls, settings_snapshot=None):
230 """Check if this provider is available.
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.
240 Args:
241 settings_snapshot: Optional settings snapshot to use
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)
254 @classmethod
255 def requires_auth_for_models(cls):
256 """Check if this provider requires authentication for listing models.
258 Override in subclasses that don't require auth.
260 Returns:
261 True if authentication is required, False otherwise
262 """
263 return True
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.
270 Reads from url_setting if defined, otherwise uses default_base_url.
272 Args:
273 settings_snapshot: Optional settings snapshot dict
275 Returns:
276 The base URL string to use for model listing
277 """
278 from ...config.thread_settings import get_setting_from_snapshot
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("/")
291 return cls.default_base_url
293 @classmethod
294 def list_models_for_api(cls, api_key=None, base_url=None):
295 """List available models for API endpoint use.
297 This method is designed to be called from Flask routes.
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)
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 []
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()
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"
336 from openai import OpenAI, Timeout
338 # Use provided base_url or fall back to class default
339 if not base_url:
340 base_url = cls.default_base_url
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 []
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 )
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()
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 )
395 logger.info(f"Found {len(models)} models from {cls.provider_name}")
396 return models
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 []
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.
411 Args:
412 settings_snapshot: Optional settings snapshot to use
414 Returns:
415 List of model dictionaries with 'value' and 'label' keys
416 """
417 from ...config.thread_settings import get_setting_from_snapshot
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 )
429 # Get base URL from settings if provider has configurable URL
430 base_url = cls._get_base_url_for_models(settings_snapshot)
432 return cls.list_models_for_api(api_key, base_url)
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 []