Coverage for src/local_deep_research/embeddings/providers/implementations/sentence_transformers.py: 94%

207 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Sentence Transformers embedding provider.""" 

2 

3import errno 

4import re 

5from pathlib import Path 

6from typing import Any, Dict, List, Optional, Union 

7 

8from langchain_core.embeddings import Embeddings 

9from ....security.secure_logging import logger 

10 

11from ....config.thread_settings import get_setting_from_snapshot 

12from ...model_cache import shared_sentence_transformer_embeddings 

13from ...sentence_transformer_models import ( 

14 DEFAULT_SENTENCE_TRANSFORMER_MODEL, 

15 SENTENCE_TRANSFORMER_MODELS, 

16 get_sentence_transformer_model_spec, 

17) 

18from ..base import BaseEmbeddingProvider, Exposure 

19 

20 

21class SentenceTransformersProvider(BaseEmbeddingProvider): 

22 """ 

23 Sentence Transformers embedding provider. 

24 

25 Uses HuggingFace sentence-transformers models for local embeddings. 

26 No API key required, runs entirely locally. 

27 """ 

28 

29 provider_name = "Sentence Transformers" 

30 provider_key = "SENTENCE_TRANSFORMERS" 

31 requires_api_key = False 

32 supports_local = True 

33 egress_exposure = Exposure.CONTAINED 

34 default_model = DEFAULT_SENTENCE_TRANSFORMER_MODEL # type: ignore[assignment] 

35 

36 # Preserve the provider API's legacy metadata shape while centralizing the 

37 # curated allowlist and the default model's immutable revision. 

38 AVAILABLE_MODELS = { 

39 model_name: spec.display_metadata() 

40 for model_name, spec in SENTENCE_TRANSFORMER_MODELS.items() 

41 } 

42 

43 @classmethod 

44 def create_embeddings( 

45 cls, 

46 model: Optional[str] = None, 

47 settings_snapshot: Optional[Dict[str, Any]] = None, 

48 **kwargs, 

49 ) -> Embeddings: 

50 """ 

51 Create a Sentence Transformers embeddings handle. 

52 

53 Args: 

54 model: Model name (defaults to Alibaba-NLP/gte-modernbert-base) 

55 settings_snapshot: Optional settings snapshot 

56 **kwargs: Additional parameters (device, etc.) 

57 

58 Returns: 

59 A closeable handle to the process-wide resident model; it holds 

60 no weights itself. 

