Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions packages/sie_sdk/src/sie_sdk/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ def is_model_cached(
model_id: str,
config: CacheConfig | None = None,
revision: str | None = None,
*,
required_files: list[str] | None = None,
) -> bool:
"""Check if a model is already in local cache.

Expand All @@ -108,13 +110,24 @@ def is_model_cached(
config: Cache configuration. If None, reads from environment.
revision: Exact snapshot commit, branch, or tag to require. Branches
and tags resolve through the local Hugging Face ``refs`` cache.
required_files: Safe relative files required for a cache hit. By
default, any file preserves the existing model-cache semantics.

Returns:
True if model appears to be cached locally.
"""
if config is None:
config = get_cache_config()

if required_files is not None:
for name in required_files:
if (
not isinstance(name, str)
or any(character in name for character in ("\\", "\x00", ":"))
or any(part in {"", ".", ".."} for part in name.split("/"))
):
raise ValueError("Required cache files must be safe relative paths")

# HF Hub cache structure: models--{org}--{model}/snapshots/{revision}/
cache_dir = config.local_cache / f"models--{model_id.replace('/', '--')}"
if not cache_dir.exists():
Expand Down Expand Up @@ -143,15 +156,27 @@ def is_model_cached(
):
return False
snapshot = snapshots_dir / resolved_revision
return snapshot.is_dir() and any(snapshot.iterdir())
return (
snapshot.is_dir()
and any(snapshot.iterdir())
and (required_files is None or all((snapshot / name).is_file() for name in required_files))
)

return any(snapshot.is_dir() and any(snapshot.iterdir()) for snapshot in snapshots_dir.iterdir())
return any(
snapshot.is_dir()
and any(snapshot.iterdir())
and (required_files is None or all((snapshot / name).is_file() for name in required_files))
for snapshot in snapshots_dir.iterdir()
)


def ensure_model_cached(
model_id: str,
config: CacheConfig | None = None,
revision: str | None = None,
*,
allow_patterns: list[str] | None = None,
required_files: list[str] | None = None,
) -> Path:
"""Ensure model weights are in local cache.

Expand All @@ -164,6 +189,10 @@ def ensure_model_cached(
model_id: HuggingFace model ID (e.g., "BAAI/bge-m3").
config: Cache configuration. If None, reads from environment.
revision: Specific model revision (commit hash, branch, or tag).
allow_patterns: Optional file filter for Hub downloads only. Local and
cluster cache lookup and population remain unchanged.
required_files: Files required for a local or cluster hit. A partial
snapshot continues through the existing fallback tiers.

Returns:
Path to cached model in local cache.
Expand All @@ -176,20 +205,22 @@ def ensure_model_cached(
config = get_cache_config()

# Check local cache first
if is_model_cached(model_id, config, revision=revision):
if is_model_cached(model_id, config, revision=revision, required_files=required_files):
logger.debug("Model %s found in local cache", model_id)
return _get_model_cache_path(model_id, config)

# Try cluster cache if configured
if config.cluster_cache:
if _download_from_cluster_cache(model_id, config) and is_model_cached(model_id, config, revision=revision):
if _download_from_cluster_cache(model_id, config) and is_model_cached(
model_id, config, revision=revision, required_files=required_files
):
logger.info("Downloaded %s from cluster cache", model_id)
return _get_model_cache_path(model_id, config)

# Download from HuggingFace Hub if fallback enabled
if config.hf_fallback:
logger.info("Downloading %s from HuggingFace Hub", model_id)
return _download_from_huggingface(model_id, config, revision)
return _download_from_huggingface(model_id, config, revision, allow_patterns=allow_patterns)

# Not in any cache and HF fallback disabled
raise RuntimeError(
Expand Down Expand Up @@ -344,6 +375,8 @@ def _download_from_huggingface(
model_id: str,
config: CacheConfig,
revision: str | None = None,
*,
allow_patterns: list[str] | None = None,
) -> Path:
"""Download model from HuggingFace Hub to local cache.

