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
« 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."""
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 # 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 )
38 @classmethod
39 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
40 """Factory function for OpenAI-compatible LLMs.
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
47 Returns:
48 A configured ChatOpenAI instance
50 Raises:
51 ValueError: If API key is not configured
52 """
53 from ...config.thread_settings import (
54 _get_optional_setting,
55 NoSettingsContextError,
56 )
58 settings_snapshot = kwargs.get("settings_snapshot")
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)
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 )
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
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 )
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 }
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 )
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
117 # Add streaming if specified
118 _get_optional_setting(
119 llm_params,
120 "streaming",
121 "llm.streaming",
122 settings_snapshot,
123 )
125 # Add max_retries if specified
126 _get_optional_setting(
127 llm_params,
128 "max_retries",
129 "llm.max_retries",
130 settings_snapshot,
131 )
133 # Add request_timeout if specified
134 _get_optional_setting(
135 llm_params,
136 "request_timeout",
137 "llm.request_timeout",
138 settings_snapshot,
139 )
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"]
147 logger.info(
148 f"Creating {cls.provider_name} LLM with model: {model_name}, "
149 f"temperature: {temperature}, endpoint: {base_url}"
150 )
152 return ChatOpenAI(**llm_params)
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.
158 This bypasses API key checking for providers that handle auth differently.
159 """
160 from ...config.thread_settings import NoSettingsContextError
162 settings_snapshot = kwargs.get("settings_snapshot")
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 )
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
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 )
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
188 api_key = kwargs.get("api_key", OPTIONAL_API_KEY_PLACEHOLDER)
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 }
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 )
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
218 return ChatOpenAI(**llm_params)
220 @classmethod
221 def is_available(cls, settings_snapshot=None):
222 """Check if this provider is available.
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.
232 Args:
233 settings_snapshot: Optional settings snapshot to use
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)
246 @classmethod
247 def requires_auth_for_models(cls):
248 """Check if this provider requires authentication for listing models.
250 Override in subclasses that don't require auth.
252 Returns:
253 True if authentication is required, False otherwise
254 """
255 return True
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.
262 Reads from url_setting if defined, otherwise uses default_base_url.
264 Args:
265 settings_snapshot: Optional settings snapshot dict
267 Returns:
268 The base URL string to use for model listing
269 """
270 from ...config.thread_settings import get_setting_from_snapshot
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("/")
283 return cls.default_base_url
285 @classmethod
286 def list_models_for_api(cls, api_key=None, base_url=None):
287 """List available models for API endpoint use.
289 This method is designed to be called from Flask routes.
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)
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 []
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"
322 from openai import OpenAI
324 # Use provided base_url or fall back to class default
325 if not base_url:
326 base_url = cls.default_base_url
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 []
348 # Create OpenAI client (uses library defaults for timeout)
349 client = OpenAI(api_key=api_key, base_url=base_url)
351 # Fetch models
352 logger.debug(
353 f"Fetching models from {cls.provider_name} at {base_url}"
354 )
355 models_response = client.models.list()
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 )
367 logger.info(f"Found {len(models)} models from {cls.provider_name}")
368 return models
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 []
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.
383 Args:
384 settings_snapshot: Optional settings snapshot to use
386 Returns:
387 List of model dictionaries with 'value' and 'label' keys
388 """
389 from ...config.thread_settings import get_setting_from_snapshot
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 )
401 # Get base URL from settings if provider has configurable URL
402 base_url = cls._get_base_url_for_models(settings_snapshot)
404 return cls.list_models_for_api(api_key, base_url)
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 []