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