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
« 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."""
3from langchain_openai import ChatOpenAI
4from ...security.secure_logging import logger
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
17class OpenAICompatibleProvider(BaseLLMProvider):
18 """Base class for OpenAI-compatible API providers.
20 This class provides a common implementation for any service that offers
21 an OpenAI-compatible API endpoint (Google, OpenRouter, Groq, Together, etc.)
22 """
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 )
33 @classmethod
34 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
35 """Factory function for OpenAI-compatible LLMs.
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
42 Returns:
43 A configured ChatOpenAI instance
45 Raises:
46 ValueError: If API key is not configured
47 """
48 from ...config.thread_settings import (
49 _get_optional_setting,
50 NoSettingsContextError,
51 )
53 settings_snapshot = kwargs.get("settings_snapshot")
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)
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 )
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
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 )
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 }
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 )
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
112 # Add streaming if specified
113 _get_optional_setting(
114 llm_params,
115 "streaming",
116 "llm.streaming",
117 settings_snapshot,
118 )
120 # Add max_retries if specified
121 _get_optional_setting(
122 llm_params,
123 "max_retries",
124 "llm.max_retries",
125 settings_snapshot,
126 )
128 # Add request_timeout if specified
129 _get_optional_setting(
130 llm_params,
131 "request_timeout",
132 "llm.request_timeout",
133 settings_snapshot,
134 )
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"]
142 logger.info(
143 f"Creating {cls.provider_name} LLM with model: {model_name}, "
144 f"temperature: {temperature}, endpoint: {base_url}"
145 )
147 return ChatOpenAI(**llm_params)
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.
153 This bypasses API key checking for providers that handle auth differently.
154 """
155 from ...config.thread_settings import NoSettingsContextError
157 settings_snapshot = kwargs.get("settings_snapshot")
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 )
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
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 )
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
183 api_key = kwargs.get("api_key", OPTIONAL_API_KEY_PLACEHOLDER)
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 }
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 )
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
213 return ChatOpenAI(**llm_params)
215 @classmethod
216 def is_available(cls, settings_snapshot=None):
217 """Check if this provider is available.
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.
227 Args:
228 settings_snapshot: Optional settings snapshot to use
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)
241 @classmethod
242 def requires_auth_for_models(cls):
243 """Check if this provider requires authentication for listing models.
245 Override in subclasses that don't require auth.
247 Returns:
248 True if authentication is required, False otherwise
249 """
250 return True
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.
257 Reads from url_setting if defined, otherwise uses default_base_url.
259 Args:
260 settings_snapshot: Optional settings snapshot dict
262 Returns:
263 The base URL string to use for model listing
264 """
265 from ...config.thread_settings import get_setting_from_snapshot
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("/")
278 return cls.default_base_url
280 @classmethod
281 def list_models_for_api(cls, api_key=None, base_url=None):
282 """List available models for API endpoint use.
284 This method is designed to be called from Flask routes.
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)
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 []
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"
317 from openai import OpenAI
319 # Use provided base_url or fall back to class default
320 if not base_url:
321 base_url = cls.default_base_url
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 []
343 # Create OpenAI client (uses library defaults for timeout)
344 client = OpenAI(api_key=api_key, base_url=base_url)
346 # Fetch models
347 logger.debug(
348 f"Fetching models from {cls.provider_name} at {base_url}"
349 )
350 models_response = client.models.list()
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 )
362 logger.info(f"Found {len(models)} models from {cls.provider_name}")
363 return models
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 []
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.
378 Args:
379 settings_snapshot: Optional settings snapshot to use
381 Returns:
382 List of model dictionaries with 'value' and 'label' keys
383 """
384 from ...config.thread_settings import get_setting_from_snapshot
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 )
396 # Get base URL from settings if provider has configurable URL
397 base_url = cls._get_base_url_for_models(settings_snapshot)
399 return cls.list_models_for_api(api_key, base_url)
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 []