Coverage for src/local_deep_research/embeddings/providers/implementations/openai.py: 98%
160 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 embedding provider."""
3import os
4from typing import Any, Dict, Final, List, Optional
5from urllib.parse import urlparse
7from langchain_core.embeddings import Embeddings
8from ....security.secure_logging import logger
10from ....config.thread_settings import get_setting_from_snapshot
11from ....security.log_sanitizer import redact_secrets
12from ....security.ssrf_validator import assert_base_url_safe
13from ....security.url_redaction import redact_url_credentials
14from ....settings.exceptions import SettingsValueError
15from ....utilities.url_utils import normalize_url
16from ..base import BaseEmbeddingProvider, Exposure
19_DEFAULT_CHUNK_SIZE: Final[int] = 5
22class OpenAIEmbeddingsProvider(BaseEmbeddingProvider):
23 """
24 OpenAI embedding provider.
26 Targets the OpenAI cloud API by default, and any OpenAI-compatible
27 endpoint (LM Studio, vLLM, llama.cpp server, etc.) when
28 ``embeddings.openai.base_url`` is configured. An API key is required
29 for the cloud, but optional for keyless local servers — the
30 ``base_url``-set, ``api_key``-empty configuration falls back to a
31 placeholder key so the OpenAI client request still goes out.
32 """
34 provider_name = "OpenAI"
35 provider_key = "OPENAI"
36 # Not strictly required: the OpenAI cloud needs a key, but
37 # OpenAI-compatible local servers (LM Studio, vLLM, llama.cpp)
38 # don't. ``is_available`` and ``create_embeddings`` enforce the
39 # cloud-needs-key rule at runtime when no base_url is set.
40 # Inherits ``requires_api_key = False`` from BaseEmbeddingProvider.
41 supports_local = False
42 egress_exposure = Exposure.EXPOSING
43 default_model = "text-embedding-3-small" # type: ignore[assignment]
44 # Placeholder key used when targeting an OpenAI-compatible local
45 # server (api_key empty, base_url set). Mirrors the LLM-side
46 # LMStudio provider's keyless-fallback pattern.
47 _PLACEHOLDER_API_KEY = "lm-studio"
49 @classmethod
50 def create_embeddings(
51 cls,
52 model: Optional[str] = None,
53 settings_snapshot: Optional[Dict[str, Any]] = None,
54 **kwargs,
55 ) -> Embeddings:
56 """
57 Create OpenAI embeddings instance.
59 Args:
60 model: Model name (defaults to text-embedding-3-small)
61 settings_snapshot: Optional settings snapshot
62 **kwargs: Additional parameters (api_key, base_url,
63 dimensions, chunk_size, openai_proxy)
65 Returns:
66 OpenAIEmbeddings instance
68 Raises:
69 ValueError: If API key is not configured, or if a custom
70 base_url fails SSRF validation (a blocked address, or a
71 host the local resolver cannot resolve -- the check
72 resolves locally even when a proxy is configured)
73 """
74 from langchain_openai import OpenAIEmbeddings
75 from openai import DefaultAsyncHttpxClient, DefaultHttpxClient, Timeout
77 # Get API key + base_url. Read base_url first so we can decide
78 # whether a missing api_key is fatal (cloud) or just a keyless
79 # local-server signal (OpenAI-compatible endpoint).
80 base_url = kwargs.get("base_url")
81 if base_url is None:
82 base_url = get_setting_from_snapshot(
83 "embeddings.openai.base_url",
84 default=None,
85 settings_snapshot=settings_snapshot,
86 )
87 if not base_url:
88 # Resolve the same fallbacks as LangChain and then the OpenAI
89 # SDK before validation and client construction. Otherwise an
90 # environment endpoint bypasses the custom-URL protections.
91 base_url = os.environ.get("OPENAI_API_BASE") or os.environ.get(
92 "OPENAI_BASE_URL"
93 )
95 api_key = kwargs.get("api_key")
96 if api_key is None:
97 api_key = get_setting_from_snapshot(
98 "embeddings.openai.api_key",
99 default=None,
100 settings_snapshot=settings_snapshot,
101 )
103 if not api_key:
104 if base_url:
105 # OpenAI-compatible local server (LM Studio, vLLM,
106 # llama.cpp). The server ignores the key but the
107 # OpenAI client requires the field to be non-empty.
108 logger.info(
109 "OpenAI embeddings: no API key set but base_url={} "
110 "is configured — using placeholder key for the "
111 "OpenAI-compatible endpoint.",
112 redact_url_credentials(base_url),
113 )
114 api_key = cls._PLACEHOLDER_API_KEY
115 else:
116 logger.error("OpenAI API key not found in settings")
117 raise ValueError(
118 "OpenAI API key not configured. "
119 "Please set embeddings.openai.api_key in settings, "
120 "or set embeddings.openai.base_url to point at an "
121 "OpenAI-compatible local server."
122 )
124 # Get model from settings if not specified
125 if model is None:
126 model = get_setting_from_snapshot(
127 "embeddings.openai.model",
128 default=cls.default_model,
129 settings_snapshot=settings_snapshot,
130 )
132 dimensions = kwargs.get("dimensions")
133 if dimensions is None:
134 dimensions = get_setting_from_snapshot(
135 "embeddings.openai.dimensions",
136 default=None,
137 settings_snapshot=settings_snapshot,
138 )
140 chunk_size = kwargs.get("chunk_size")
141 if chunk_size is None:
142 chunk_size = get_setting_from_snapshot(
143 "embeddings.openai.chunk_size",
144 default=_DEFAULT_CHUNK_SIZE,
145 settings_snapshot=settings_snapshot,
146 )
147 if chunk_size is None:
148 chunk_size = _DEFAULT_CHUNK_SIZE
150 embedding_dimensions = None
151 if dimensions and model.startswith("text-embedding-3"):
152 embedding_dimensions = int(dimensions)
153 # Convert early so invalid numeric settings raise before any
154 # custom HTTP client is constructed in the base_url branch below.
155 embedding_chunk_size = int(chunk_size)
157 # Same bound as the LLM side: langchain otherwise forwards an
158 # explicit ``timeout=None`` that overrides the SDK default, leaving
159 # embedding reads unbounded, and the SDK's own 2 retries would
160 # multiply the wait. Imported lazily — the ``llm.providers``
161 # package runs provider auto-discovery at import time. Resolved
162 # here, before the base_url branch builds any HTTP client, so a
163 # bad timeout/retry setting cannot leak those clients.
164 from ....llm.providers._helpers import (
165 build_httpx_timeout,
166 resolve_max_retries,
167 resolve_request_timeout,
168 )
170 # An SDK ``Timeout`` object, not the chat path's hashable tuple:
171 # ``OpenAIEmbeddings`` never consults langchain's ``@lru_cache``'d
172 # ``_get_default_httpx_client``, so every instance owns its httpx
173 # clients whatever the timeout's type. The object keeps the SDK's
174 # informational ``x-stainless-read-timeout`` header well formed
175 # while the effective bounds stay identical. ``openai.Timeout`` is
176 # the httpx2 flavour the SDK ships; ``httpx.Timeout`` is a
177 # different class and would not be recognised. The SDK applies an
178 # explicit ``timeout`` even when an ``http_client`` is injected.
179 request_timeout = build_httpx_timeout(
180 resolve_request_timeout(settings_snapshot), Timeout
181 )
182 max_retries = resolve_max_retries(settings_snapshot)
184 # Proxy precedence matches langchain's own ``openai_proxy`` field:
185 # an explicit kwarg wins, else the ``OPENAI_PROXY`` env var.
186 openai_proxy = kwargs.get("openai_proxy") or os.environ.get(
187 "OPENAI_PROXY"
188 )
190 # Build parameters. Annotated as Dict[str, Any] so the
191 # heterogeneous values (str for model/key/base_url, int for
192 # dimensions) and the **params unpack into OpenAIEmbeddings
193 # type-check under mypy.
194 params: Dict[str, Any] = {
195 "model": model,
196 "openai_api_key": api_key,
197 }
199 http_client = None
200 http_async_client = None
201 if base_url:
202 # Normalize first so a scheme-less entry like "api.openai.com"
203 # parses to a hostname (urlparse otherwise returns hostname=None
204 # for bare hosts, which would silently drop the ctx-length guard
205 # for the real OpenAI endpoint). Mirrors the LLM-side OpenAI
206 # provider, which already normalizes via the same helper.
207 base_url = assert_base_url_safe(
208 normalize_url(base_url),
209 setting_key="embeddings.openai.base_url",
210 )
211 params["openai_api_base"] = base_url
212 from .owned_openai_embeddings import (
213 OwnedOpenAIEmbeddings,
214 close_openai_embedding_clients,
215 )
217 # Own the HTTP clients for a custom base_url so redirects are
218 # refused explicitly: the SDK's default client follows them,
219 # which would let an allowed host bounce embedding requests
220 # (and the bearer key) to a metadata endpoint that
221 # assert_base_url_safe() above refuses directly.
222 #
223 # A configured proxy is routed through these same clients
224 # rather than through langchain's ``openai_proxy`` wiring:
225 # langchain raises if ``openai_proxy`` and an explicit
226 # ``http_client`` are both set, and its ``openai_proxy``
227 # field reads ``OPENAI_PROXY`` from the env by default, so it
228 # is pinned to None below. Every custom-base_url instance
229 # therefore gets follow_redirects=False, proxy or not, and
230 # its clients are closed by OwnedOpenAIEmbeddings.close().
231 #
232 # Neither path does DNS pinning -- assert_base_url_safe()
233 # validates once at construction time; like every other LLM
234 # SDK integration (see SECURITY.md "LLM Provider URL
235 # Validation"), the OpenAI SDK's HTTP client re-resolves the
236 # hostname on every call. Egress restriction at the firewall
237 # is the operator-side mitigation for that residual window.
238 # The check also resolves with the LOCAL resolver, so the
239 # host must resolve locally even under a proxy; with a proxy
240 # it is advisory, since the proxy resolves the name itself.
241 client_options: Dict[str, Any] = {"follow_redirects": False}
242 if openai_proxy:
243 client_options["proxy"] = openai_proxy
244 http_client = DefaultHttpxClient(**client_options)
245 try:
246 http_async_client = DefaultAsyncHttpxClient(**client_options)
247 except Exception:
248 try:
249 http_client.close()
250 except Exception:
251 logger.warning(
252 "Failed to close OpenAI embeddings sync httpx client"
253 )
254 raise
255 params["http_client"] = http_client
256 params["http_async_client"] = http_async_client
257 params["openai_proxy"] = None
258 # Disable client-side context length checks only for non-OpenAI
259 # hosts (LM Studio, vLLM, llama.cpp, etc.) which may lack tiktoken
260 # model entries or reject tokenized inputs. Keep the LangChain
261 # default for api.openai.com so the guard stays in place for users
262 # who set base_url explicitly to the real OpenAI endpoint.
263 if urlparse(base_url).hostname != "api.openai.com":
264 params["check_embedding_ctx_length"] = False
265 elif openai_proxy:
266 # Fixed public endpoint: langchain builds the proxy-routed
267 # client itself. Forwarded explicitly so a proxy passed as a
268 # kwarg is not dropped (langchain only reads the env var).
269 params["openai_proxy"] = openai_proxy
271 # For text-embedding-3 models, dimensions can be customized
272 if embedding_dimensions is not None:
273 params["dimensions"] = embedding_dimensions
275 if embedding_chunk_size <= 0:
276 logger.warning(
277 "OpenAI embeddings chunk_size={} is not positive; "
278 "using safe default chunk_size={}",
279 chunk_size,
280 _DEFAULT_CHUNK_SIZE,
281 )
282 embedding_chunk_size = _DEFAULT_CHUNK_SIZE
283 params["chunk_size"] = embedding_chunk_size
284 params["request_timeout"] = request_timeout
285 params["max_retries"] = max_retries
287 embeddings: Embeddings
288 if http_client is not None:
289 try:
290 embeddings = OwnedOpenAIEmbeddings(**params)
291 except Exception:
292 close_openai_embedding_clients(http_client, http_async_client)
293 raise
294 else:
295 embeddings = OpenAIEmbeddings(**params)
296 logger.info(
297 "Creating OpenAIEmbeddings with model={}, chunk_size={}",
298 model,
299 embeddings.chunk_size,
300 )
301 return embeddings
303 @classmethod
304 def is_available(
305 cls, settings_snapshot: Optional[Dict[str, Any]] = None
306 ) -> bool:
307 """Check if OpenAI embeddings are available.
309 Available when either an API key (cloud) or a custom base URL
310 (OpenAI-compatible local server) is configured. A blank
311 installation still reports unavailable so the UI doesn't list
312 the provider on first launch.
313 """
314 api_key = None
315 try:
316 api_key = get_setting_from_snapshot(
317 "embeddings.openai.api_key",
318 default=None,
319 settings_snapshot=settings_snapshot,
320 )
321 if api_key and str(api_key).strip():
322 return True
323 base_url = get_setting_from_snapshot(
324 "embeddings.openai.base_url",
325 default=None,
326 settings_snapshot=settings_snapshot,
327 )
328 return bool(base_url and str(base_url).strip())
329 except SettingsValueError:
330 raise
331 except Exception as e:
332 # Drop exc_info — the traceback would render the exception's
333 # cause chain, which may embed the api_key value if a
334 # settings-layer error message ever surfaces it. Interpolate a
335 # redacted exception message so debugging is still possible.
336 safe_msg = redact_secrets(str(e), api_key)
337 logger.debug(
338 f"Error checking OpenAI embedding availability: {safe_msg}"
339 )
340 return False
342 @classmethod
343 def get_available_models(
344 cls, settings_snapshot: Optional[Dict[str, Any]] = None
345 ) -> List[Dict[str, Any]]:
346 """Get every model the configured endpoint reports.
348 No filtering: ``/v1/models`` doesn't expose a reliable "is this
349 an embedding model?" signal — neither cloud OpenAI nor
350 OpenAI-compatible local servers (LM Studio, vLLM, llama.cpp).
351 Earlier versions guessed from the model name and ended up
352 hiding real embedding models whose names didn't match the
353 heuristic (e.g. ``nomic-embed-text-v1.5`` was dropped because
354 it lacks the trailing ``-ing``). The dropdown now shows every
355 model the endpoint returns so the user can pick the one they
356 actually loaded.
357 """
358 # Initialized before the try so the except block can redact it
359 # even if an early statement (e.g. the openai import) raises.
360 api_key = None
361 http_client = None
362 client_entered = False
363 try:
364 from openai import DefaultHttpxClient, OpenAI, Timeout
366 api_key = get_setting_from_snapshot(
367 "embeddings.openai.api_key",
368 default=None,
369 settings_snapshot=settings_snapshot,
370 )
371 base_url = get_setting_from_snapshot(
372 "embeddings.openai.base_url",
373 default=None,
374 settings_snapshot=settings_snapshot,
375 )
377 if not base_url:
378 # Discovery uses the SDK directly; OPENAI_API_BASE is a
379 # LangChain-only fallback and does not apply here.
380 base_url = os.environ.get("OPENAI_BASE_URL")
382 if not api_key:
383 if base_url:
384 # Keyless OpenAI-compatible local server — use a
385 # placeholder so the client request can proceed.
386 api_key = cls._PLACEHOLDER_API_KEY
387 else:
388 logger.warning("OpenAI API key not configured")
389 return []
391 client_kwargs: Dict[str, Any] = {"api_key": api_key}
392 # Bounded discovery request — mirrors the LLM-side provider.
393 # Resolved before any HTTP client is built below.
394 from ....llm.providers._helpers import (
395 MODEL_DISCOVERY_TIMEOUT_SECONDS,
396 build_httpx_timeout,
397 )
399 # Fresh client per call, outside langchain's cached factory —
400 # see ``create_embeddings`` above for why the object form,
401 # not the tuple, is right here.
402 client_kwargs["timeout"] = build_httpx_timeout(
403 MODEL_DISCOVERY_TIMEOUT_SECONDS, Timeout
404 )
405 client_kwargs["max_retries"] = 0
406 if base_url:
407 client_kwargs["base_url"] = assert_base_url_safe(
408 normalize_url(base_url),
409 setting_key="embeddings.openai.base_url",
410 )
411 http_client = DefaultHttpxClient(follow_redirects=False)
412 client_kwargs["http_client"] = http_client
413 with OpenAI(**client_kwargs) as client:
414 client_entered = True
415 models_response = client.models.list()
417 # No name-based filtering — see method docstring (#4195).
418 models: List[Dict[str, Any]] = []
419 for model in models_response.data:
420 model_id = model.id
421 # Skip only blank ids (malformed entry); never skip
422 # based on what the name looks like.
423 if not model_id: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 continue
425 models.append({"value": model_id, "label": model_id})
427 logger.info(
428 "Fetched {} models from OpenAI endpoint{}",
429 len(models),
430 f" at {redact_url_credentials(base_url)}" if base_url else "",
431 )
432 return models
434 except SettingsValueError:
435 raise
436 except Exception as e:
437 if http_client is not None and not client_entered:
438 try:
439 http_client.close()
440 except Exception:
441 logger.warning(
442 "Failed to close OpenAI model discovery httpx client"
443 )
444 # Use logger.warning rather than logger.exception so the
445 # exception's cause chain (which may embed the api_key in a
446 # URL or auth header echoed back in the error body) is not
447 # written to log sinks. The api_key value is also redacted
448 # from str(e) as defense-in-depth.
449 safe_msg = redact_secrets(str(e), api_key)
450 logger.warning(
451 f"Error fetching OpenAI embedding models: {safe_msg}"
452 )
453 return []