61 """ 

62 from langchain_community.embeddings import ( 

63 SentenceTransformerEmbeddings, 

64 ) 

65 

66 # Get model from settings if not specified 

67 if model is None: 

68 model = get_setting_from_snapshot( 

69 "embeddings.sentence_transformers.model", 

70 default=cls.default_model, 

71 settings_snapshot=settings_snapshot, 

72 ) 

73 

74 # Get device setting (cpu or cuda) 

75 device = kwargs.get("device") 

76 if device is None: 

77 device = get_setting_from_snapshot( 

78 "embeddings.sentence_transformers.device", 

79 default="cpu", 

80 settings_snapshot=settings_snapshot, 

81 ) 

82 

83 logger.info( 

84 f"Preparing local embeddings with model={model}, device={device}" 

85 ) 

86 

87 # Constructing a cache-missing model is itself an egress action. It is 

88 # allowed only under a resolved public scope (or the explicit 

89 # UNPROTECTED escape hatch), never merely because the local-embedding 

90 # flag is false. Snapshot-less, STRICT, PRIVATE_ONLY, and adaptive 

91 # contexts that resolve BOTH therefore remain cache-only. 

92 download_allowed = False 

93 if settings_snapshot is not None: 

94 try: 

95 from ....security.egress.policy import ( 

96 EgressScope, 

97 context_from_snapshot, 

98 resolve_run_primary_engine, 

99 ) 

100 

101 from ....search_system import username_from_snapshot 

102 

103 # No fallback primary: an incomplete snapshot cannot widen the 

104 # constructor into a network-capable state. 

105 _primary = resolve_run_primary_engine(settings_snapshot) 

106 context = context_from_snapshot( 

107 settings_snapshot, 

108 _primary, 

109 username=username_from_snapshot(settings_snapshot), 

110 ) 

111 download_allowed = ( 

112 not context.require_local_embeddings 

113 and context.scope 

114 in {EgressScope.PUBLIC_ONLY, EgressScope.UNPROTECTED} 

115 ) 

116 except ValueError: 

117 # No usable primary: fail closed to cache-only. Unknown scopes 

118 # raise PolicyDeniedError and intentionally propagate. 

119 pass 

120 

121 spec = ( 

122 get_sentence_transformer_model_spec(model) 

123 if isinstance(model, str) 

124 else None 

125 ) 

126 # Keep existing explicit local models confined to the application's 

127 # models directory. The confined models directory is app-owned state, 

128 # so an operator-placed model there wins in every scope — including 

129 # when its name matches a catalog key or repository. That is the 

130 # pre-catalog behavior and the only offline-usable form for an 

131 # air-gapped install; resolving such a name to the Hub artifact 

132 # instead would silently embed with different bytes than the 

133 # collection was indexed with. The substitution is audited below so 

134 # it is never silent. (This is unrelated to 

135 # ``_reject_shadowed_hub_model``, which refuses an unconfined 

136 # working-directory match for a name that was admitted as a Hub id.) 

137 local_model_path = cls._confined_local_model_path(model) 

138 if local_model_path is not None and spec is not None: 

139 logger.bind(policy_audit=True).warning( 

140 "using the confined local model directory {!r} for " 

141 "embedding model {!r} instead of the curated Hub model {!r}", 

142 str(local_model_path), 

143 model, 

144 spec.repository, 

145 ) 

146 if local_model_path is None and cls._looks_like_filesystem_path(model): 

147 raise ValueError( 

148 "Invalid embedding model path: local model paths must " 

149 "resolve under the application's models directory." 

150 ) 

151 cache_dir = cls._model_cache_directory() 

152 if local_model_path is not None: 

153 return shared_sentence_transformer_embeddings( 

154 SentenceTransformerEmbeddings, 

155 model_name=str(local_model_path), 

156 cache_folder=cache_dir, 

157 model_kwargs={ 

158 "device": device, 

159 "trust_remote_code": False, 

160 "token": False, 

161 "model_kwargs": {"use_safetensors": True}, 

162 "local_files_only": True, 

163 }, 

164 ) 

165 

166 if spec is None: 

167 # Legacy compatibility: if the model is already cached locally 

168 # from a previous installation, allow loading it in local-only 

169 # mode. This preserves access to existing collections indexed 

170 # with non-catalog models without permitting new downloads of 

171 # unvetted artifacts. 

172 if isinstance(model, str) and cls._is_model_cached_locally( 

173 model, cache_dir=cache_dir 

174 ): 

175 cls._reject_shadowed_hub_model(model) 

176 logger.info( 

177 "Loading legacy cached SentenceTransformer model {!r} " 

178 "in local-only mode (not in catalog but already cached)", 

179 model, 

180 ) 

181 return shared_sentence_transformer_embeddings( 

182 SentenceTransformerEmbeddings, 

183 model_name=model, 

184 cache_folder=cache_dir, 

185 model_kwargs={ 

186 "device": device, 

187 "trust_remote_code": False, 

188 "token": False, 

189 "model_kwargs": {"use_safetensors": True}, 

190 "local_files_only": True, 

191 }, 

192 ) 

193 

194 from ....security.egress.policy import ( 

195 Decision, 

196 PolicyDeniedError, 

197 ) 

198 

199 # New downloads are restricted to the catalog. Existing local 

200 # models were handled through the confined path above. 

201 logger.bind(policy_audit=True).warning( 

202 "refusing non-curated SentenceTransformer model {!r}", model 

203 ) 

204 raise PolicyDeniedError( 

205 Decision(False, "embeddings_model_not_curated"), 

206 target=str(model), 

207 ) 

208 

209 # The new GTE key has an immutable artifact revision. Legacy keys must 

210 # remain unpinned until collections persist a revision/digest; changing 

211 # the bytes behind an existing key would silently invalidate its index. 

212 resolved_model = spec.repository if spec.revision is not None else model 

213 cls._reject_shadowed_hub_model(resolved_model) 

214 # Resolve an already cached legacy snapshot without consulting the 

215 # network. Repair must use these same bytes, never a newer remote main. 

216 resolved_revision = spec.revision or cls._cached_model_revision( 

217 spec.repository, cache_dir=cache_dir 

218 ) 

219 model_kwargs = { 

220 "device": device, 

221 "trust_remote_code": False, 

222 "token": False, 

223 # Forwarded by SentenceTransformer to the nested transformers 

224 # AutoModel.from_pretrained call. Do not accept pickle-based weights. 

225 "model_kwargs": {"use_safetensors": True}, 

226 } 

227 if resolved_revision is not None: 

228 model_kwargs["revision"] = resolved_revision 

229 cached = cls._is_model_cached_locally( 

230 spec.repository, revision=resolved_revision, cache_dir=cache_dir 

231 ) 

232 else: 

233 # Preserve the exact legacy key/alias lookup semantics. The 

234 # local-only constructor below reuses refs/main without checking 

235 # for, or advancing to, a newer remote artifact. 

236 cached = cls._is_model_cached_locally(model, cache_dir=cache_dir) 

237 

238 if cached: 

239 # Probe the cache before considering policy-authorized network 

240 # acquisition in every scope. This prevents repeat network 

241 # checks and keeps a legacy collection on its existing bytes. 

242 model_kwargs["local_files_only"] = True 

243 elif not download_allowed: 

244 from ....security.egress.policy import ( 

245 Decision, 

246 PolicyDeniedError, 

247 ) 

248 

249 logger.bind(policy_audit=True).warning( 

250 "refusing SentenceTransformer download for {!r} " 

251 "outside a resolved public egress scope", 

252 model, 

253 ) 

254 raise PolicyDeniedError( 

255 Decision(False, "embeddings_model_not_cached"), 

256 target=str(model), 

257 ) 

258 

259 elif ( 

260 spec.revision is None 

261 and resolved_revision is None 

262 and cls._has_model_cache_state(spec.repository, cache_dir=cache_dir) 

263 ): 

264 from ....security.egress.policy import Decision, PolicyDeniedError 

265 

266 # A partially cached model is not a first install. Without a 

267 # trustworthy revision, a floating download could change the 

268 # vectors behind an existing collection's model key. 

269 raise PolicyDeniedError( 

270 Decision(False, "embeddings_cache_revision_unknown"), 

271 target=str(model), 

272 ) 

273 

274 try: 

275 return shared_sentence_transformer_embeddings( 

276 SentenceTransformerEmbeddings, 

277 model_name=resolved_model, 

278 cache_folder=cache_dir, 

279 model_kwargs=model_kwargs, 

280 ) 

281 except OSError as error: 

282 # Metadata is evidence of a snapshot, not proof it is complete. 

283 # An interrupted download can leave config/modules but no weights. 

284 # Try the cache first; repair only under an authorized public scope 

285 # and at an immutable known revision (including legacy snapshots). 

286 # 

287 # An environment fault is not an incomplete snapshot. The repair 

288 # runs at the same commit revision, and the Hub client returns a 

289 # file already present in that snapshot without fetching it again, 

290 # so e.g. an unreadable modules.json or config (PermissionError, 

291 # EACCES) fails identically on the retry. The retry could only 

292 # reproduce the fault, now with network access enabled, and would 

293 # replace the original, actionable error. Surface those unchanged. 

294 # (Unreadable weights are different: safetensors reports them as 

295 # FileNotFoundError without an errno, so they enter the repair, 

296 # which likewise reuses the existing blob and fails the same way.) 

297 if isinstance(error, PermissionError) or error.errno in ( 

298 errno.EACCES, 

299 errno.EPERM, 

300 errno.ENOSPC, 

301 errno.EROFS, 

302 ): 

303 raise 

304 if not ( 

305 model_kwargs.get("local_files_only") 

306 and download_allowed 

307 and resolved_revision is not None 

308 ): 

309 raise 

310 repair_kwargs = dict(model_kwargs) 

311 repair_kwargs.pop("local_files_only") 

312 return shared_sentence_transformer_embeddings( 

313 SentenceTransformerEmbeddings, 

314 model_name=resolved_model, 

315 cache_folder=cache_dir, 

316 model_kwargs=repair_kwargs, 

317 ) 

318 

319 @staticmethod 

320 def _model_cache_directory() -> str: 

321 """Use the loader's cache root for admission, revision and repair. 

