-
-
Notifications
You must be signed in to change notification settings - Fork 7.1k
studio: classify embedding models from the HF cache and honor offline mode #7218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
033d607
aabf0d0
763ef72
fb0cfae
abf5874
21bef98
e9c7da6
bfc1cff
3468fa9
202b23f
cb1f538
df32b90
38b78c8
164b5d8
f94f3ec
72eb4f4
c7b8c85
1a26812
a3fb2e9
afb323d
ae2f5c0
9e6a355
003c982
563dd31
62e57d1
98abd64
9b2895c
9753c43
8f4d3bb
c2614bb
9e34679
b83c15d
35113e6
27f60b1
8407e0a
4d49096
ba98c68
fbf9977
c96decf
56201d0
947beb1
f56b712
4c04b87
ac0a89c
fbfbe07
8e9a0e4
b85a771
5847763
95af81b
67ac0ef
2250697
ae15078
9fd2b13
be9a581
9c77fd7
5623ed1
cf8ece8
e46c7ef
f84c7e1
a1323be
47d4cd3
ecbd325
3db5c0c
fe756ba
cde4da9
02d843e
831da56
8c91458
527396a
0704c6e
529876b
6c58540
e55a1c6
e667419
845ba4b
f08dc34
1c7170d
2571e1b
b76eda1
cadd262
fd7c2de
92cc0e7
ef49462
48309dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,7 @@ | |
|
|
||
| from utils.hardware.hardware import DeviceType, get_device | ||
| from utils.transformers_dtype import dtype_kwargs | ||
| from utils.utils import hf_env_offline | ||
|
|
||
| from . import config | ||
|
|
||
|
|
@@ -119,46 +120,86 @@ def _st_module_subdirs(name: str, token: str | None) -> tuple[str, ...]: | |
| return () | ||
|
|
||
|
|
||
| def _guard_model_security(name: str) -> None: | ||
| def _guard_model_security(name: str, local_only: bool = False) -> None: | ||
| """Refuse to load a repo HF flagged as unsafe: a poisoned pickle deserializes inside | ||
| SentenceTransformer regardless of trust_remote_code. Defense in depth behind the | ||
| /settings gate (a name can also arrive via env/default); local paths and unreachable | ||
| scans fail open inside evaluate_file_security. Never bricks the embedder on a gate error. | ||
|
|
||
| ``local_only`` (offline) inspects the local cache; subdir probes are skipped (they'd hit the | ||
| network and hang, and the offline gate walks the whole snapshot anyway). | ||
| """ | ||
| try: | ||
| from utils.security import evaluate_file_security, security_load_subdirs | ||
|
|
||
| token = _ambient_hf_token() | ||
| # Union the audio-model load roots with the ST module dirs so a flagged pickle | ||
| # directly under a Transformer module dir (0_Transformer/) blocks instead of | ||
| # passing as an unreferenced nested shard. | ||
| load_subdirs = tuple( | ||
| dict.fromkeys((*security_load_subdirs(name, token), *_st_module_subdirs(name, token))) | ||
| ) | ||
| blocked = evaluate_file_security(name, hf_token = token, load_subdirs = load_subdirs).blocked | ||
| if local_only: | ||
| load_subdirs = () | ||
| else: | ||
| # Union audio-model load roots with ST module dirs so a flagged pickle under a | ||
| # Transformer module dir blocks instead of passing as an unreferenced nested shard. | ||
| load_subdirs = tuple( | ||
| dict.fromkeys( | ||
| (*security_load_subdirs(name, token), *_st_module_subdirs(name, token)) | ||
| ) | ||
| ) | ||
| blocked = evaluate_file_security( | ||
| name, hf_token = token, load_subdirs = load_subdirs, local_only_load = local_only | ||
| ).blocked | ||
| except Exception: | ||
| return | ||
| if blocked: | ||
| reason = ( | ||
| "has cached pickle weights that cannot be security-scanned offline and no " | ||
| "safetensors alternative" | ||
| if local_only | ||
| else "is flagged as unsafe by Hugging Face's security scan" | ||
| ) | ||
| raise UnsafeEmbeddingModelError( | ||
| f"Embedding model {name!r} is flagged as unsafe by Hugging Face's security " | ||
| "scan; refusing to load. Set a different RAG embedding model." | ||
| f"Embedding model {name!r} {reason}; refusing to load. " | ||
| "Set a different RAG embedding model." | ||
| ) | ||
|
|
||
|
|
||
| def _st_accepts_local_files_only(st_cls) -> bool: | ||
| """Whether this SentenceTransformer version accepts local_files_only; passing it to an | ||
| older constructor raises, so gate on the signature.""" | ||
| try: | ||
| import inspect | ||
| return "local_files_only" in inspect.signature(st_cls.__init__).parameters | ||
| except Exception: | ||
| return False | ||
|
|
||
|
|
||
| def _get(model_name: str | None = None): | ||
| """Cached SentenceTransformer, (re)loading on a name change. Loaded in fp16 | ||
| for a ~1.5x speedup at negligible accuracy loss.""" | ||
| global _model, _name | ||
| name = model_name or config.effective_embedding_model() | ||
| # Capture offline state once so the gate and the load agree (no window where the gate is | ||
| # skipped as offline but the constructor then reaches the network). | ||
| local_only = hf_env_offline() | ||
| with _lock: | ||
| if _model is None or _name != name: | ||
| _install_torchao_stub_once() | ||
| from sentence_transformers import SentenceTransformer | ||
|
|
||
| device = _device() | ||
| logger.info("loading embedding model %s on %s", name, device) | ||
| _guard_model_security(name) | ||
| _model = SentenceTransformer(name, device = device, model_kwargs = dtype_kwargs("float16")) | ||
| _guard_model_security(name, local_only) | ||
| st_kwargs = dict(device = device, model_kwargs = dtype_kwargs("float16")) | ||
| load_target = name | ||
| if local_only: | ||
| from utils.utils import hf_cache_snapshot_dir | ||
| snapshot = hf_cache_snapshot_dir(name) | ||
| if snapshot is not None: | ||
| # Load from the local snapshot dir: a local path never touches the Hub, so | ||
| # this is offline-safe on ANY sentence-transformers version (even ones | ||
| # predating local_files_only). | ||
| load_target = str(snapshot) | ||
| elif _st_accepts_local_files_only(SentenceTransformer): | ||
|
Comment on lines
+197
to
+200
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When only Useful? React with 👍 / 👎.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The premise is not accurate for huggingface_hub 0.36.2: constants.HF_HUB_OFFLINE = _is_true(HF_HUB_OFFLINE or TRANSFORMERS_OFFLINE) is evaluated at import, so hub DOES honor TRANSFORMERS_OFFLINE. Verified: with TRANSFORMERS_OFFLINE=1 set at launch, hf_hub_download() on an uncached repo with NO local_files_only kwarg raises LocalEntryNotFoundError in 0.00s (no network, no hang) - the download forces offline from the constant. The studio sets the offline env at launch (nothing sets it at runtime for the RAG embedder; _hf_offline_if_dns_dead wraps only llama inference), so an old-ST uncached load fails fast rather than reaching the network. |
||
| st_kwargs["local_files_only"] = True | ||
|
Comment on lines
+200
to
+201
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When only Useful? React with 👍 / 👎.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as the P1 above: hub 0.36.2 folds TRANSFORMERS_OFFLINE into HF_HUB_OFFLINE at import and the underlying download forces offline from that constant, so an uncached load on an ST predating local_files_only raises LocalEntryNotFoundError immediately (0.00s) instead of reaching the network. Not reachable for the launch-set offline session the studio actually runs.
Comment on lines
+200
to
+201
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When only Useful? React with 👍 / 👎. |
||
| _model = SentenceTransformer(load_target, **st_kwargs) | ||
| _name = name | ||
| return _model | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -416,6 +416,11 @@ def update_embedding_model( | |
| log = logger, | ||
| ) from exc | ||
| hf_token = (payload.hf_token or "").strip() or None | ||
| from utils.utils import hf_env_offline | ||
|
|
||
| # Offline, both the Hub malware scan and the is-embedding check are unreachable and degrade | ||
| # to the local cache below; capture the state once. | ||
| local_only_load = hf_env_offline() | ||
| # The env/default model needs no verification; saving it is a no-op override. | ||
| # A local GGUF on the llama-server backend is accepted as-is: it is exactly | ||
| # what the backend loads, and HF metadata cannot verify a local path. | ||
|
|
@@ -439,26 +444,41 @@ def update_embedding_model( | |
| # Fall back to the loader's own token so a gated/private repo is actually scanned | ||
| # (a token-less scan fails open for exactly the repo that would still load). | ||
| scan_token = hf_token or _ambient_hf_token() | ||
| # Include the ST module dirs (0_Transformer/) so a flagged pickle directly under | ||
| # one blocks instead of passing as an unreferenced nested shard. | ||
| load_subdirs = tuple( | ||
| dict.fromkeys( | ||
| ( | ||
| *security_load_subdirs(model, scan_token), | ||
| *_st_module_subdirs(model, scan_token), | ||
| # Offline: subdir probes would hit the network and hang; the offline gate walks the | ||
| # whole cached snapshot, so no load-subdir hints are needed. | ||
| if local_only_load: | ||
| load_subdirs = () | ||
| else: | ||
| # Include ST module dirs (0_Transformer/) so a flagged pickle directly under one | ||
| # blocks instead of passing as an unreferenced nested shard. | ||
| load_subdirs = tuple( | ||
| dict.fromkeys( | ||
| ( | ||
| *security_load_subdirs(model, scan_token), | ||
| *_st_module_subdirs(model, scan_token), | ||
| ) | ||
| ) | ||
| ) | ||
| ) | ||
| if evaluate_file_security(model, hf_token = scan_token, load_subdirs = load_subdirs).blocked: | ||
| if evaluate_file_security( | ||
| model, | ||
| hf_token = scan_token, | ||
| load_subdirs = load_subdirs, | ||
| local_only_load = local_only_load, | ||
| ).blocked: | ||
| # 403, not 409: the client routes every 409 into the forceable "save anyway" | ||
| # flow, but this block is a hard, non-forceable security refusal. | ||
| raise HTTPException( | ||
| status_code = 403, | ||
| if local_only_load: | ||
| detail = ( | ||
| f"{model!r} has cached pickle weights that cannot be security-scanned " | ||
| "offline and no safetensors alternative, so it cannot be used as the " | ||
| "embedding model. Re-download it with safetensors weights while online." | ||
| ) | ||
| else: | ||
| detail = ( | ||
| f"{model!r} is flagged as unsafe by Hugging Face's security scan and " | ||
| "cannot be used as the embedding model." | ||
| ), | ||
| ) | ||
| ) | ||
| raise HTTPException(status_code = 403, detail = detail) | ||
| if model != default_embedding_model() and not payload.force and not is_local_gguf: | ||
| from core.rag import config as rag_config | ||
|
|
||
|
|
@@ -468,15 +488,28 @@ def update_embedding_model( | |
| # which would wrongly 409 a valid online GGUF embedder. | ||
| gguf_named = _llama_backend_active() and rag_config._names_gguf(model) | ||
| if not gguf_named and not is_embedding_model(model, hf_token = hf_token): | ||
| raise HTTPException( | ||
| status_code = 409, | ||
| detail = ( | ||
| f"Could not verify {model!r} as an embedding model on " | ||
| "Hugging Face (it may be the wrong model type, gated, or " | ||
| "you may be offline)." | ||
| ), | ||
| ) | ||
| gguf_error = _local_gguf_backend_error(model) or _hf_gguf_backend_error(model, hf_token) | ||
| # Offline, is_embedding_model can only confirm the ST layout (modules.json); a | ||
| # transformers-native embedder (e.g. gte-modernbert) is unverifiable without Hub | ||
| # metadata. If already cached and loadable, accept it rather than raising a 409 that | ||
| # online would not (ST can load any cached encoder). Uncached -> 409. | ||
| from utils.utils import hf_cache_snapshot_is_loadable | ||
|
|
||
| # Require a genuinely loadable cache (config + weights), not just a resolved refs/main, | ||
| # so a metadata-only partial cache still gets the forceable 409. | ||
| offline_cached = local_only_load and hf_cache_snapshot_is_loadable(model) | ||
| if not offline_cached: | ||
|
Comment on lines
+497
to
+500
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When offline, a custom cached SentenceTransformer repo containing only Useful? React with 👍 / 👎.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Offline, hub is forced into local-files-only mode (the constant above), so a partial ST snapshot (modules.json, no weights) does not hang: _get() loads the local snapshot path and SentenceTransformer raises an immediate local error on the missing weights, no network. That is a clean failure on an incomplete cache (not well-formed input), not a reachable hang or wrong result, so it is below the bar for a code change here. |
||
| raise HTTPException( | ||
| status_code = 409, | ||
| detail = ( | ||
| f"Could not verify {model!r} as an embedding model on " | ||
| "Hugging Face (it may be the wrong model type, gated, or " | ||
| "you may be offline)." | ||
| ), | ||
| ) | ||
| # The Hub GGUF probe (list_repo_files) can hang offline; skip it. Local check stays. | ||
| gguf_error = _local_gguf_backend_error(model) | ||
| if gguf_error is None and not local_only_load: | ||
| gguf_error = _hf_gguf_backend_error(model, hf_token) | ||
|
Comment on lines
+510
to
+512
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the llama-server backend and offline mode enabled, this skips the only remote-GGUF validation and accepts an uncached GGUF-named model without Useful? React with 👍 / 👎.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With the offline flag set (always at launch for the studio process; nothing sets it at runtime here), huggingface_hub honors it, so list_repo_files()/hf_hub_download() in _resolve_model_path() fail fast offline (LocalEntryNotFoundError, ~0ms) rather than hang. The residual is a clean error on an uncached GGUF, and the llama-server GGUF preflight is outside this PR's sentence-transformers embedding scope (a separate #6817 follow-up). |
||
| if gguf_error: | ||
| raise HTTPException(status_code = 409, detail = gguf_error) | ||
| set_rag_embedding_model(model) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When offline inspection itself raises, this blanket handler returns and
_get()immediately constructsSentenceTransformerfrom the cached snapshot, so an unverified pickle can still be deserialized. This is especially problematic for malformed/unreadable cache metadata that causesevaluate_file_security(..., local_only_load=True)to raise rather than return a blocking decision; offline mode has no Hub scan to compensate. Re-raise anUnsafeEmbeddingModelError(or otherwise block) whenlocal_onlyis true.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The offline gate already fails closed on every reachable case: _evaluate_local_only returns a blocking decision when the cache cannot be resolved or read, rather than raising. The only way evaluate_file_security(local_only_load=True) raises is a valid-JSON-but-wrong-shape modules.json, and that same file makes SentenceTransformer's own _load_sbert_model raise while reading modules.json, before any module weight is deserialized, so there is no reachable path where the swallow lets an unscanned pickle load. I have left the guard unchanged; glad to add the offline fail-closed invariant as defense in depth if preferred, but it is not fixing a reachable bug.