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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Sentence Transformers embedding provider."""
3import errno
4import re
5from pathlib import Path
6from typing import Any, Dict, List, Optional, Union
8from langchain_core.embeddings import Embeddings
9from ....security.secure_logging import logger
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
21class SentenceTransformersProvider(BaseEmbeddingProvider):
22 """
23 Sentence Transformers embedding provider.
25 Uses HuggingFace sentence-transformers models for local embeddings.
26 No API key required, runs entirely locally.
27 """
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]
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 }
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.
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.)
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 )
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 )
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 )
83 logger.info(
84 f"Preparing local embeddings with model={model}, device={device}"
85 )
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 )
101 from ....search_system import username_from_snapshot
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
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 )
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 )
194 from ....security.egress.policy import (
195 Decision,
196 PolicyDeniedError,
197 )
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 )
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)
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 )
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 )
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
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 )
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 )
319 @staticmethod
320 def _model_cache_directory() -> str:
321 """Use the loader's cache root for admission, revision and repair.
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
330 cache_dir = get_sentence_transformers_home()
331 return HF_HUB_CACHE if cache_dir is None else cache_dir
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.
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).
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
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 )
392 candidates = [
393 model_name,
394 f"{__MODEL_HUB_ORGANIZATION__}/{model_name}",
395 ]
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 ()
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)
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
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.
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
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
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.
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
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.
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 )
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.
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.
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
544 from ....security.path_validator import (
545 PathValidator,
546 resolve_allow_missing,
547 )
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
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
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
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
630 try:
631 if candidate.exists():
632 return candidate
633 except OSError: # pragma: no cover - defensive
634 return None
635 return None
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?
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
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.
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
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.
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 ]