322 

323 Sentence Transformers gives its own environment override precedence 

324 over the Hub default. Resolve it once per load and pass it explicitly 

325 to both cache probes and constructors. 

326 """ 

327 from ....config.paths import get_sentence_transformers_home 

328 from huggingface_hub.constants import HF_HUB_CACHE 

329 

330 cache_dir = get_sentence_transformers_home() 

331 return HF_HUB_CACHE if cache_dir is None else cache_dir 

332 

333 @classmethod 

334 def _is_model_cached_locally( 

335 cls, 

336 model_name: str, 

337 revision: Optional[str] = None, 

338 *, 

339 cache_dir: Optional[str] = None, 

340 ) -> bool: 

341 """Check the Hub cache for an offline-loadable configuration. 

342 

343 Confined local models never reach this helper: ``create_embeddings`` 

344 resolves ``_confined_local_model_path`` first and returns from its own 

345 branch, so this is purely a repo-id cache probe (a filesystem-shaped 

346 name is a miss, not an existence probe). 

347 

348 Curated models require every file in their spec's 

349 ``required_cache_files`` (model config, module list, pooling config, 

350 tokenizer config and, where the artifact publishes one, the 

351 Transformer module config) plus at least one file from each group in 

352 ``required_cache_file_alternatives`` (the tokenizer vocabulary: 

353 ``tokenizer.json`` or ``vocab.txt`` for the legacy WordPiece/MPNet 

354 models, ``tokenizer.json`` for the others). Any of them missing can 

