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