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

1"""OpenAI embedding provider.""" 

2 

3from typing import Any, Dict, List, Optional 

4from urllib.parse import urlparse 

5 

6from langchain_core.embeddings import Embeddings 

7from ....security.secure_logging import logger 

8 

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 

13 

14 

15class OpenAIEmbeddingsProvider(BaseEmbeddingProvider): 

16 """ 

17 OpenAI embedding provider. 

18 

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

26 

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" 

41 

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. 

51 

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

56 

57 Returns: 

58 OpenAIEmbeddings instance 

59 

60 Raises: 

61 ValueError: If API key is not configured 

62 """ 

63 from langchain_openai import OpenAIEmbeddings 

64 

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 ) 

75 

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 ) 

83 

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 ) 

104 

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 ) 

112 

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 ) 

120 

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 ) 

128 

129 logger.info(f"Creating OpenAIEmbeddings with model={model}") 

130 

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 } 

139 

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 

155 

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

157 if dimensions and model.startswith("text-embedding-3"): 

158 params["dimensions"] = int(dimensions) 

159 

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) 

174 

175 return OpenAIEmbeddings(**params) 

176 

177 @classmethod 

178 def is_available( 

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

180 ) -> bool: 

181 """Check if OpenAI embeddings are available. 

182 

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 

213 

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. 

219 

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 

235 

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 ) 

246 

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

255 

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

261 

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

271 

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 

278 

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