355 silently change pooling, truncation, tokenizer selection or the 

356 vocabulary itself without the offline constructor raising, so none of 

357 it is left to the missing-weight repair path. Weights are not probed: 

358 a missing weights file does raise from the offline constructor, which 

359 a policy-authorized load repairs at the cache's existing revision. 

360 Names outside the catalog need only ``config.json`` (config-only 

361 vanilla transformer snapshots remain compatible). 

362 """ 

363 if not model_name or not isinstance(model_name, str): 

364 return False 

365 if cls._looks_like_filesystem_path(model_name): 

366 return False 

367 try: 

368 from huggingface_hub import try_to_load_from_cache 

369 

370 # Probe both the bare and the namespaced cache keys when the 

371 # input has no "/", because the upstream loader resolves bare 

372 # names two different ways: 

373 # - names in ``sentence_transformers.util.misc``'s 

374 # ``ORIGINAL_TRANSFORMER_MODELS`` edge-case list 

375 # (bert-base-uncased, gpt2, t5-base, ...) are requested 

376 # from the BARE repo_id; 

377 # - everything else is prefixed with the model class's 

378 # ``default_huggingface_organization`` — for 

379 # ``SentenceTransformer`` the value re-exported as 

380 # ``__MODEL_HUB_ORGANIZATION__`` (e.g. "all-MiniLM-L6-v2" 

381 # -> "sentence-transformers/all-MiniLM-L6-v2"). 

382 # The HF hub cache is keyed on whichever form the loader 

383 # requests, so probing both is the only way to be correct 

384 # for both classes without mirroring that upstream list. 

385 if "/" in model_name: 

386 candidates = [model_name] 

387 else: 

388 from sentence_transformers import ( 

389 __MODEL_HUB_ORGANIZATION__, 

390 ) 

391 

392 candidates = [ 

393 model_name, 

394 f"{__MODEL_HUB_ORGANIZATION__}/{model_name}", 

395 ] 

396 

397 if cache_dir is None: 

398 cache_dir = cls._model_cache_directory() 

399 spec = get_sentence_transformer_model_spec(model_name) 

400 required_files = ( 

401 spec.required_cache_files if spec else ("config.json",) 

402 ) 

403 alternatives = spec.required_cache_file_alternatives if spec else () 

404 

405 def is_cached(repo_id: str, filename: str) -> bool: 

406 cache_kwargs = { 

407 "repo_id": repo_id, 

408 "filename": filename, 

409 "cache_dir": cache_dir, 

410 } 

411 if revision is not None: 

412 cache_kwargs["revision"] = revision 

413 cached = try_to_load_from_cache(**cache_kwargs) 

414 return isinstance(cached, str) and bool(cached) 

415 

416 # Every required file, and at least one file of every group. 

417 return any( 

418 all(is_cached(repo_id, name) for name in required_files) 

419 and all( 

420 any(is_cached(repo_id, name) for name in group) 

421 for group in alternatives 

422 ) 

423 for repo_id in candidates 

424 ) 

425 except Exception: # pragma: no cover - defensive 

426 return False 

427 

428 @classmethod 

429 def _cached_model_revision( 

430 cls, repository: str, *, cache_dir: Optional[str] = None 

431 ) -> Optional[str]: 

432 """Read a snapshot commit from local metadata or the Hub's refs/main. 

