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

1"""OpenAI embedding provider.""" 

2 

3from typing import Any, Dict, Final, 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 

15_DEFAULT_CHUNK_SIZE: Final[int] = 5 

16 

17 

18class OpenAIEmbeddingsProvider(BaseEmbeddingProvider): 

19 """ 

20 OpenAI embedding provider. 

21 

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

29 

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" 

44 

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. 

54 

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

59 

60 Returns: 

61 OpenAIEmbeddings instance 

62 

63 Raises: 

64 ValueError: If API key is not configured 

65 """ 

66 from langchain_openai import OpenAIEmbeddings 

67 

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 ) 

78 

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 ) 

86 

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 ) 

107 

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 ) 

115 

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 ) 

123 

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 

133 

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 } 

142 

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 

158 

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

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

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

162 

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 

173 

174 embeddings = OpenAIEmbeddings(**params) 

175 logger.info( 

176 "Creating OpenAIEmbeddings with model={}, chunk_size={}", 

177 model, 

178 embeddings.chunk_size, 

179 ) 

180 return embeddings 

181 

182 @classmethod 

183 def is_available( 

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

185 ) -> bool: 

186 """Check if OpenAI embeddings are available. 

187 

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 

218 

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. 

224 

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 

240 

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 ) 

251 

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

260 

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

266 

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

276 

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 

283 

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