Coverage for src/local_deep_research/llm/providers/implementations/openai.py: 100%
45 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"""OpenAI LLM 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# create_llm() so test patches at the source module
9# (`local_deep_research.config.thread_settings`) are picked up by the
10# function-local import at call time. Module-level binding here would be
11# unaffected by the source-module patch.
12from ....security.ssrf_validator import assert_base_url_safe
13from ..base import Exposure
14from ..openai_base import OpenAICompatibleProvider
15from .._helpers import (
16 build_timeout,
17 resolve_max_retries,
18 resolve_request_timeout,
19)
22class OpenAIProvider(OpenAICompatibleProvider):
23 """OpenAI provider for Local Deep Research.
25 This is the official OpenAI API provider.
26 """
28 provider_name = "OpenAI"
29 api_key_setting = "llm.openai.api_key"
30 default_model = "" # User must explicitly pick a model — no silent fallback
31 default_base_url = "https://api.openai.com/v1"
33 # Metadata for auto-discovery
34 provider_key = "OPENAI"
35 company_name = "OpenAI"
36 is_cloud = True
37 # Egress exposure (ADR-0007): cloud inference sink — data leaves the box.
38 egress_exposure = Exposure.EXPOSING
40 @classmethod
41 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
42 """Factory function for OpenAI LLMs.
44 Args:
45 model_name: Name of the model to use
46 temperature: Model temperature (0.0-1.0)
47 **kwargs: Additional arguments including settings_snapshot
49 Returns:
50 A configured ChatOpenAI instance
52 Raises:
53 ValueError: If API key is not configured
54 """
55 from ....config.thread_settings import (
56 _get_optional_setting,
57 get_setting_from_snapshot,
58 NoSettingsContextError,
59 )
61 settings_snapshot = kwargs.get("settings_snapshot")
63 # resolve_api_key raises ValueError when the required key is missing
64 # (preserves the legacy behavior with a unified error message).
65 api_key = cls.resolve_api_key(settings_snapshot)
67 # Require an explicit model — no silent fallback to a hardcoded default.
68 if not model_name or not model_name.strip():
69 logger.error(f"{cls.provider_name} model name not provided")
70 raise ValueError(
71 f"{cls.provider_name} model not configured. "
72 f"Please set llm.model in settings (e.g. 'gpt-4o-mini')."
73 )
75 # Build OpenAI-specific parameters
76 openai_params = {
77 "model": model_name,
78 "api_key": api_key,
79 "temperature": temperature,
80 }
82 # Add optional parameters if they exist in settings
83 try:
84 api_base = get_setting_from_snapshot(
85 "llm.openai.api_base",
86 default=None,
87 settings_snapshot=settings_snapshot,
88 )
89 if api_base:
90 # SSRF guard for operator-configurable api_base. OpenAIProvider
91 # has url_setting = None and overrides create_llm without
92 # calling super, so the base-class guard never runs here.
93 # ChatOpenAI uses its own httpx transport that bypasses
94 # safe_requests, so an attacker who can edit llm.openai.api_base
95 # could route inference at internal/cloud-credential endpoints.
96 # Let ValueError propagate (fail-fast) — at inference
97 # construction we fail rather than silently route traffic to a
98 # metadata endpoint, matching ollama.create_llm.
99 api_base = assert_base_url_safe(
100 api_base, setting_key="llm.openai.api_base"
101 )
102 openai_params["openai_api_base"] = api_base
103 except NoSettingsContextError:
104 pass # Optional parameter
106 # organization uses the falsy check intentionally — an empty string
107 # must be dropped rather than forwarded to the OpenAI client.
108 _get_optional_setting(
109 openai_params,
110 "openai_organization",
111 "llm.openai.organization",
112 settings_snapshot,
113 check="falsy",
114 )
116 _get_optional_setting(
117 openai_params,
118 "streaming",
119 "llm.streaming",
120 settings_snapshot,
121 )
123 # Apply context-window-aware max_tokens cap (was previously only
124 # applied in dead code in llm_config.get_llm).
125 from .._helpers import (
126 compute_max_tokens,
127 get_context_window_for_provider,
128 )
130 try:
131 context_window_size = get_context_window_for_provider(
132 "openai", settings_snapshot=settings_snapshot
133 )
134 max_tokens = compute_max_tokens(
135 settings_snapshot=settings_snapshot,
136 context_window_size=context_window_size,
137 )
138 if max_tokens: # Treat 0 as unset (matches legacy behavior)
139 openai_params["max_tokens"] = max_tokens
140 except NoSettingsContextError:
141 pass # Optional parameter
143 # Bounded per-operation timeout and retry count (see
144 # ``_helpers.build_timeout``). Both are always set: langchain
145 # otherwise forwards an explicit ``timeout=None`` that overrides
146 # the SDK's own default, leaving reads unbounded.
147 openai_params["request_timeout"] = build_timeout(
148 resolve_request_timeout(settings_snapshot)
149 )
150 openai_params["max_retries"] = resolve_max_retries(settings_snapshot)
152 logger.info(
153 f"Creating {cls.provider_name} LLM with model: {model_name}, "
154 f"temperature: {temperature}"
155 )
157 return ChatOpenAI(**openai_params)