433 

434 Hub cache lookup returns a lexical snapshots/<commit>/<file> path; 

435 resolving its symlink would lose the commit by pointing into blobs/. 

436 Reject ambiguous or unrecognized paths rather than repair at main. 

437 """ 

438 try: 

439 from huggingface_hub import try_to_load_from_cache 

440 

441 if cache_dir is None: 

442 cache_dir = cls._model_cache_directory() 

443 revisions = set() 

444 for filename in ("config.json", "modules.json"): 

445 cached = try_to_load_from_cache( 

446 repo_id=repository, filename=filename, cache_dir=cache_dir 

447 ) 

448 if not isinstance(cached, str) or not cached: 

449 continue 

450 snapshot = Path(cached).parent 

451 if snapshot.parent.name != "snapshots" or not re.fullmatch( 

452 r"[0-9a-f]{40}", snapshot.name 

453 ): 

454 return None 

455 revisions.add(snapshot.name) 

456 # The Hub's local ref survives loss of both metadata anchors. 

457 # Read it without resolving a model file or contacting the Hub. 

458 reference = ( 

459 Path(cache_dir) 

460 / ("models--" + repository.replace("/", "--")) 

461 / "refs" 

462 / "main" 

463 ) 

464 try: 

465 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Repository comes from the curated embedding catalog; refs/main is inside the operator model cache 

466 cached_revision = reference.read_text(encoding="ascii").strip() 

467 except FileNotFoundError: 

468 cached_revision = None 

469 if cached_revision is not None: 

470 if not re.fullmatch(r"[0-9a-f]{40}", cached_revision): 

471 return None 

472 revisions.add(cached_revision) 

473 return next(iter(revisions)) if len(revisions) == 1 else None 

474 except Exception: # pragma: no cover - cache lookup is best-effort 

475 return None 

476 

477 @classmethod 

478 def _has_model_cache_state( 

479 cls, repository: str, *, cache_dir: Optional[str] = None 

480 ) -> bool: 

481 """Distinguish an empty cache from an unresolved existing snapshot. 

482 

483 Only curated repository names reach this helper. Any existing entry 

484 counts as cache state; inaccessible state must not permit a floating 

485 download. An absent or completely empty repository cache is a new 

486 installation. 

487 """ 

488 if cache_dir is None: 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true

489 cache_dir = cls._model_cache_directory() 

490 cache = Path(cache_dir) / ("models--" + repository.replace("/", "--")) 

491 try: 

492 # nosemgrep: semgrep.rules.path-traversal-risk, reason: Repository comes from the curated embedding catalog; only its local cache directory is enumerated 

493 return next(cache.iterdir(), None) is not None 

494 except FileNotFoundError: 

495 return False 

496 except OSError: 

497 return True 

498 

499 @staticmethod 

500 def _reject_shadowed_hub_model(model_name: str) -> None: 

501 """Prevent the loader from interpreting an admitted Hub ID as a CWD path. 

502 

503 Call only after catalog/cache admission and lexical path checks, so 

504 arbitrary absolute server paths never reach this existence check. 

505 Explicit local models use the separately confined absolute path. 

