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

1"""OpenAI embedding provider.""" 

2 

3import os 

4from typing import Any, Dict, Final, List, Optional 

5from urllib.parse import urlparse 

6 

7from langchain_core.embeddings import Embeddings 

8from ....security.secure_logging import logger 

9 

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 

17 

18 

19_DEFAULT_CHUNK_SIZE: Final[int] = 5 

20 

21 

22class OpenAIEmbeddingsProvider(BaseEmbeddingProvider): 

23 """ 

24 OpenAI embedding provider. 

25 

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 """ 

33 

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" 

48 

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. 

58 

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) 

64 

65 Returns: 

66 OpenAIEmbeddings instance 

67 

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 

76 

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 ) 

94 

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 ) 

102 

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 ) 

123 

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 ) 

131 

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 ) 

139 

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 

149 

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) 

156 

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 ) 

169 

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) 

183 

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 ) 

189 

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 } 

198 

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 ) 

216 

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 

270 

271 # For text-embedding-3 models, dimensions can be customized 

272 if embedding_dimensions is not None: 

273 params["dimensions"] = embedding_dimensions 

274 

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 

286 

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 

302 

303 @classmethod 

304 def is_available( 

305 cls, settings_snapshot: Optional[Dict[str, Any]] = None 

306 ) -> bool: 

307 """Check if OpenAI embeddings are available. 

308 

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 

341 

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. 

347 

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 

365 

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 ) 

376 

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") 

381 

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 [] 

390 

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 ) 

398 

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() 

416 

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}) 

426 

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 

433 

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 []