bge_m3_embedding_server/embedder/model_files.rs
1// Copyright (c) 2026 J. Patrick Fulton
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `HuggingFace` Hub download + cache-layout helpers for the BGE-M3 model files.
16
17use std::path::{Path, PathBuf};
18
19use anyhow::Result;
20use tracing::info;
21
22use crate::config::ModelVariant;
23
24const REPO_ID: &str = "BAAI/bge-m3";
25/// Pinned HF commit — prevents silent model updates and provides supply-chain
26/// integrity for the ONNX weights and tokenizer. Update this hash intentionally
27/// after verifying a new revision produces equivalent embeddings.
28const REPO_REVISION: &str = "5617a9f61b028005a4858fdac845db406aefb181";
29
30const XENOVA_REPO_ID: &str = "Xenova/bge-m3";
31/// Pinned HF commit for the Xenova/bge-m3 FP16 (~1.08 GB) and INT8 (~568 MB) models.
32/// Update intentionally after verifying equivalent embedding quality vs FP32.
33const XENOVA_REPO_REVISION: &str = "4de13258303883538bd53b696b452bf8099f0858";
34
35/// Paths to the ONNX model and tokenizer files resolved from the hf-hub cache.
36pub(super) struct ModelFiles {
37 /// Path to the ONNX model file (variant-specific).
38 pub onnx_path: PathBuf,
39 /// Path to the `tokenizer.json` file.
40 pub tokenizer_path: PathBuf,
41}
42
43/// Returns `true` when the primary ONNX model file already exists in the
44/// hf-hub snapshot cache, meaning `repo.get()` will return immediately
45/// without fetching from the network.
46///
47/// hf-hub 0.5.x layout when constructed with `ApiBuilder::with_cache_dir(p)`:
48/// `{p}/models--{owner}--{name}/snapshots/{revision}/{filename}`
49///
50/// Note: this differs from Python `huggingface_hub`, which appends a `hub/`
51/// segment when `HF_HOME` is set. The Rust crate treats `with_cache_dir`
52/// as `HF_HUB_CACHE` directly — no `hub/` subdirectory is added.
53fn is_model_cached(cache_dir: &Path, repo_id: &str, revision: &str, onnx_filename: &str) -> bool {
54 let repo_dir = format!("models--{}", repo_id.replace('/', "--"));
55 cache_dir
56 .join(repo_dir)
57 .join("snapshots")
58 .join(revision)
59 .join(onnx_filename)
60 .exists()
61}
62
63/// Downloads (or retrieves from the local hf-hub snapshot cache) the ONNX model
64/// and tokenizer files for the given model variant.
65///
66/// `show_progress` enables hf-hub's download progress bar; pass `true` only for
67/// the leader worker (worker 0) so progress is shown exactly once.
68pub(super) fn download_model_files(
69 cache_dir: &Path,
70 show_progress: bool,
71 variant: ModelVariant,
72) -> Result<ModelFiles> {
73 // Fail fast if the cache directory is structurally invalid (e.g. a path
74 // component is a regular file or a non-directory device, the parent is
75 // read-only, or the operator pointed `BGE_M3_CACHE_DIR` at something we
76 // can never write to). `create_dir_all` is idempotent on an already-valid
77 // directory, so this is a no-op on a healthy production setup. The check
78 // is cheap and runs before any network syscall.
79 //
80 // Without this check, `hf_hub::ApiBuilder` defers cache validation until
81 // mid-download — after a `metadata()` HTTP round-trip that has *no*
82 // default ureq timeout. On a runner with a misconfigured cache dir AND
83 // unreliable IPv6 connectivity (notably GitHub Actions), the connect
84 // call to huggingface.co blocks indefinitely instead of letting the
85 // doomed mkdir surface as the actual cause. That hang reaches all the
86 // way up to `EmbedPool::spawn`'s init task, which never sees a ready
87 // signal — manifesting as the spawn-tests timeout on CI.
88 std::fs::create_dir_all(cache_dir).map_err(|e| {
89 anyhow::anyhow!(
90 "Cannot create or access model cache directory {}: {e}",
91 cache_dir.display()
92 )
93 })?;
94
95 let (repo_id, repo_revision) = match variant {
96 ModelVariant::Fp32 => (REPO_ID, REPO_REVISION),
97 ModelVariant::Fp16 | ModelVariant::Int8 => (XENOVA_REPO_ID, XENOVA_REPO_REVISION),
98 };
99
100 // Check the hf-hub snapshot directory for the primary ONNX file before
101 // touching the network. This lets us log a clear "from cache" message
102 // rather than silence while hf-hub resolves files.
103 let onnx_filename = match variant {
104 ModelVariant::Fp32 => "onnx/model.onnx",
105 ModelVariant::Fp16 => "onnx/model_fp16.onnx",
106 ModelVariant::Int8 => "onnx/model_int8.onnx",
107 };
108 let cached = is_model_cached(cache_dir, repo_id, repo_revision, onnx_filename);
109 if cached {
110 info!(
111 repo_id,
112 revision = repo_revision,
113 model_variant = %variant,
114 "Model files found in local cache — no download needed"
115 );
116 } else {
117 info!(
118 repo_id,
119 revision = repo_revision,
120 model_variant = %variant,
121 "Model files not in local cache — downloading from HuggingFace Hub"
122 );
123 }
124
125 let api = hf_hub::api::sync::ApiBuilder::new()
126 .with_cache_dir(cache_dir.to_path_buf())
127 .with_progress(show_progress)
128 .build()
129 .map_err(|e| anyhow::anyhow!("Failed to build hf-hub API: {e}"))?;
130
131 let repo = api.repo(hf_hub::Repo::with_revision(
132 repo_id.to_string(),
133 hf_hub::RepoType::Model,
134 repo_revision.to_string(),
135 ));
136
137 let onnx_path = match variant {
138 ModelVariant::Fp32 => {
139 let path = repo
140 .get("onnx/model.onnx")
141 .map_err(|e| anyhow::anyhow!("Failed to get onnx/model.onnx: {e}"))?;
142 repo.get("onnx/model.onnx_data")
143 .map_err(|e| anyhow::anyhow!("Failed to get onnx/model.onnx_data: {e}"))?;
144 repo.get("onnx/Constant_7_attr__value")
145 .map_err(|e| anyhow::anyhow!("Failed to get onnx/Constant_7_attr__value: {e}"))?;
146 path
147 }
148 ModelVariant::Fp16 => repo
149 .get("onnx/model_fp16.onnx")
150 .map_err(|e| anyhow::anyhow!("Failed to get onnx/model_fp16.onnx: {e}"))?,
151 ModelVariant::Int8 => repo
152 .get("onnx/model_int8.onnx")
153 .map_err(|e| anyhow::anyhow!("Failed to get onnx/model_int8.onnx: {e}"))?,
154 };
155
156 let tokenizer_path = repo
157 .get("tokenizer.json")
158 .map_err(|e| anyhow::anyhow!("Failed to get tokenizer.json: {e}"))?;
159
160 Ok(ModelFiles {
161 onnx_path,
162 tokenizer_path,
163 })
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::config::ModelVariant;
170
171 /// `download_model_files` must fail synchronously — without ever issuing a
172 /// network request — when the cache directory is structurally impossible
173 /// to create. `/dev/null/impossible` is the canonical bad-cache fixture
174 /// used by `EmbedPool::spawn` tests: `/dev/null` is a character device on
175 /// every Unix, so `mkdir /dev/null/impossible` reliably returns ENOTDIR.
176 ///
177 /// Regression guard: before the upfront `create_dir_all` validation,
178 /// hf-hub's lazy cache layout meant this code path executed a metadata
179 /// HTTP call to huggingface.co first and only attempted the doomed mkdir
180 /// inside the download flow. On runners with no IPv6 connectivity and
181 /// ureq's default `None` connect timeout, that call blocked indefinitely
182 /// and the leader-failure spawn tests timed out on CI.
183 #[cfg(unix)]
184 #[test]
185 fn download_model_files_fails_fast_on_unwritable_cache_dir() {
186 let bad = Path::new("/dev/null/impossible");
187 let started = std::time::Instant::now();
188 let result = download_model_files(bad, false, ModelVariant::Fp32);
189 let elapsed = started.elapsed();
190 let Err(err) = result else {
191 panic!("expected Err for an unwritable cache dir, got Ok");
192 };
193 assert!(
194 elapsed < std::time::Duration::from_secs(2),
195 "validation should fail without a network round-trip; took {elapsed:?}"
196 );
197 let msg = err.to_string();
198 assert!(
199 msg.contains("cache directory"),
200 "error should mention the cache directory; got: {msg}"
201 );
202 }
203}