506 """ 

507 try: 

508 shadowed = Path(model_name).exists() 

509 except (OSError, ValueError): 

510 shadowed = True 

511 if shadowed: 

512 raise ValueError( 

513 "Embedding model identifier conflicts with a local path. " 

514 "Use an explicit path under the application's models directory " 

515 "for a local model." 

516 ) 

517 

518 @classmethod 

519 def _confined_local_model_path( 

520 cls, 

521 model_name: Optional[str], 

522 models_dir: Optional[Union[str, Path]] = None, 

523 ) -> Optional[Path]: 

524 """Resolve ``model_name`` to a real local model path IFF it denotes an 

525 EXISTING location confined UNDER the application's models directory. 

526 

527 Returns the resolved :class:`~pathlib.Path` for a legitimate local 

528 model (a file or directory living under the models dir), or ``None`` 

529 for everything else — HuggingFace repo ids and, crucially, any 

530 absolute/relative path that escapes the models directory. 

531 

532 Security: a value that is NOT confined under the models dir is 

533 rejected by LEXICAL containment (``is_relative_to``) and is never 

534 probed on disk with ``Path(model_name).exists()``. This is what closes 

535 the arbitrary-filesystem existence oracle. Only a value already 

536 confined under the models dir is resolved and stat-ed. 

537 """ 

538 if not isinstance(model_name, str): 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true

539 return None 

540 text = model_name.strip() 

541 if not text: 541 ↛ 542line 541 didn't jump to line 542 because the condition on line 541 was never true

542 return None 

543 

544 from ....security.path_validator import ( 

545 PathValidator, 

546 resolve_allow_missing, 

547 ) 

548 

549 try: 

550 if models_dir is None: 550 ↛ 574line 550 didn't jump to line 574 because the condition on line 550 was always true

551 from ....config.paths import get_models_directory 

552 

553 models_dir = get_models_directory() 

554 # Two views of the models root are needed: 

555 # * ``models_root_lexical`` -- absolute + lexically normalized 

556 # but NOT symlink-resolved. Used for the LEXICAL containment 

557 # check on absolute inputs so a legitimate in-tree model still 

558 # matches when an ANCESTOR of the models dir is a symlink 

559 # (NFS-mounted homes, a symlinked ``LDR_DATA_DIR``, macOS 

560 # ``/tmp`` -> ``/private/tmp``). Resolving the root but NOT the 

561 # user path is precisely the mismatch that wrongly refused 

562 # legitimate models. 

563 # * ``models_root`` -- fully symlink-resolved. Used ONLY for the 

564 # secondary post-resolution escape re-check below, which 

565 # collapses any symlink in the confined candidate and confirms 

566 # it still lands inside the resolved tree (catches an in-tree 

567 # symlink that points OUT of the models dir). 

568 # 

569 # ``.absolute()`` makes the path absolute WITHOUT resolving 

570 # symlinks (pathlib already collapses ``.`` and redundant 

571 # separators on construction); it is used deliberately here in 

572 # place of ``.resolve()`` so the lexical comparison below stays 

573 # symlink-agnostic. 

574 models_root_lexical = Path(models_dir).absolute() 

575 models_root = resolve_allow_missing(Path(models_dir)) 

576 except Exception: # pragma: no cover - defensive 

577 return None 

578 

579 try: 

580 if Path(text).is_absolute(): 

581 # Absolute reference: decide containment LEXICALLY first so a 

582 # path outside the models dir is rejected without any 

583 # filesystem access. Compare ABSOLUTE-but-UN-RESOLVED paths on 

584 # BOTH sides (``lexical`` here vs ``models_root_lexical`` above, 

585 # never ``.resolve()``) so a symlinked ANCESTOR of the models 

586 # dir does not spuriously break the match for a legitimate 

587 # in-tree model. The ``..`` guard runs first, on the parts, 

588 # so a traversal segment is refused regardless. Only a path 

589 # already lexically inside is then resolved (collapsing any 

590 # symlink escape) and re-checked against the resolved root 

591 # below. 

592 lexical = Path(text) 

593 if ".." in lexical.parts or not lexical.is_relative_to( 

594 models_root_lexical 

595 ): 

596 return None 

597 candidate = resolve_allow_missing(lexical) 

598 else: 

599 # Relative reference: safe_join confines it under the models 

600 # dir, rejecting traversal / absolute inputs (returns None or 

601 # raises ValueError). A bare HF id like "all-MiniLM-L6-v2" or 

602 # "sentence-transformers/all-MiniLM-L6-v2" confines cleanly but 

603 # simply won't exist under the models dir -> falls through to 

604 # None and is handled as a repo id by the caller. 

605 confined = PathValidator.validate_safe_path(text, models_root) 

606 if confined is None: 606 ↛ 607line 606 didn't jump to line 607 because the condition on line 606 was never true

607 return None 

608 candidate = resolve_allow_missing(confined) 

609 except (ValueError, OSError, RuntimeError): 

610 # resolve_allow_missing() raises OSError for a symlink LOOP 

611 # (e.g. a -> b -> a), for a symlink chain whose resolved form 

612 # outgrows PATH_MAX, and for any other component it cannot 

613 # resolve, where a bare non-strict resolve() would return a 

614 # half-resolved path. RuntimeError is still caught defensively. 

615 # Every case fails closed with a clean refusal instead of 

616 # propagating out of this function / ``create_embeddings``. 

617 return None 

618 

619 # Secondary post-resolution escape re-check (defence in depth): both 

620 # ``candidate`` (each branch above resolved it) and ``models_root`` are 

621 # fully symlink-resolved here, so this catches an in-tree symlink whose 

622 # target points OUT of the models dir — which the lexical check above 

623 # deliberately cannot see. Also require a STRICT subpath (never the 

624 # models root itself). is_relative_to stays a pure lexical check. 

625 if candidate == models_root or not candidate.is_relative_to( 

626 models_root 

627 ): 

628 return None 

629 

630 try: 

631 if candidate.exists(): 

632 return candidate 

633 except OSError: # pragma: no cover - defensive 

634 return None 

635 return None 

636 

637 @staticmethod 

638 def _looks_like_filesystem_path(model_name: Optional[str]) -> bool: 

639 """Pure string classification: does ``model_name`` look like a 

