Skip to content
Open
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
57 changes: 46 additions & 11 deletions fastembed/common/model_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ def download_files_from_huggingface(
Path: The path to the model directory.
"""

def _repo_relative_path(relative_path: Path) -> str:
# Cached files are stored under `snapshots/<revision>/<repo_path>`. Strip that
# prefix so the remainder matches a repo file's path, which may itself be nested
# (e.g. `onnx/model.onnx`).
parts = relative_path.parts
if len(parts) > 2 and parts[0] == "snapshots":
return "/".join(parts[2:])
return relative_path.as_posix()

def _verify_files_from_metadata(
model_dir: Path, stored_metadata: dict[str, Any], repo_files: list[RepoFile]
) -> bool:
Expand All @@ -162,7 +171,8 @@ def _verify_files_from_metadata(
return False

if repo_files: # online verification
file_info = next((f for f in repo_files if f.path == file_path.name), None)
repo_path = _repo_relative_path(Path(rel_path))
file_info = next((f for f in repo_files if f.path == repo_path), None)
if (
not file_info
or file_info.size != meta["size"]
Expand All @@ -185,9 +195,10 @@ def _collect_file_metadata(
file_info_map = {f.path: f for f in repo_files}
for file_path in model_dir.rglob("*"):
if file_path.is_file() and file_path.name != cls.METADATA_FILE:
repo_file = file_info_map.get(file_path.name)
relative_path = file_path.relative_to(model_dir)
repo_file = file_info_map.get(_repo_relative_path(relative_path))
if repo_file:
meta[str(file_path.relative_to(model_dir))] = {
meta[str(relative_path)] = {
"size": repo_file.size,
"blob_id": repo_file.blob_id,
}
Expand Down Expand Up @@ -218,11 +229,26 @@ def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]])
disable_progress_bars()
if metadata_file.exists():
metadata = json.loads(metadata_file.read_text())
verified = _verify_files_from_metadata(snapshot_dir, metadata, repo_files=[])
if not verified:
logger.warning(
"Local file sizes do not match the metadata."
) # do not raise, still make an attempt to load the model
if not _verify_files_from_metadata(snapshot_dir, metadata, repo_files=[]):
# A size mismatch on a model file means ONNX Runtime would later fail
# with a cryptic protobuf error. Raise so download_model's offline probe
# (which wraps this call in `except Exception`) falls through to a forced
# online re-download instead of returning a corrupt cached path. A
# mismatch limited to an auxiliary file (e.g. a config) is left to
# best-effort loading, preserving the previous behavior.
model_files = set(extra_patterns)
model_file_corrupt = any(
_repo_relative_path(Path(rel_path)) in model_files
and (snapshot_dir / rel_path).is_file()
and (snapshot_dir / rel_path).stat().st_size != meta["size"]
for rel_path, meta in metadata.items()
)
if model_file_corrupt:
raise ValueError(
"Cached model files do not match the stored metadata; "
"the cache appears corrupted."
)
logger.warning("Local file sizes do not match the metadata.")
result = snapshot_download(
repo_id=hf_source_repo,
allow_patterns=allow_patterns,
Expand All @@ -233,9 +259,13 @@ def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]])
return result

repo_revision = model_info(hf_source_repo).sha
repo_tree = list(list_repo_tree(hf_source_repo, revision=repo_revision, repo_type="model"))
repo_tree = list(
list_repo_tree(
hf_source_repo, revision=repo_revision, repo_type="model", recursive=True
)
)

allowed_extensions = {".json", ".onnx", ".txt"}
allowed_extensions = {".json", ".onnx", ".txt", ".onnx_data", ".npy", ".vocab"}
Comment on lines +262 to +268

@coderabbitai coderabbitai Bot May 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

huggingface_hub list_repo_tree recursive parameter

💡 Result:

In huggingface_hub, HfApi.list_repo_tree() does not provide a recursive parameter. Instead, you control what is listed via its other arguments (notably path/subdirectory scope) and you can get expanded metadata depending on the expand flag. Hugging Face staff also note that listing files in a repo is costly and that “it is not possible to filter them server-side when listing files from a repo… best solution is still to do the filtering client-side”; however, you can limit the request by only listing files from a subdirectory [1]. Practical implication for your question: if you want a recursive listing, you should treat list_repo_tree() as the API meant for listing repo trees, and use the supported arguments to restrict scope (e.g., target a subdirectory path). Don’t look for a recursive parameter; it isn’t part of the documented method signature [1]. References: Hugging Face issue discussion explaining server-side filtering limits for list_repo_tree [1]; and the official HfApi documentation entry point (for method signatures and related API usage) [2].

Citations:


Fix list_repo_tree call: remove unsupported recursive=True.

fastembed/common/model_management.py (lines 262-268) passes recursive=True to huggingface_hub.list_repo_tree(), but HfApi.list_repo_tree() has no recursive parameter; this will raise a TypeError at runtime. Rework the listing to use supported arguments (e.g., subdirectory scoping via path and/or expand, then filter client-side) to collect nested model files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fastembed/common/model_management.py` around lines 262 - 268, The call to
list_repo_tree(...) incorrectly uses recursive=True; remove that unsupported
kwarg and instead call list_repo_tree(hf_source_repo, revision=repo_revision,
repo_type="model", path=...) or use expand=True (depending on API) to retrieve
directory entries, then perform client-side recursion/filtering to collect
nested files into repo_tree; update the code around repo_tree and the filtering
that uses allowed_extensions to walk subdirectories (by repeatedly calling
list_repo_tree with sub-paths or expanding entries) so that nested model files
are discovered without passing recursive=True to huggingface_hub.list_repo_tree.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_repo_tree does take recursive. It is in the signature (recursive: bool = False) and has been since the method landed in huggingface_hub 0.23.0, which is also the floor this function already needs, since it calls list_repo_tree regardless of this PR. So there is no new version constraint here. The linked #2676 is about server-side filtering on large repos, not whether the parameter exists. Verified on 1.17.0: the call returns the nested files (onnx/model.onnx plus the quantized variants) without raising, so I have left recursive=True as is.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Resolve the coderabbit review comment on the PR
gh api graphql -f query='
{
  repository(owner: "qdrant", name: "fastembed") {
    pullRequest(number: 642) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 5) {
            nodes {
              author { login }
              body
              url
            }
          }
        }
      }
    }
  }
}'

Length of output: 8970


🏁 Script executed:

#!/bin/bash
# Resolve the incorrect review thread
gh api graphql -f query='
mutation {
  resolveReviewThread(input: { threadId: "PRRT_kwDOJ7ZVjc6F90mS" }) {
    thread {
      id
      isResolved
    }
  }
}'

Length of output: 717



@Dmheath1, thanks for the thorough clarification — my initial flag was wrong and I've noted the correction. Unfortunately I don't have permission to resolve the thread programmatically in this repo, so you'd need to resolve it manually. The recursive=True usage is correct and the review comment can be dismissed.

(ᵔ ᵕ ᵔ)🐇


🧬 Code Graph Analysis Results

fastembed/common/model_management.py

Lines 11-12 (imports list_repo_tree):

from huggingface_hub import snapshot_download, model_info, list_repo_tree
from huggingface_hub.hf_api import RepoFile

Lines 261-277 (calls list_repo_tree(..., recursive=True) and builds repo_files from the returned tree):

repo_revision = model_info(hf_source_repo).sha
repo_tree = list(
    list_repo_tree(
        hf_source_repo, revision=repo_revision, repo_type="model", recursive=True
    )
)

allowed_extensions = {".json", ".onnx", ".txt", ".onnx_data", ".npy", ".vocab"}
repo_files = (
    [
        f
        for f in repo_tree
        if isinstance(f, RepoFile) and Path(f.path).suffix in allowed_extensions
    ]
    if repo_tree
    else []
)

✏️ Learnings added
Learnt from: Dmheath1
Repo: qdrant/fastembed PR: 642
File: fastembed/common/model_management.py:262-268
Timestamp: 2026-05-31T19:37:43.899Z
Learning: `huggingface_hub.list_repo_tree()` does support a `recursive: bool = False` parameter — it has been part of the method signature since huggingface_hub 0.23.0 and works correctly on 1.17.0. Passing `recursive=True` returns all files in nested subdirectories (e.g., `onnx/model.onnx`) without raising a TypeError. GitHub issue huggingface/huggingface_hub#2676 is about server-side filtering performance on large repos, not the absence of the `recursive` parameter. Do not flag `recursive=True` in `list_repo_tree` calls as invalid.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

repo_files = (
[
f
Expand Down Expand Up @@ -442,7 +472,12 @@ def download_model(cls, model: T, cache_dir: str, retries: int = 3, **kwargs: An
hf_source,
cache_dir=cache_dir,
extra_patterns=extra_patterns,
**kwargs,
# The local probe failed (cache missing or corrupt). force_download so
# huggingface_hub re-fetches a present-but-truncated blob instead of
# trusting it (its cache short-circuit is existence-only). Merge into
# kwargs so a caller-supplied force_download can't raise a duplicate
# keyword error.
**{**kwargs, "force_download": True},
)
)
except (EnvironmentError, RepositoryNotFoundError, ValueError) as e:
Expand Down
92 changes: 92 additions & 0 deletions tests/test_model_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Offline cache-verification tests for ModelManagement.

The offline probe must distinguish a corrupt *model* file (which would make ONNX Runtime
fail with a cryptic protobuf error) from a benign size drift on an auxiliary file. A corrupt
model file must raise so ``download_model`` falls through to a forced re-download; an auxiliary
mismatch must be tolerated so an offline caller is not bricked by best-effort-loadable drift.
Model files may live in a subdirectory (e.g. ``onnx/model.onnx``), so matching is done on the
repo-relative path, not the bare filename.
"""

import json
from pathlib import Path
from typing import Any

import pytest

from fastembed.common import model_management
from fastembed.common.model_management import ModelManagement

REVISION = "0123456789abcdef0123456789abcdef01234567"


def _seed_cache(cache: Path, files: dict[str, bytes], metadata: dict[str, Any]) -> Path:
snapshot = cache / "models--qdrant--fake-onnx"
for name, blob in files.items():
path = snapshot / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(blob)
snapshot.mkdir(parents=True, exist_ok=True)
(snapshot / ModelManagement.METADATA_FILE).write_text(json.dumps(metadata))
return snapshot


def test_offline_probe_raises_when_model_file_is_corrupt(tmp_path: Path) -> None:
cache = tmp_path / "cache"
_seed_cache(
cache,
files={"model.onnx": b"x" * 10},
metadata={"model.onnx": {"size": 999_999, "blob_id": "deadbeef"}},
)

with pytest.raises(ValueError, match="corrupt"):
ModelManagement.download_files_from_huggingface(
"qdrant/fake-onnx",
cache_dir=str(cache),
extra_patterns=["model.onnx"],
local_files_only=True,
)


def test_offline_probe_raises_when_subdir_model_file_is_corrupt(tmp_path: Path) -> None:
cache = tmp_path / "cache"
key = f"snapshots/{REVISION}/onnx/model.onnx"
_seed_cache(
cache,
files={key: b"x" * 10},
metadata={key: {"size": 999_999, "blob_id": "deadbeef"}},
)

with pytest.raises(ValueError, match="corrupt"):
ModelManagement.download_files_from_huggingface(
"qdrant/fake-onnx",
cache_dir=str(cache),
extra_patterns=["onnx/model.onnx"],
local_files_only=True,
)


def test_offline_probe_tolerates_auxiliary_file_drift(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
cache = tmp_path / "cache"
snapshot = _seed_cache(
cache,
files={"model.onnx": b"x" * 10, "config.json": b"y" * 20},
metadata={
"model.onnx": {"size": 10, "blob_id": "modelblob"},
"config.json": {"size": 999, "blob_id": "configblob"},
},
)

# The model file matches metadata; only config.json drifted. The probe must NOT raise:
# it falls through to snapshot_download (mocked here to return the cached path).
monkeypatch.setattr(model_management, "snapshot_download", lambda **kwargs: str(snapshot))

result = ModelManagement.download_files_from_huggingface(
"qdrant/fake-onnx",
cache_dir=str(cache),
extra_patterns=["model.onnx"],
local_files_only=True,
)
assert result == str(snapshot)