Expand All @@ -354,6 +387,7 @@ def _download_from_huggingface(
model_id: HuggingFace model ID.
config: Cache configuration.
revision: Specific model revision.
allow_patterns: Optional file filter passed to snapshot_download.

Returns:
Path to the model in local cache.
Expand All @@ -379,6 +413,7 @@ def _download_from_huggingface(
revision=revision,
token=token,
cache_dir=str(config.local_cache), # Points to HF_HOME/hub
allow_patterns=allow_patterns,
ignore_patterns=_get_hf_ignore_patterns(model_id, revision, token),
)
return _get_model_cache_path(model_id, config)
Expand Down
70 changes: 70 additions & 0 deletions packages/sie_sdk/tests/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,3 +778,73 @@ def test_cache_dir_uses_local_cache_path(self, tmp_path: Path) -> None:
mock_download.assert_called_once()
call_kwargs = mock_download.call_args[1]
assert call_kwargs["cache_dir"] == str(tmp_path)


@pytest.mark.parametrize("allow_patterns", [None, ["adapter_config.json", "adapter_model.safetensors"]])
def test_hub_download_pattern_filter_is_optional_and_forwarded(
tmp_path: Path, allow_patterns: list[str] | None
) -> None:
config = CacheConfig(local_cache=tmp_path, hf_fallback=True)
revision = "a" * 40
with patch("huggingface_hub.snapshot_download") as download:
if allow_patterns is None:
result = ensure_model_cached("org/model", config, revision=revision)
else:
result = ensure_model_cached("org/model", config, revision=revision, allow_patterns=allow_patterns)
assert result == tmp_path / "models--org--model"
download.assert_called_once()
assert download.call_args.args == ("org/model",)
assert download.call_args.kwargs["revision"] == revision
assert download.call_args.kwargs["cache_dir"] == str(tmp_path)
assert download.call_args.kwargs.get("allow_patterns") == allow_patterns


@pytest.mark.parametrize("revision", [None, "a" * 40])
@pytest.mark.parametrize("required_files", [None, [], ["adapter_config.json"], ["adapter_model.safetensors"]])
def test_opt_in_cache_completeness_keeps_ordinary_semantics(
tmp_path: Path, revision: str | None, required_files: list[str] | None
) -> None:
directory = tmp_path / "models--org--model" / "snapshots" / ("a" * 40)
directory.mkdir(parents=True)
(directory / "adapter_config.json").write_bytes(b"{}")
config = CacheConfig(local_cache=tmp_path, hf_fallback=False)

assert is_model_cached("org/model", config, revision=revision) is True
assert is_model_cached("org/model", config, revision=revision, required_files=required_files) is (
required_files != ["adapter_model.safetensors"]
)


@pytest.mark.parametrize("lookup", [is_model_cached, ensure_model_cached])
@pytest.mark.parametrize(
"name",
[
"",
".",
"..",
"/absolute",
"../weights",
"nested/../../weights",
"nested//weights",
"nested\\weights",
"C:weights",
"file:stream",
"bad\x00name",
"./weights",
],
)
def test_required_cache_files_refuse_unsafe_relative_names(tmp_path: Path, lookup, name: str) -> None:
config = CacheConfig(local_cache=tmp_path, cluster_cache="s3://must-not-contact", hf_fallback=True)
with (
patch("sie_sdk.cache.get_storage_backend", side_effect=AssertionError("storage must not be contacted")),
patch("huggingface_hub.snapshot_download", side_effect=AssertionError("Hub must not be contacted")),
pytest.raises(ValueError, match="safe relative paths"),
):
lookup("org/model", config, required_files=[name])


def test_required_cache_files_support_safe_nested_files(tmp_path: Path) -> None:
directory = tmp_path / "models--org--model" / "snapshots" / ("a" * 40)
(directory / "nested").mkdir(parents=True)
(directory / "nested" / "config.json").write_bytes(b"{}")
assert is_model_cached("org/model", CacheConfig(local_cache=tmp_path), required_files=["nested/config.json"])
95 changes: 95 additions & 0 deletions packages/sie_server/src/sie_server/adapters/_lora_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Resolve admitted LoRA sources through the configured model-cache policy.

This module neither declares nor authorizes adapters. Callers must resolve their
served alias and admit its source/revision before calling this load-time helper.
It has no PEFT, GLiNER or model-runtime dependency.
"""

from pathlib import Path

from huggingface_hub.utils import validate_repo_id
from sie_sdk.cache import ensure_model_cached, get_cache_config

from sie_server.config.model import is_immutable_revision

_FILES = ("adapter_config.json", "adapter_model.safetensors")


def _check_files(directory: Path, *, blobs: Path | None = None) -> Path:
for filename in _FILES:
path = directory / filename
resolved = path.resolve()
if not resolved.is_relative_to(directory) and (blobs is None or not resolved.is_relative_to(blobs)):
raise ValueError("LoRA artifact escapes its declared directory or repository blobs")
if not path.is_file():
raise ValueError(f"LoRA source ships no {filename}")
return directory


def _check_repository(repository: Path) -> None:
# The configured cache root may intentionally be a symlink; it is already
# canonicalized. Derived repositories and layout directories may not alias
# another repository or snapshot.
for directory in (repository, *(repository / name for name in ("snapshots", "refs", "blobs"))):
if directory.resolve() != directory:
raise ValueError("LoRA cache repository directories cannot be aliases")


def resolve_lora_source(source: str, revision: str | None = None) -> Path:
"""Return the admitted two-file adapter from local, cluster or allowed Hub storage.

Explicit local directories (including a selected root symlink) are supported
without a Hub revision. Hub pins must be immutable commits. An unpinned Hub
source uses only its cached ``refs/main`` commit, never an arbitrary snapshot.
Standard snapshot-file links to this repository's blobs remain supported.
Missing files, redirected paths or a missing exact revision fail closed.
"""
directory = Path(source)
if directory.is_dir():
if revision is not None:
raise ValueError("A local LoRA directory cannot honor a Hugging Face revision pin")
directory = directory.resolve()
repository = directory.parent.parent
if (
directory.parent.name == "snapshots"
and is_immutable_revision(directory.name)
and repository.name.startswith("models--")
):
_check_repository(repository)
return _check_files(directory, blobs=repository / "blobs")
return _check_files(directory)

validate_repo_id(source)
if revision is not None and not is_immutable_revision(revision):
raise ValueError("LoRA revision must be an immutable 40-hex commit SHA")
config = get_cache_config()
repository = config.local_cache.resolve() / f"models--{source.replace('/', '--')}"
_check_repository(repository)
if revision is None:
reference = repository / "refs" / "main"
if reference.is_symlink():
raise ValueError("LoRA default revision cannot be a file alias")
if reference.is_file() and not is_immutable_revision(reference.read_text().strip()):
raise ValueError("LoRA cached default revision must be an immutable commit SHA")
cached = ensure_model_cached(
source,
config,
revision=revision or "main",
allow_patterns=list(_FILES),
required_files=list(_FILES),
)
if cached.resolve() != repository:
raise ValueError("LoRA cache returned a different repository")
_check_repository(repository)

if revision is None:
reference = repository / "refs" / "main"
if reference.is_symlink():
raise ValueError("LoRA default revision cannot be a file alias")
revision = reference.read_text().strip()
if not is_immutable_revision(revision):
raise ValueError("LoRA cached default revision must be an immutable commit SHA")
directory = repository / "snapshots" / revision
if directory.resolve() != directory:
raise ValueError("LoRA snapshot cannot alias another revision or repository")
return _check_files(directory, blobs=repository / "blobs")
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
from typing import Any

import torch
from huggingface_hub import snapshot_download
from peft import LoraConfig, PeftConfig
from peft.functional import delete_adapter
from peft.tuners.lora.layer import Linear as LoraLinear
from safetensors.torch import load_file
from sentence_transformers import CrossEncoder
from transformers import BertForSequenceClassification, XLMRobertaForSequenceClassification

from sie_server.adapters._lora_source import resolve_lora_source
from sie_server.adapters.lora import LoraRuntimeConfig, LoraStateUncertainError, LoraUnavailableError
from sie_server.adapters.peft_lora_mixin import PEFTLoRAMixin

Expand Down Expand Up @@ -211,12 +211,7 @@ def fetch(self, name: str, revision: str | None = None) -> str:
generation = self._generation
if source in self._prepared:
return self._prepared[source].directory.name
directory = Path(source)
if directory.is_dir():
if pinned is not None:
raise ValueError("A local LoRA directory cannot honor a Hugging Face revision pin")
else:
directory = Path(snapshot_download(source, revision=pinned, allow_patterns=list(_FILES)))
directory = resolve_lora_source(source, pinned)
temporary = tempfile.TemporaryDirectory(prefix="sie-cross-encoder-lora-")
try:
# A private snapshot prevents later source edits or alternative pickle files
Expand Down
24 changes: 8 additions & 16 deletions packages/sie_server/src/sie_server/adapters/peft_lora_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ def load(self, device: str) -> None:
import hashlib
import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar

from huggingface_hub import snapshot_download
from safetensors import safe_open

from sie_server.adapters._lora_source import resolve_lora_source

if TYPE_CHECKING:
from collections.abc import Collection

Expand All @@ -36,7 +36,6 @@ def load(self, device: str) -> None:
# OLoRA, LoftQ and LoRA-GA instead rewrite the base layer's weights.
_BASE_PRESERVING_INITIALIZERS = ("gaussian", "eva", "orthogonal")

_ADAPTER_CONFIG_FILE = "adapter_config.json"
_ADAPTER_WEIGHTS_FILE = "adapter_model.safetensors"

# The tensor names PEFT saves for a LoRA's own parameters. PEFT loads any
Expand Down Expand Up @@ -219,9 +218,10 @@ def supports_hot_lora_reload(self) -> bool:
def fetch_lora(self, lora_path: str, revision: str | None = None) -> str:
"""Resolve a LoRA to one local directory, and refuse it there unless it is safe to attach.

A Hub id is downloaded once, at one commit, with only its
``adapter_config.json`` and ``adapter_model.safetensors``; a local
directory is used as it is. :meth:`load_lora` then reads both files
A Hub id uses the configured local/cluster cache and allowed Hub
fallback at one commit; a local directory is used as it is. The
required files are ``adapter_config.json`` and
``adapter_model.safetensors``. :meth:`load_lora` reads both files
from the returned directory. Network and disk I/O happen here, so a
caller can run this outside inference.

Expand All @@ -238,21 +238,13 @@ def fetch_lora(self, lora_path: str, revision: str | None = None) -> str:
the adapter ships no ``adapter_model.safetensors``, or its
weights file has a tensor that is not a LoRA parameter.
"""
directory = Path(lora_path)
if not directory.is_dir():
directory = Path(
snapshot_download(
lora_path,
revision=revision,
allow_patterns=[_ADAPTER_CONFIG_FILE, _ADAPTER_WEIGHTS_FILE],
)
)
directory = resolve_lora_source(lora_path, revision)
try:
from peft import PeftConfig
except ImportError as e:
msg = "PEFT is required for LoRA support. Install with: pip install peft"
raise RuntimeError(msg) from e
self._check_lora_config(lora_path, PeftConfig.from_pretrained(str(directory)))
self._check_lora_config(lora_path, PeftConfig.from_pretrained(str(directory), local_files_only=True))
weights = directory / _ADAPTER_WEIGHTS_FILE
if not weights.is_file():
msg = f"LoRA '{lora_path}' ships no {_ADAPTER_WEIGHTS_FILE}; adapter weights in other formats are refused."
Expand Down
Loading
Loading