640 filesystem path rather than a HuggingFace repo id? 

641 

642 Returns True only for the unambiguous path shapes used to escape the 

643 models directory — absolute paths, home-relative (``~``) refs, Windows 

644 drive paths, and any reference containing a parent-traversal (``..``) 

645 segment. HuggingFace repo ids such as ``all-MiniLM-L6-v2`` or 

646 ``sentence-transformers/all-MiniLM-L6-v2`` return False. Performs no 

647 filesystem access. 

648 """ 

649 if not isinstance(model_name, str): 649 ↛ 650line 649 didn't jump to line 650 because the condition on line 649 was never true

650 return False 

651 text = model_name.strip() 

652 if not text: 

653 return False 

654 if text.startswith(("/", "\\", "~")) or Path(text).is_absolute(): 

655 return True 

656 # Windows drive-letter absolute path, e.g. "C:\\models". 

657 if len(text) >= 2 and text[1] == ":": 657 ↛ 658line 657 didn't jump to line 658 because the condition on line 657 was never true

658 return True 

659 # Parent-directory traversal in any separator form. 

660 if ".." in re.split(r"[\\/]+", text): 

661 return True 

662 return False 

663 

664 @classmethod 

665 def is_available( 

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

667 ) -> bool: 

668 """ 

669 Check if Sentence Transformers is available. 

670 

671 Since sentence-transformers is a required dependency, this always returns True. 

672 This method exists for API consistency with other providers. 

673 """ 

674 return True 

675 

676 @classmethod 

677 def get_available_models( 

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

679 ) -> List[Dict[str, Any]]: 

680 """ 

681 Get list of available Sentence Transformer models. 

682 

683 The catalog controls new Hub downloads. Existing non-catalog Hub 

684 snapshots and models confined to the application's models directory 

685 remain available in local-only mode. 

686 """ 

687 return [ 

688 { 

689 "value": model, 

690 "label": f"{model} ({info['dimensions']}d) - {info['description']}", 

691 } 

692 for model, info in cls.AVAILABLE_MODELS.items() 

693 ]