Coverage for src/local_deep_research/embeddings/providers/implementations/openai.py: 99%
99 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"""OpenAI embedding provider."""
3from typing import Any, Dict, Final, List, Optional
4from urllib.parse import urlparse
6from langchain_core.embeddings import Embeddings
7from ....security.secure_logging import logger
9from ....config.thread_settings import get_setting_from_snapshot
10from ....security.log_sanitizer import redact_secrets
11from ....utilities.url_utils import normalize_url
12from ..base import BaseEmbeddingProvider, Exposure
15_DEFAULT_CHUNK_SIZE: Final[int] = 5
18class OpenAIEmbeddingsProvider(BaseEmbeddingProvider):
19 """
20 OpenAI embedding provider.
22 Targets the OpenAI cloud API by default, and any OpenAI-compatible
23 endpoint (LM Studio, vLLM, llama.cpp server, etc.) when
24 ``embeddings.openai.base_url`` is configured. An API key is required
25 for the cloud, but optional for keyless local servers — the
26 ``base_url``-set, ``api_key``-empty configuration falls back to a
27 placeholder key so the OpenAI client request still goes out.
28 """
30 provider_name = "OpenAI"
31 provider_key = "OPENAI"
32 # Not strictly required: the OpenAI cloud needs a key, but
33 # OpenAI-compatible local servers (LM Studio, vLLM, llama.cpp)
34 # don't. ``is_available`` and ``create_embeddings`` enforce the
35 # cloud-needs-key rule at runtime when no base_url is set.
36 # Inherits ``requires_api_key = False`` from BaseEmbeddingProvider.
37 supports_local = False
38 egress_exposure = Exposure.EXPOSING
39 default_model = "text-embedding-3-small" # type: ignore[assignment]
40 # Placeholder key used when targeting an OpenAI-compatible local
41 # server (api_key empty, base_url set). Mirrors the LLM-side
42 # LMStudio provider's keyless-fallback pattern.
43 _PLACEHOLDER_API_KEY = "lm-studio"
45 @classmethod
46 def create_embeddings(
47 cls,
48 model: Optional[str] = None,
49 settings_snapshot: Optional[Dict[str, Any]] = None,
50 **kwargs,
51 ) -> Embeddings:
52 """
53 Create OpenAI embeddings instance.
55 Args:
56 model: Model name (defaults to text-embedding-3-small)
57 settings_snapshot: Optional settings snapshot
58 **kwargs: Additional parameters (api_key, etc.)
60 Returns:
61 OpenAIEmbeddings instance
63 Raises:
64 ValueError: If API key is not configured
65 """
66 from langchain_openai import OpenAIEmbeddings
68 # Get API key + base_url. Read base_url first so we can decide
69 # whether a missing api_key is fatal (cloud) or just a keyless
70 # local-server signal (OpenAI-compatible endpoint).
71 base_url = kwargs.get("base_url")
72 if base_url is None:
73 base_url = get_setting_from_snapshot(
74 "embeddings.openai.base_url",
75 default=None,
76 settings_snapshot=settings_snapshot,
77 )
79 api_key = kwargs.get("api_key")
80 if api_key is None:
81 api_key = get_setting_from_snapshot(
82 "embeddings.openai.api_key",
83 default=None,
84 settings_snapshot=settings_snapshot,
85 )
87 if not api_key:
88 if base_url:
89 # OpenAI-compatible local server (LM Studio, vLLM,
90 # llama.cpp). The server ignores the key but the
91 # OpenAI client requires the field to be non-empty.
92 logger.info(
93 "OpenAI embeddings: no API key set but base_url={} "
94 "is configured — using placeholder key for the "
95 "OpenAI-compatible endpoint.",
96 base_url,
97 )
98 api_key = cls._PLACEHOLDER_API_KEY
99 else:
100 logger.error("OpenAI API key not found in settings")
101 raise ValueError(
102 "OpenAI API key not configured. "
103 "Please set embeddings.openai.api_key in settings, "
104 "or set embeddings.openai.base_url to point at an "
105 "OpenAI-compatible local server."
106 )
108 # Get model from settings if not specified
109 if model is None:
110 model = get_setting_from_snapshot(
111 "embeddings.openai.model",
112 default=cls.default_model,
113 settings_snapshot=settings_snapshot,
114 )
116 dimensions = kwargs.get("dimensions")
117 if dimensions is None:
118 dimensions = get_setting_from_snapshot(
119 "embeddings.openai.dimensions",
120 default=None,
121 settings_snapshot=settings_snapshot,
122 )
124 chunk_size = kwargs.get("chunk_size")
125 if chunk_size is None:
126 chunk_size = get_setting_from_snapshot(
127 "embeddings.openai.chunk_size",
128 default=_DEFAULT_CHUNK_SIZE,
129 settings_snapshot=settings_snapshot,
130 )
131 if chunk_size is None:
132 chunk_size = _DEFAULT_CHUNK_SIZE
134 # Build parameters. Annotated as Dict[str, Any] so the
135 # heterogeneous values (str for model/key/base_url, int for
136 # dimensions) and the **params unpack into OpenAIEmbeddings
137 # type-check under mypy.
138 params: Dict[str, Any] = {
139 "model": model,
140 "openai_api_key": api_key,
141 }
143 if base_url:
144 # Normalize first so a scheme-less entry like "api.openai.com"
145 # parses to a hostname (urlparse otherwise returns hostname=None
146 # for bare hosts, which would silently drop the ctx-length guard
147 # for the real OpenAI endpoint). Mirrors the LLM-side OpenAI
148 # provider, which already normalizes via the same helper.
149 base_url = normalize_url(base_url)
150 params["openai_api_base"] = base_url
151 # Disable client-side context length checks only for non-OpenAI
152 # hosts (LM Studio, vLLM, llama.cpp, etc.) which may lack tiktoken
153 # model entries or reject tokenized inputs. Keep the LangChain
154 # default for api.openai.com so the guard stays in place for users
155 # who set base_url explicitly to the real OpenAI endpoint.
156 if urlparse(base_url).hostname != "api.openai.com":
157 params["check_embedding_ctx_length"] = False
159 # For text-embedding-3 models, dimensions can be customized
160 if dimensions and model.startswith("text-embedding-3"):
161 params["dimensions"] = int(dimensions)
163 effective_chunk_size = int(chunk_size)
164 if effective_chunk_size <= 0:
165 logger.warning(
166 "OpenAI embeddings chunk_size={} is not positive; "
167 "using safe default chunk_size={}",
168 chunk_size,
169 _DEFAULT_CHUNK_SIZE,
170 )
171 effective_chunk_size = _DEFAULT_CHUNK_SIZE
172 params["chunk_size"] = effective_chunk_size
174 embeddings = OpenAIEmbeddings(**params)
175 logger.info(
176 "Creating OpenAIEmbeddings with model={}, chunk_size={}",
177 model,
178 embeddings.chunk_size,
179 )
180 return embeddings
182 @classmethod
183 def is_available(
184 cls, settings_snapshot: Optional[Dict[str, Any]] = None
185 ) -> bool:
186 """Check if OpenAI embeddings are available.
188 Available when either an API key (cloud) or a custom base URL
189 (OpenAI-compatible local server) is configured. A blank
190 installation still reports unavailable so the UI doesn't list
191 the provider on first launch.
192 """
193 api_key = None
194 try:
195 api_key = get_setting_from_snapshot(
196 "embeddings.openai.api_key",
197 default=None,
198 settings_snapshot=settings_snapshot,
199 )
200 if api_key and str(api_key).strip():
201 return True
202 base_url = get_setting_from_snapshot(
203 "embeddings.openai.base_url",
204 default=None,
205 settings_snapshot=settings_snapshot,
206 )
207 return bool(base_url and str(base_url).strip())
208 except Exception as e:
209 # Drop exc_info — the traceback would render the exception's
210 # cause chain, which may embed the api_key value if a
211 # settings-layer error message ever surfaces it. Interpolate a
212 # redacted exception message so debugging is still possible.
213 safe_msg = redact_secrets(str(e), api_key)
214 logger.debug(
215 f"Error checking OpenAI embedding availability: {safe_msg}"
216 )
217 return False
219 @classmethod
220 def get_available_models(
221 cls, settings_snapshot: Optional[Dict[str, Any]] = None
222 ) -> List[Dict[str, Any]]:
223 """Get every model the configured endpoint reports.
225 No filtering: ``/v1/models`` doesn't expose a reliable "is this
226 an embedding model?" signal — neither cloud OpenAI nor
227 OpenAI-compatible local servers (LM Studio, vLLM, llama.cpp).
228 Earlier versions guessed from the model name and ended up
229 hiding real embedding models whose names didn't match the
230 heuristic (e.g. ``nomic-embed-text-v1.5`` was dropped because
231 it lacks the trailing ``-ing``). The dropdown now shows every
232 model the endpoint returns so the user can pick the one they
233 actually loaded.
234 """
235 # Initialized before the try so the except block can redact it
236 # even if an early statement (e.g. the openai import) raises.
237 api_key = None
238 try:
239 from openai import OpenAI
241 api_key = get_setting_from_snapshot(
242 "embeddings.openai.api_key",
243 default=None,
244 settings_snapshot=settings_snapshot,
245 )
246 base_url = get_setting_from_snapshot(
247 "embeddings.openai.base_url",
248 default=None,
249 settings_snapshot=settings_snapshot,
250 )
252 if not api_key:
253 if base_url:
254 # Keyless OpenAI-compatible local server — use a
255 # placeholder so the client request can proceed.
256 api_key = cls._PLACEHOLDER_API_KEY
257 else:
258 logger.warning("OpenAI API key not configured")
259 return []
261 client_kwargs: Dict[str, Any] = {"api_key": api_key}
262 if base_url:
263 client_kwargs["base_url"] = normalize_url(base_url)
264 client = OpenAI(**client_kwargs)
265 models_response = client.models.list()
267 # No name-based filtering — see method docstring (#4195).
268 models: List[Dict[str, Any]] = []
269 for model in models_response.data:
270 model_id = model.id
271 # Skip only blank ids (malformed entry); never skip
272 # based on what the name looks like.
273 if not model_id: 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 continue
275 models.append({"value": model_id, "label": model_id})
277 logger.info(
278 "Fetched {} models from OpenAI endpoint{}",
279 len(models),
280 f" at {base_url}" if base_url else "",
281 )
282 return models
284 except Exception as e:
285 # Use logger.warning rather than logger.exception so the
286 # exception's cause chain (which may embed the api_key in a
287 # URL or auth header echoed back in the error body) is not
288 # written to log sinks. The api_key value is also redacted
289 # from str(e) as defense-in-depth.
290 safe_msg = redact_secrets(str(e), api_key)
291 logger.warning(
292 f"Error fetching OpenAI embedding models: {safe_msg}"
293 )
294 return []