hf_fetch_model/inspect.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Safetensors header inspection (local and remote).
4//!
5//! Reads tensor metadata (names, shapes, dtypes, byte offsets) from
6//! `.safetensors` files without downloading full weight data. Supports
7//! cache-first resolution with HTTP Range request fallback.
8//!
9//! The primary types are [`TensorInfo`] (per-tensor metadata),
10//! [`SafetensorsHeaderInfo`] (parsed header), and [`ShardedIndex`]
11//! (shard-to-tensor mapping for sharded models).
12//!
13//! The module also reads small JSON sidecars from the same cache-first /
14//! HTTP-fallback path: [`AdapterConfig`] (`adapter_config.json`, for `PEFT`
15//! adapters) and [`ModelConfig`] (`config.json`, the architecture parameters
16//! that drive `inspect --check-gpu --context` KV-cache budgeting), via
17//! [`fetch_model_config`] / [`fetch_model_config_cached`].
18
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22use serde::Serialize;
23use tokio::task::JoinSet;
24
25use crate::cache;
26use crate::cache_layout;
27use crate::chunked;
28use crate::error::FetchError;
29
30// -----------------------------------------------------------------------
31// Types
32// -----------------------------------------------------------------------
33
34/// Metadata for a single tensor from a `.safetensors` header.
35///
36/// This is hf-fetch-model's own type — lightweight, no quantization logic.
37/// Consumers (e.g., anamnesis) map this into their own richer types.
38#[derive(Debug, Clone, Serialize)]
39pub struct TensorInfo {
40 /// Tensor name (e.g., `"model.layers.0.self_attn.q_proj.weight"`).
41 pub name: String,
42 /// Element dtype string as it appears in the header (e.g., `"F8_E4M3"`, `"BF16"`).
43 pub dtype: String,
44 /// Tensor shape (e.g., `[7168, 7168]`).
45 pub shape: Vec<usize>,
46 /// Byte offset range `[start, end)` within the data section of the file.
47 pub data_offsets: (u64, u64),
48}
49
50impl TensorInfo {
51 /// Total number of elements (product of shape dimensions).
52 ///
53 /// Returns `1` for a scalar (empty shape).
54 #[must_use]
55 pub fn num_elements(&self) -> u64 {
56 self.shape.iter().fold(1u64, |acc, &d| {
57 // CAST: usize → u64, dimension values fit in u64
58 #[allow(clippy::as_conversions)]
59 let dim = d as u64;
60 acc.saturating_mul(dim)
61 })
62 }
63
64 /// Byte length of the tensor data (`end - start`).
65 #[must_use]
66 pub const fn byte_len(&self) -> u64 {
67 self.data_offsets.1.saturating_sub(self.data_offsets.0)
68 }
69
70 /// Bytes per element for the tensor's dtype, if recognized.
71 ///
72 /// Returns `None` for unknown dtype strings. Recognized dtypes:
73 ///
74 /// | Dtype string | Bytes | Notes |
75 /// |-------------|-------|-------|
76 /// | `"BOOL"` | 1 | |
77 /// | `"U8"`, `"I8"` | 1 | |
78 /// | `"F8_E4M3"`, `"F8_E5M2"` | 1 | FP8 variants |
79 /// | `"U16"`, `"I16"`, `"F16"`, `"BF16"` | 2 | |
80 /// | `"U32"`, `"I32"`, `"F32"` | 4 | |
81 /// | `"U64"`, `"I64"`, `"F64"` | 8 | |
82 #[must_use]
83 pub fn dtype_bytes(&self) -> Option<usize> {
84 // BORROW: explicit .as_str() instead of Deref coercion
85 match self.dtype.as_str() {
86 "BOOL" | "U8" | "I8" | "F8_E4M3" | "F8_E5M2" => Some(1),
87 "U16" | "I16" | "F16" | "BF16" => Some(2),
88 "U32" | "I32" | "F32" => Some(4),
89 "U64" | "I64" | "F64" => Some(8),
90 _ => None,
91 }
92 }
93}
94
95/// Bytes per element for a model's activation dtype, as spelled in a
96/// `config.json` `torch_dtype` field.
97///
98/// Distinct from [`TensorInfo::dtype_bytes`], which maps the *safetensors*
99/// header spellings (`"BF16"`, `"F16"`, …); `config.json` uses the `PyTorch`
100/// spellings (`"bfloat16"`, `"float16"`, `"float32"`, `"float8_e4m3fn"`).
101/// Used to size the KV cache, whose element dtype tracks the model's
102/// activations (typically `bf16` / `fp16`) independently of weight
103/// quantization. Defaults to `2` when the dtype is absent or unrecognized —
104/// the modern inference default and the safe assumption for KV sizing.
105#[must_use]
106pub fn torch_dtype_bytes(torch_dtype: Option<&str>) -> u8 {
107 match torch_dtype {
108 Some("float32" | "float") => 4,
109 Some("float8_e4m3fn" | "float8_e5m2") => 1,
110 // `bf16` / `fp16`, and any unknown or absent dtype: 2-byte activations.
111 _ => 2,
112 }
113}
114
115/// Quantization scheme + size estimates for a cached `.safetensors` file.
116///
117/// Populated by [`inspect_safetensors_local`] (the cache-hit path) via
118/// `anamnesis::InspectInfo::from(&header)`. Absent (`None`) when:
119/// - the safetensors file has no detected quantization (`QuantScheme::Unquantized`),
120/// - the inspect ran over HTTP Range (the remote path's bespoke parser
121/// doesn't go through anamnesis until v0.11.1), or
122/// - the file format isn't safetensors (`GGUF` / `NPZ` / `PTH` carry no
123/// quant-method metadata).
124///
125/// Decoupled from `anamnesis::QuantScheme` (a `#[non_exhaustive]` enum) so
126/// downstream library consumers (`candle-mi`, `anamnesis`) aren't forced to
127/// match every variant. The `scheme` field stores `QuantScheme`'s `Display`
128/// output (`"FineGrainedFp8"`, `"Bnb4"`, `"Gptq"`, `"Awq"`, …); consumers
129/// that need to match exact variants should call
130/// `anamnesis::parse_safetensors_header` themselves.
131#[derive(Debug, Clone, Serialize)]
132pub struct QuantInfo {
133 /// Detected quantization scheme as the `Display` form of
134 /// `anamnesis::QuantScheme` (e.g. `"FineGrainedFp8"`, `"Bnb4"`).
135 pub scheme: String,
136 /// Bytes stored on disk for tensor data (header excluded).
137 pub stored_bytes: u64,
138 /// Estimated bytes after dequantising to `BF16`. For `BnB-NF4`/`FP4`
139 /// (`U8`-packed nibbles), this is `stored_bytes × 4`; for `FP8` / `GPTQ` /
140 /// `AWQ` / `BnB-INT8` it's `num_elements × 2` summed over weight
141 /// tensors, plus passthrough tensors copied as-is. The formula lives
142 /// in `anamnesis::InspectInfo::from(&SafetensorsHeader)` — hf-fm just
143 /// reads the result.
144 pub dequantized_bytes: u64,
145}
146
147/// Parsed safetensors header metadata.
148///
149/// Marked `#[non_exhaustive]` (since v0.10.3) — the struct has been
150/// growing through v0.10.x (the `quant_info` field landed in Phase C)
151/// and will keep growing in v0.11.x. External library consumers should
152/// pattern-match with `..` or use field reads, not exhaustive struct
153/// literals.
154#[derive(Debug, Clone, Serialize)]
155#[non_exhaustive]
156pub struct SafetensorsHeaderInfo {
157 /// All tensors in the header, in the order they appear in the JSON.
158 pub tensors: Vec<TensorInfo>,
159 /// Raw `__metadata__` entries, if present.
160 ///
161 /// For quantized models, this typically contains entries like
162 /// `quant_method`, `bits`, `group_size` that consumers like anamnesis
163 /// use to distinguish GPTQ from AWQ without downloading weights.
164 pub metadata: Option<HashMap<String, String>>,
165 /// Size of the JSON header in bytes.
166 pub header_size: u64,
167 /// Total file size in bytes (header + data), if known.
168 ///
169 /// **Source:** for local files, from `std::fs::metadata().len()`. For HTTP
170 /// Range requests, extracted from the `Content-Range` response header of
171 /// the first request (`bytes 0-7/TOTAL` → `TOTAL`). This is free — no
172 /// extra request needed.
173 pub file_size: Option<u64>,
174 /// Quantization scheme + size estimates (cached safetensors only).
175 ///
176 /// Populated by [`inspect_safetensors_local`] when a non-`Unquantized`
177 /// `QuantScheme` is detected. `None` for unquantized safetensors,
178 /// `GGUF` / `NPZ` / `PTH` files, and the remote safetensors path
179 /// (until v0.11.1 migrates that path to anamnesis).
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub quant_info: Option<QuantInfo>,
182}
183
184impl SafetensorsHeaderInfo {
185 /// Total parameter count across all tensors.
186 #[must_use]
187 pub fn total_params(&self) -> u64 {
188 self.tensors
189 .iter()
190 .map(TensorInfo::num_elements)
191 .fold(0u64, u64::saturating_add)
192 }
193
194 /// Returns tensors matching a dtype string (e.g., `"F8_E4M3"`).
195 #[must_use]
196 pub fn tensors_with_dtype(&self, dtype: &str) -> Vec<&TensorInfo> {
197 self.tensors
198 .iter()
199 // BORROW: explicit .as_str() instead of Deref coercion
200 .filter(|t| t.dtype.as_str() == dtype)
201 .collect()
202 }
203
204 /// Constructs a new [`SafetensorsHeaderInfo`] from its core fields.
205 ///
206 /// Since v0.10.3 the struct is `#[non_exhaustive]` — this constructor is
207 /// the canonical way to build one from outside the `hf-fetch-model` lib
208 /// crate (e.g. the `hf-fm` binary crate, downstream consumers like
209 /// `candle-mi`). Inside the lib crate, struct-literal syntax stays
210 /// available for the inspect entry points.
211 ///
212 /// `quant_info` is typically `None`; populated only by
213 /// [`inspect_safetensors_local`] for cached, quantized safetensors files.
214 #[must_use]
215 pub fn new(
216 tensors: Vec<TensorInfo>,
217 metadata: Option<HashMap<String, String>>,
218 header_size: u64,
219 file_size: Option<u64>,
220 quant_info: Option<QuantInfo>,
221 ) -> Self {
222 Self {
223 tensors,
224 metadata,
225 header_size,
226 file_size,
227 quant_info,
228 }
229 }
230}
231
232/// The source from which a header was read.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234#[non_exhaustive]
235pub enum InspectSource {
236 /// Read from local cache (no network).
237 Cached,
238 /// Fetched via HTTP Range requests.
239 Remote,
240}
241
242/// Parsed `model.safetensors.index.json` for a sharded model.
243#[derive(Debug, Clone, Serialize)]
244pub struct ShardedIndex {
245 /// Mapping from tensor name to shard filename.
246 pub weight_map: HashMap<String, String>,
247 /// Ordered list of unique shard filenames.
248 pub shards: Vec<String>,
249 /// Raw metadata from the index, if present.
250 pub metadata: Option<HashMap<String, serde_json::Value>>,
251}
252
253/// `PEFT` adapter configuration parsed from `adapter_config.json`.
254///
255/// Contains the key fields that identify an adapter: the `PEFT` type,
256/// base model, `LoRA` rank and scaling parameters, and target modules.
257/// All fields are optional because adapter configs vary across `PEFT` methods.
258#[derive(Debug, Clone, Serialize)]
259pub struct AdapterConfig {
260 /// `PEFT` method type (e.g., `"LORA"`, `"ADALORA"`, `"IA3"`).
261 pub peft_type: Option<String>,
262 /// The base model this adapter was trained on.
263 pub base_model_name_or_path: Option<String>,
264 /// `LoRA` rank (the `r` parameter). Only meaningful for `LoRA`-family methods.
265 pub r: Option<u32>,
266 /// `LoRA` alpha scaling factor. Only meaningful for `LoRA`-family methods.
267 pub lora_alpha: Option<f64>,
268 /// List of model modules targeted by the adapter.
269 pub target_modules: Vec<String>,
270 /// Task type the adapter was trained for (e.g., `"CAUSAL_LM"`).
271 pub task_type: Option<String>,
272}
273
274/// Attention- and cache-relevant fields parsed from a model's `config.json`.
275///
276/// Every field is [`Option`] because configs vary across architecture
277/// families; the KV-cache estimator decides which combinations are
278/// computable and which fall back to an "unavailable" verdict. Legacy
279/// `n_layer` / `n_head` / `n_head_kv` spellings are absorbed by serde aliases
280/// on the private deserialization struct. Drives KV-cache budgeting for
281/// `inspect --check-gpu --context`.
282#[derive(Debug, Clone, Default, Serialize)]
283#[non_exhaustive]
284pub struct ModelConfig {
285 /// Architecture tag (e.g. `"llama"`, `"qwen3"`, `"gemma2"`, `"deepseek_v2"`).
286 pub model_type: Option<String>,
287 /// Number of transformer layers (`num_hidden_layers` / `n_layer`).
288 pub num_hidden_layers: Option<u32>,
289 /// Number of query attention heads (`num_attention_heads` / `n_head`).
290 pub num_attention_heads: Option<u32>,
291 /// Number of key/value heads for `GQA` (`num_key_value_heads` /
292 /// `num_kv_heads` / `n_head_kv`). Absent ⇒ `MHA` (equals
293 /// `num_attention_heads`).
294 pub num_key_value_heads: Option<u32>,
295 /// Explicit per-head dimension when stated (Gemma = 256, Qwen3 = 128).
296 /// Absent ⇒ derived as `hidden_size / num_attention_heads`.
297 pub head_dim: Option<u32>,
298 /// Model hidden size, used to derive `head_dim` when it is not explicit.
299 pub hidden_size: Option<u32>,
300 /// Activation dtype spelling (`"bfloat16"`, `"float16"`, …); sizes the
301 /// KV-cache element via [`torch_dtype_bytes`].
302 pub torch_dtype: Option<String>,
303 /// Sliding-window span in tokens when the model uses windowed attention.
304 /// `null` / absent ⇒ full attention.
305 pub sliding_window: Option<u32>,
306 /// Global-attention period for mixed local/global layouts (Gemma-3:
307 /// every `N`-th layer is a full-attention layer).
308 pub sliding_window_pattern: Option<u32>,
309 /// Explicit on/off switch for sliding-window attention — Qwen2/3 ship a
310 /// `sliding_window` value but disable it with `false`.
311 pub use_sliding_window: Option<bool>,
312 /// `MLA` latent-KV rank (`DeepSeek`). Presence marks multi-head latent
313 /// attention, where the naive KV formula does not apply.
314 pub kv_lora_rank: Option<u32>,
315 /// `MLA` decoupled-`RoPE` key dimension (`DeepSeek`); part of the latent-KV
316 /// size used by the documented `MLA` estimate.
317 pub qk_rope_head_dim: Option<u32>,
318 /// Per-layer kind tags for hybrid models (`"attention"` / `"mamba"` /
319 /// `"linear_attention"` / …). Primary hybrid-layout signal (Granite-4).
320 pub layer_types: Option<Vec<String>>,
321 /// Nemotron-H layer-layout string (`"M-M-M-M*-…"`: `*` = attention,
322 /// `M` = Mamba, `-` = FFN-only). Alternative hybrid-layout signal.
323 pub hybrid_override_pattern: Option<String>,
324 /// Explicit indices of the attention layers (Bamba). Alternative
325 /// hybrid-layout signal; the remaining layers are recurrent.
326 pub attn_layer_indices: Option<Vec<u32>>,
327 /// Period of full-attention layers — every `N`-th layer is attention, the
328 /// rest recurrent (Qwen3-Next). Alternative hybrid-layout signal.
329 pub full_attention_interval: Option<u32>,
330 /// Mamba2 SSM head count (`mamba_n_heads` / `mamba_num_heads`).
331 pub mamba_n_heads: Option<u32>,
332 /// Mamba2 SSM per-head dimension (`mamba_d_head` / `mamba_head_dim`).
333 pub mamba_d_head: Option<u32>,
334 /// Mamba2 SSM state size (`mamba_d_state` / `ssm_state_size`).
335 pub mamba_d_state: Option<u32>,
336 /// Mamba2 causal-convolution width (`mamba_d_conv` / `conv_kernel`).
337 pub mamba_d_conv: Option<u32>,
338 /// Mamba2 group count for the convolution (`mamba_n_groups` / `n_groups`).
339 pub mamba_n_groups: Option<u32>,
340}
341
342// -----------------------------------------------------------------------
343// JSON parsing
344// -----------------------------------------------------------------------
345
346/// Raw tensor entry as it appears in the safetensors JSON header.
347#[derive(serde::Deserialize)]
348struct RawTensorEntry {
349 dtype: String,
350 shape: Vec<usize>,
351 data_offsets: (u64, u64),
352}
353
354/// Parsed tensor list and optional metadata from a safetensors header.
355type ParsedHeader = (Vec<TensorInfo>, Option<HashMap<String, String>>);
356
357/// Parses the safetensors JSON header bytes into tensor metadata.
358///
359/// Extracts the `__metadata__` key separately (if present).
360fn parse_header_json(json_bytes: &[u8], filename: &str) -> Result<ParsedHeader, FetchError> {
361 let raw: HashMap<String, serde_json::Value> =
362 serde_json::from_slice(json_bytes).map_err(|e| FetchError::SafetensorsHeader {
363 filename: filename.to_owned(),
364 reason: format!("failed to parse header JSON: {e}"),
365 })?;
366
367 let mut metadata: Option<HashMap<String, String>> = None;
368 let mut tensors = Vec::new();
369
370 for (key, value) in raw {
371 if key == "__metadata__" {
372 if let serde_json::Value::Object(obj) = value {
373 let mut meta_map = HashMap::new();
374 for (mk, mv) in obj {
375 // BORROW: explicit .to_owned()/.to_string() for Value → String conversion
376 let v_str = if let Some(s) = mv.as_str() {
377 s.to_owned()
378 } else {
379 mv.to_string()
380 };
381 meta_map.insert(mk, v_str);
382 }
383 metadata = Some(meta_map);
384 }
385 continue;
386 }
387
388 let entry: RawTensorEntry =
389 serde_json::from_value(value).map_err(|e| FetchError::SafetensorsHeader {
390 filename: filename.to_owned(),
391 reason: format!("failed to parse tensor \"{key}\": {e}"),
392 })?;
393
394 tensors.push(TensorInfo {
395 name: key,
396 dtype: entry.dtype,
397 shape: entry.shape,
398 data_offsets: entry.data_offsets,
399 });
400 }
401
402 // Sort by data offset start to preserve file order.
403 tensors.sort_by_key(|t| t.data_offsets.0);
404
405 Ok((tensors, metadata))
406}
407
408// -----------------------------------------------------------------------
409// Cache resolution
410// -----------------------------------------------------------------------
411
412/// Resolves a cached file path for a given repo, revision, and filename.
413///
414/// Returns `None` if the file is not in the local cache.
415fn resolve_cached_path(repo_id: &str, revision: &str, filename: &str) -> Option<PathBuf> {
416 let cache_dir = cache::hf_cache_dir().ok()?;
417 let repo_dir = cache_layout::repo_dir(&cache_dir, repo_id);
418 let commit_hash = cache::read_ref(&repo_dir, revision)?;
419 let cached_path = cache_layout::pointer_path(&repo_dir, &commit_hash, filename);
420 if cached_path.exists() {
421 Some(cached_path)
422 } else {
423 None
424 }
425}
426
427// -----------------------------------------------------------------------
428// Local file reading
429// -----------------------------------------------------------------------
430
431/// Inspects a single `.safetensors` file's header from a local file path.
432///
433/// Reads the first `8 + header_size` bytes from disk. Does not read tensor data.
434///
435/// # Blocking I/O
436///
437/// This function performs synchronous filesystem I/O. In async contexts, wrap
438/// it in [`tokio::task::spawn_blocking`] so the calling task does not stall
439/// the runtime — particularly important on network-mounted caches (NFS/CIFS)
440/// where `read`/`stat` calls can take tens of milliseconds each.
441///
442/// # Errors
443///
444/// Returns [`FetchError::Io`] if the file cannot be read.
445/// Returns [`FetchError::SafetensorsHeader`] if the header is malformed.
446pub fn inspect_safetensors_local(path: &Path) -> Result<SafetensorsHeaderInfo, FetchError> {
447 let file_size = std::fs::metadata(path)
448 .map_err(|e| FetchError::Io {
449 path: path.to_path_buf(),
450 source: e,
451 })?
452 .len();
453
454 // BORROW: explicit .to_string_lossy() for Path → str conversion
455 let filename = path.file_name().map_or_else(
456 || path.display().to_string(),
457 |n| n.to_string_lossy().to_string(),
458 );
459
460 let file = std::fs::File::open(path).map_err(|e| FetchError::Io {
461 path: path.to_path_buf(),
462 source: e,
463 })?;
464
465 // Cache-hit path delegates to anamnesis (v0.10.3 Phase B commit 4):
466 // single source of truth for safetensors layout. The reader variant
467 // reads the 8-byte u64 prefix + the JSON header bytes from the `Read`
468 // impl itself, *without* requiring the data section to be present
469 // (it bypasses `safetensors::SafeTensors::read_metadata` for exactly
470 // this reason). Anamnesis caps the declared header length at 100 MiB
471 // internally so the worst-case allocation is bounded.
472 //
473 // The remote path's `fetch_header_bytes` + `parse_header_json` chain
474 // stays bespoke until v0.11.1, when anamnesis's reader variant adapts
475 // to an HTTP Range source.
476 let header = anamnesis::parse_safetensors_header_from_reader(file).map_err(|e| {
477 FetchError::SafetensorsHeader {
478 // BORROW: explicit .clone() for the error variant's owned String field
479 filename: filename.clone(),
480 reason: format!("failed to parse safetensors header: {e}"),
481 }
482 })?;
483 // CAST: usize → u64, anamnesis caps header_size at 100 MiB so it always fits in u64
484 #[allow(clippy::as_conversions)]
485 let header_size = header.header_size as u64;
486
487 // Phase C: derive `quant_info` before the `header.tensors` move below.
488 // `anamnesis::InspectInfo::from(&header)` iterates the already-parsed
489 // tensors and aggregates per-role byte sums — pure computation, no I/O.
490 // Unquantized models produce no `quant_info` so the renderer suppresses
491 // the new `Format:` / `Size:` lines (absence communicates full precision).
492 let quant_info = if header.scheme == anamnesis::QuantScheme::Unquantized {
493 None
494 } else {
495 let info = anamnesis::InspectInfo::from(&header);
496 Some(QuantInfo {
497 // BORROW: explicit .to_string() — anamnesis `QuantScheme` → owned `String`
498 scheme: info.format.to_string(),
499 stored_bytes: info.current_size,
500 dequantized_bytes: info.dequantized_size,
501 })
502 };
503
504 let mut tensors: Vec<TensorInfo> = header
505 .tensors
506 .into_iter()
507 .map(|t| {
508 // CAST: usize → u64, header data_offsets fit in u64 by definition (file size is u64)
509 #[allow(clippy::as_conversions)]
510 let start = t.data_offsets.0 as u64;
511 // CAST: usize → u64, same rationale as above
512 #[allow(clippy::as_conversions)]
513 let end = t.data_offsets.1 as u64;
514 TensorInfo {
515 name: t.name,
516 // BORROW: explicit .to_string() — anamnesis `Dtype` enum → owned `String`
517 dtype: t.dtype.to_string(),
518 shape: t.shape,
519 data_offsets: (start, end),
520 }
521 })
522 .collect();
523
524 // Preserve hf-fm's v0.10.2 sort order. Anamnesis returns tensors sorted
525 // alphabetically by name; hf-fm's inspect table has always been
526 // file-ordered (sorted by start offset) so users can spot first/last
527 // tensors per shard at a glance.
528 tensors.sort_by_key(|t| t.data_offsets.0);
529
530 Ok(SafetensorsHeaderInfo {
531 tensors,
532 metadata: header.metadata,
533 header_size,
534 file_size: Some(file_size),
535 quant_info,
536 })
537}
538
539// -----------------------------------------------------------------------
540// Remote fetching (HTTP Range requests)
541// -----------------------------------------------------------------------
542
543/// Fetches safetensors header bytes via two HTTP Range requests.
544///
545/// 1. `Range: bytes=0-7` → 8-byte header length (little-endian `u64`)
546/// 2. `Range: bytes=8-{8+length-1}` → JSON header
547///
548/// Returns `(json_bytes, total_file_size)`. The file size is extracted from
549/// the `Content-Range` header of the first request.
550async fn fetch_header_bytes(
551 client: &reqwest::Client,
552 url: &str,
553 filename: &str,
554) -> Result<(Vec<u8>, Option<u64>), FetchError> {
555 // Request 1: 8-byte length prefix.
556 let resp1 = client
557 .get(url)
558 .header(reqwest::header::RANGE, "bytes=0-7")
559 .send()
560 .await
561 .map_err(|e| {
562 FetchError::Http(format!("failed to fetch header length for {filename}: {e}"))
563 })?;
564
565 if !resp1.status().is_success() && resp1.status() != reqwest::StatusCode::PARTIAL_CONTENT {
566 return Err(FetchError::Http(format!(
567 "Range request for {filename} returned status {}",
568 resp1.status()
569 )));
570 }
571
572 // Extract total file size from Content-Range: bytes 0-7/{total}
573 let file_size = resp1
574 .headers()
575 .get(reqwest::header::CONTENT_RANGE)
576 .and_then(|v| v.to_str().ok())
577 .and_then(|s| s.split('/').next_back())
578 .and_then(|s| s.parse::<u64>().ok());
579
580 let len_bytes = resp1.bytes().await.map_err(|e| {
581 FetchError::Http(format!("failed to read header length for {filename}: {e}"))
582 })?;
583
584 if len_bytes.len() < 8 {
585 return Err(FetchError::SafetensorsHeader {
586 filename: filename.to_owned(),
587 reason: format!(
588 "expected 8 bytes for length prefix, got {}",
589 len_bytes.len()
590 ),
591 });
592 }
593
594 // INDEX: first 8 bytes guaranteed by length check above
595 #[allow(clippy::indexing_slicing)]
596 let header_size = u64::from_le_bytes([
597 len_bytes[0],
598 len_bytes[1],
599 len_bytes[2],
600 len_bytes[3],
601 len_bytes[4],
602 len_bytes[5],
603 len_bytes[6],
604 len_bytes[7],
605 ]);
606
607 // Request 2: JSON header.
608 let range_end = 8u64.saturating_add(header_size).saturating_sub(1);
609 let range_header = format!("bytes=8-{range_end}");
610 let resp2 = client
611 .get(url)
612 // BORROW: explicit .as_str() instead of Deref coercion
613 .header(reqwest::header::RANGE, range_header.as_str())
614 .send()
615 .await
616 .map_err(|e| {
617 FetchError::Http(format!("failed to fetch header JSON for {filename}: {e}"))
618 })?;
619
620 if !resp2.status().is_success() && resp2.status() != reqwest::StatusCode::PARTIAL_CONTENT {
621 return Err(FetchError::Http(format!(
622 "Range request for {filename} header JSON returned status {}",
623 resp2.status()
624 )));
625 }
626
627 let json_bytes = resp2
628 .bytes()
629 .await
630 .map_err(|e| FetchError::Http(format!("failed to read header JSON for {filename}: {e}")))?;
631
632 Ok((json_bytes.to_vec(), file_size))
633}
634
635// -----------------------------------------------------------------------
636// Public API: single-file inspection
637// -----------------------------------------------------------------------
638
639/// Inspects a single `.safetensors` file's header (cache-first).
640///
641/// Checks the local HF cache first. If the file is cached, reads the header
642/// from disk with zero network requests. Otherwise, falls back to two HTTP
643/// Range requests (8-byte length prefix + JSON header). Does not download
644/// tensor data in either case.
645///
646/// # Errors
647///
648/// Returns [`FetchError::Http`] if the Range requests fail.
649/// Returns [`FetchError::SafetensorsHeader`] if the header is malformed.
650pub async fn inspect_safetensors(
651 repo_id: &str,
652 filename: &str,
653 token: Option<&str>,
654 revision: Option<&str>,
655) -> Result<(SafetensorsHeaderInfo, InspectSource), FetchError> {
656 let rev = revision.unwrap_or("main");
657
658 // Try local cache first.
659 if let Some(cached_path) = resolve_cached_path(repo_id, rev, filename) {
660 let info = inspect_safetensors_local(&cached_path)?;
661 return Ok((info, InspectSource::Cached));
662 }
663
664 // Fall back to HTTP Range requests.
665 let client = chunked::build_client(token)?;
666 let url = chunked::build_download_url(repo_id, rev, filename);
667
668 // BORROW: explicit .as_str() instead of Deref coercion
669 let (json_bytes, file_size) = fetch_header_bytes(&client, url.as_str(), filename).await?;
670
671 // CAST: usize → u64, JSON buffer length is always small
672 #[allow(clippy::as_conversions)]
673 let header_size = json_bytes.len() as u64;
674
675 let (tensors, metadata) = parse_header_json(&json_bytes, filename)?;
676
677 Ok((
678 SafetensorsHeaderInfo {
679 tensors,
680 metadata,
681 header_size,
682 file_size,
683 // Remote path doesn't go through anamnesis yet; quant detection
684 // arrives in v0.11.1 when this path migrates to
685 // `anamnesis::parse_safetensors_header_from_reader` over an
686 // HTTP Range adapter.
687 quant_info: None,
688 },
689 InspectSource::Remote,
690 ))
691}
692
693/// Inspects a single `.safetensors` file from cache only.
694///
695/// Resolves the file in the local HF cache using the given `repo_id`,
696/// `revision`, and `filename`. Returns an error if the file is not cached.
697///
698/// # Blocking I/O
699///
700/// Performs synchronous filesystem I/O; wrap in [`tokio::task::spawn_blocking`]
701/// from async contexts. See [`inspect_safetensors_local`] for rationale.
702///
703/// # Errors
704///
705/// Returns [`FetchError::SafetensorsHeader`] if the file is not in the cache.
706/// Returns [`FetchError::Io`] if the cached file cannot be read.
707/// Returns [`FetchError::SafetensorsHeader`] if the header is malformed.
708pub fn inspect_safetensors_cached(
709 repo_id: &str,
710 filename: &str,
711 revision: Option<&str>,
712) -> Result<SafetensorsHeaderInfo, FetchError> {
713 let rev = revision.unwrap_or("main");
714
715 let cached_path = resolve_cached_path(repo_id, rev, filename).ok_or_else(|| {
716 FetchError::SafetensorsHeader {
717 filename: filename.to_owned(),
718 reason: format!("file not found in local cache for {repo_id} ({rev})"),
719 }
720 })?;
721
722 inspect_safetensors_local(&cached_path)
723}
724
725/// Inspects a `.gguf` file's metadata from the local `HuggingFace` cache.
726///
727/// Delegates to [`anamnesis::parse_gguf`] for the on-disk parse, then maps the
728/// result into the format-agnostic [`SafetensorsHeaderInfo`] shape used by
729/// hf-fm's existing render path. Tensor names, GGUF-native shape order, and
730/// dtype name strings carry over directly; per-tensor `data_offsets` are
731/// `(data_offset, data_offset + byte_len)` (with `byte_len = 0` for tensors
732/// whose dtype has no known byte size in anamnesis yet).
733///
734/// **Naming note:** the returned type is still called [`SafetensorsHeaderInfo`]
735/// in v0.10.x because renaming a public type is a breaking change; the
736/// uniform-dispatch rename to a format-agnostic name is scheduled for v0.10.3
737/// when the dispatcher extends across `.npz` / `.pth` (see the cache-management
738/// roadmap). For now, treat the type name as "header / file-level inspect
739/// info" regardless of format.
740///
741/// **Metadata surfacing:** the GGUF metadata table can contain very large
742/// arrays (e.g. tokenizer.ggml.tokens with 50K+ entries). To keep `Metadata:`
743/// rendering useful, this function surfaces *scalar* metadata values only —
744/// strings, booleans, integers, floats — and skips arrays. The GGUF format
745/// version is surfaced under the synthetic key `gguf.version`, the effective
746/// alignment under `gguf.alignment`. The original `general.architecture`,
747/// `general.name`, and friends pass through unchanged.
748///
749/// **Blocking I/O:** anamnesis's GGUF parser mmaps the file; this function is
750/// synchronous and should be wrapped in [`tokio::task::spawn_blocking`] from
751/// async contexts.
752///
753/// # Errors
754///
755/// Returns [`FetchError::SafetensorsHeader`] if the file is not in the cache.
756/// Returns [`FetchError::SafetensorsHeader`] if anamnesis rejects the GGUF
757/// file (malformed header, truncated tensor table, etc.).
758pub fn inspect_gguf_cached(
759 repo_id: &str,
760 filename: &str,
761 revision: Option<&str>,
762) -> Result<SafetensorsHeaderInfo, FetchError> {
763 let rev = revision.unwrap_or("main");
764
765 let cached_path = resolve_cached_path(repo_id, rev, filename).ok_or_else(|| {
766 FetchError::SafetensorsHeader {
767 // BORROW: explicit .to_owned() for owned String in the error variant
768 filename: filename.to_owned(),
769 reason: format!("file not found in local cache for {repo_id} ({rev})"),
770 }
771 })?;
772
773 let file_size = std::fs::metadata(&cached_path).ok().map(|m| m.len());
774
775 let parsed =
776 anamnesis::parse_gguf(&cached_path).map_err(|e| FetchError::SafetensorsHeader {
777 // BORROW: explicit .to_owned() for owned String in the error variant
778 filename: filename.to_owned(),
779 reason: format!("failed to parse GGUF: {e}"),
780 })?;
781
782 let tensors: Vec<TensorInfo> = parsed
783 .tensor_info()
784 .iter()
785 .map(|info| {
786 let start = info.data_offset;
787 let end = info.byte_len.map_or(start, |b| start.saturating_add(b));
788 TensorInfo {
789 // BORROW: explicit .clone() / .to_string() to materialise owned
790 // String + Vec<usize> from anamnesis's borrowed metadata
791 name: info.name.clone(),
792 dtype: info.dtype.to_string(),
793 shape: info.shape.clone(),
794 data_offsets: (start, end),
795 }
796 })
797 .collect();
798
799 // Stringify scalar metadata only; skip arrays (potentially huge — e.g.
800 // tokenizer.ggml.tokens). Add synthetic keys for the format version and
801 // alignment so they appear in the `Metadata:` block.
802 let mut metadata: HashMap<String, String> = parsed
803 .metadata()
804 .iter()
805 // BORROW: explicit .clone() to materialise an owned String key from
806 // the borrowed HashMap iteration
807 .filter_map(|(k, v)| stringify_gguf_scalar(v).map(|s| (k.clone(), s)))
808 .collect();
809 // BORROW: explicit .to_owned() for owned String keys
810 metadata.insert("gguf.version".to_owned(), parsed.version().to_string());
811 metadata.insert("gguf.alignment".to_owned(), parsed.alignment().to_string());
812
813 Ok(SafetensorsHeaderInfo {
814 tensors,
815 metadata: Some(metadata),
816 // GGUF has no discrete "header size" like safetensors's
817 // u64-length-prefix + JSON. The value is left at 0 here; consumers
818 // that care can derive an approximation from `file_size` minus the
819 // tensor byte sum. The `Metadata:` block's `gguf.version` /
820 // `gguf.alignment` keys surface the equivalent format-level info.
821 header_size: 0,
822 file_size,
823 // GGUF quant info (Q4_K_M etc.) is implicit in per-tensor dtypes;
824 // the v0.10.3 Phase C `Format:` / `Size:` lines are safetensors-only.
825 quant_info: None,
826 })
827}
828
829/// Inspects a `.npz` file's metadata from the local `HuggingFace` cache.
830///
831/// Delegates to [`anamnesis::inspect_npz`] for the on-disk parse (which
832/// reads only the ZIP central directory + per-entry NPY headers — no
833/// tensor data), then maps the result into the format-agnostic
834/// [`SafetensorsHeaderInfo`] shape used by hf-fm's existing render path.
835///
836/// **Synthesised offsets.** Anamnesis exposes per-tensor `byte_len` but
837/// not on-disk byte offsets (NPZ tensors live inside a ZIP archive;
838/// offsets are not part of the inspect surface). hf-fm's
839/// `TensorInfo::data_offsets` is synthesised as cumulative `(start, end)`
840/// pairs — `start = sum of previous byte_lens` — so `byte_len()`
841/// (= `end - start`) renders the actual storage size. The synthetic
842/// offsets are NOT on-disk truth; consumers that need real offsets
843/// should defer to v0.11.x once remote-inspect lands.
844///
845/// **Metadata.** `metadata: None` — NPZ has no metadata block analogous
846/// to safetensors's `__metadata__` or GGUF's KV table.
847///
848/// **Header size.** Always `0` — NPZ has no discrete header analogous
849/// to safetensors's `u64`-length-prefix + JSON. Mirrors the GGUF convention.
850///
851/// **Blocking I/O:** anamnesis's NPZ parser opens the file with
852/// `std::fs::File`; this function is synchronous and should be wrapped
853/// in [`tokio::task::spawn_blocking`] from async contexts.
854///
855/// # Errors
856///
857/// Returns [`FetchError::SafetensorsHeader`] if the file is not in the cache.
858/// Returns [`FetchError::SafetensorsHeader`] if anamnesis rejects the NPZ
859/// file (malformed ZIP central directory, unsupported NPY dtype, etc.).
860pub fn inspect_npz_cached(
861 repo_id: &str,
862 filename: &str,
863 revision: Option<&str>,
864) -> Result<SafetensorsHeaderInfo, FetchError> {
865 let rev = revision.unwrap_or("main");
866
867 let cached_path = resolve_cached_path(repo_id, rev, filename).ok_or_else(|| {
868 FetchError::SafetensorsHeader {
869 // BORROW: explicit .to_owned() for owned String in the error variant
870 filename: filename.to_owned(),
871 reason: format!("file not found in local cache for {repo_id} ({rev})"),
872 }
873 })?;
874
875 let file_size = std::fs::metadata(&cached_path).ok().map(|m| m.len());
876
877 let parsed =
878 anamnesis::inspect_npz(&cached_path).map_err(|e| FetchError::SafetensorsHeader {
879 // BORROW: explicit .to_owned() for owned String in the error variant
880 filename: filename.to_owned(),
881 reason: format!("failed to parse NPZ: {e}"),
882 })?;
883
884 let mut tensors: Vec<TensorInfo> = Vec::with_capacity(parsed.tensors.len());
885 let mut cursor: u64 = 0;
886 for t in parsed.tensors {
887 // CAST: usize → u64, byte_len fits in u64 by definition (in-memory size).
888 #[allow(clippy::as_conversions)]
889 let len = t.byte_len as u64;
890 let start = cursor;
891 let end = cursor.saturating_add(len);
892 cursor = end;
893 tensors.push(TensorInfo {
894 name: t.name,
895 // BORROW: explicit .to_string() — anamnesis `NpzDtype` enum → owned `String`
896 dtype: t.dtype.to_string(),
897 shape: t.shape,
898 data_offsets: (start, end),
899 });
900 }
901
902 Ok(SafetensorsHeaderInfo {
903 tensors,
904 metadata: None,
905 header_size: 0,
906 file_size,
907 // NPZ has no quant-method metadata; quant_info stays None.
908 quant_info: None,
909 })
910}
911
912/// Inspects a `.pth` file's metadata from the local `HuggingFace` cache.
913///
914/// Delegates to [`anamnesis::parse_pth`] for the on-disk parse, then uses
915/// the metadata-only `ParsedPth::tensor_info()` view (new in anamnesis
916/// `0.5.0`) to enumerate `(name, shape, dtype, byte_len)` per tensor — no
917/// further I/O beyond the initial mmap. The earlier `.tensors()` method
918/// would materialise each tensor's data via `Cow<'a, [u8]>`, which is
919/// unnecessary for inspect-only use.
920///
921/// **Synthesised offsets.** As with NPZ, anamnesis exposes per-tensor
922/// `byte_len` but not on-disk byte offsets (PTH tensors live inside a
923/// ZIP archive; offsets are not part of the inspect surface). hf-fm's
924/// `TensorInfo::data_offsets` is synthesised as cumulative `(start, end)`
925/// pairs so `byte_len()` (= `end - start`) renders the actual storage size.
926///
927/// **Metadata.** `metadata: None` — PTH has no metadata block analogous
928/// to safetensors's `__metadata__` or GGUF's KV table. The format-level
929/// `big_endian` flag (rare, near-always `false`) is not surfaced here;
930/// can be added as a synthetic `pth.big_endian` key in a future patch if
931/// real users request it.
932///
933/// **Header size.** Always `0` — PTH has no discrete header analogous
934/// to safetensors's `u64`-length-prefix + JSON. Mirrors the GGUF / NPZ
935/// convention.
936///
937/// **Blocking I/O:** anamnesis's PTH parser mmaps the file; this function
938/// is synchronous and should be wrapped in [`tokio::task::spawn_blocking`]
939/// from async contexts.
940///
941/// # Errors
942///
943/// Returns [`FetchError::SafetensorsHeader`] if the file is not in the cache.
944/// Returns [`FetchError::SafetensorsHeader`] if anamnesis rejects the PTH
945/// file (malformed pickle stream, legacy pre-1.6 raw-pickle format,
946/// unsupported tensor dtype, etc.).
947pub fn inspect_pth_cached(
948 repo_id: &str,
949 filename: &str,
950 revision: Option<&str>,
951) -> Result<SafetensorsHeaderInfo, FetchError> {
952 let rev = revision.unwrap_or("main");
953
954 let cached_path = resolve_cached_path(repo_id, rev, filename).ok_or_else(|| {
955 FetchError::SafetensorsHeader {
956 // BORROW: explicit .to_owned() for owned String in the error variant
957 filename: filename.to_owned(),
958 reason: format!("file not found in local cache for {repo_id} ({rev})"),
959 }
960 })?;
961
962 let file_size = std::fs::metadata(&cached_path).ok().map(|m| m.len());
963
964 let parsed = anamnesis::parse_pth(&cached_path).map_err(|e| FetchError::SafetensorsHeader {
965 // BORROW: explicit .to_owned() for owned String in the error variant
966 filename: filename.to_owned(),
967 reason: format!("failed to parse PTH: {e}"),
968 })?;
969
970 let pth_tensors = parsed.tensor_info();
971 let mut tensors: Vec<TensorInfo> = Vec::with_capacity(pth_tensors.len());
972 let mut cursor: u64 = 0;
973 for t in pth_tensors {
974 // CAST: usize → u64, byte_len fits in u64 by definition (in-memory size).
975 #[allow(clippy::as_conversions)]
976 let len = t.byte_len as u64;
977 let start = cursor;
978 let end = cursor.saturating_add(len);
979 cursor = end;
980 tensors.push(TensorInfo {
981 name: t.name,
982 // BORROW: explicit .to_string() — anamnesis `PthDtype` enum → owned `String`
983 dtype: t.dtype.to_string(),
984 shape: t.shape,
985 data_offsets: (start, end),
986 });
987 }
988
989 Ok(SafetensorsHeaderInfo {
990 tensors,
991 metadata: None,
992 header_size: 0,
993 file_size,
994 // PTH has no quant-method metadata; quant_info stays None.
995 quant_info: None,
996 })
997}
998
999/// Stringifies a scalar `GgufMetadataValue` from anamnesis.
1000///
1001/// Returns `None` for array variants (potentially huge — vocab tables, merges
1002/// lists) and for any future `#[non_exhaustive]` variants we don't yet
1003/// recognise. Surfaced through the `Metadata:` block in `inspect` output by
1004/// [`inspect_gguf_cached`].
1005//
1006// `GgufMetadataValue` is `#[non_exhaustive]`. The explicit `V::Array(_)` arm
1007// and the `_ =>` catch-all both return `None`, but they document different
1008// intents — "array variants are deliberately skipped" vs "future unknown
1009// variants fall through". Clippy's `match_same_arms` flags the bodies as
1010// identical; the duplication is intentional.
1011#[allow(clippy::match_same_arms)]
1012fn stringify_gguf_scalar(value: &anamnesis::parse::gguf::GgufMetadataValue) -> Option<String> {
1013 use anamnesis::parse::gguf::GgufMetadataValue as V;
1014 match value {
1015 V::String(s) => Some(s.clone()),
1016 V::Bool(b) => Some(b.to_string()),
1017 V::U8(n) => Some(n.to_string()),
1018 V::I8(n) => Some(n.to_string()),
1019 V::U16(n) => Some(n.to_string()),
1020 V::I16(n) => Some(n.to_string()),
1021 V::U32(n) => Some(n.to_string()),
1022 V::I32(n) => Some(n.to_string()),
1023 V::U64(n) => Some(n.to_string()),
1024 V::I64(n) => Some(n.to_string()),
1025 V::F32(n) => Some(format!("{n}")),
1026 V::F64(n) => Some(format!("{n}")),
1027 V::Array(_) => None,
1028 _ => None,
1029 }
1030}
1031
1032// -----------------------------------------------------------------------
1033// Public API: multi-file inspection
1034// -----------------------------------------------------------------------
1035
1036/// Inspects all `.safetensors` files in a repository (cache-first per file).
1037///
1038/// Fetches the file listing via `list_repo_files_with_metadata()`, then
1039/// inspects each `.safetensors` file's header via [`inspect_safetensors()`].
1040/// For each file, checks the local cache first and only makes HTTP Range
1041/// requests on cache miss. Returns full per-shard headers in filename order.
1042///
1043/// For a lightweight summary of sharded models (tensor counts per shard
1044/// without fetching individual headers), use [`fetch_shard_index()`] instead.
1045///
1046/// # Errors
1047///
1048/// Returns [`FetchError::Http`] if the metadata or Range requests fail.
1049pub async fn inspect_repo_safetensors(
1050 repo_id: &str,
1051 token: Option<&str>,
1052 revision: Option<&str>,
1053) -> Result<Vec<(String, SafetensorsHeaderInfo, InspectSource)>, FetchError> {
1054 let client = crate::chunked::build_client(token)?;
1055 let files =
1056 crate::repo::list_repo_files_with_metadata(repo_id, token, revision, &client).await?;
1057
1058 let safetensors_files: Vec<String> = files
1059 .into_iter()
1060 .filter(|f| f.filename.ends_with(".safetensors"))
1061 .map(|f| f.filename)
1062 .collect();
1063
1064 if safetensors_files.is_empty() {
1065 return Ok(Vec::new());
1066 }
1067
1068 let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(4));
1069 let mut join_set = JoinSet::new();
1070
1071 for filename in safetensors_files {
1072 // BORROW: explicit .clone()/.to_owned() to move into async task
1073 let sem = semaphore.clone();
1074 let repo = repo_id.to_owned();
1075 let tok = token.map(str::to_owned);
1076 let rev = revision.map(str::to_owned);
1077
1078 join_set.spawn(async move {
1079 let _permit = sem
1080 .acquire()
1081 .await
1082 .map_err(|e| FetchError::Http(format!("semaphore error: {e}")))?;
1083 // BORROW: explicit .as_deref() for Option<String> → Option<&str>
1084 let (info, source) =
1085 inspect_safetensors(&repo, &filename, tok.as_deref(), rev.as_deref()).await?;
1086 Ok::<_, FetchError>((filename, info, source))
1087 });
1088 }
1089
1090 let mut results = Vec::new();
1091 while let Some(join_result) = join_set.join_next().await {
1092 match join_result {
1093 Ok(Ok(item)) => results.push(item),
1094 Ok(Err(e)) => {
1095 join_set.abort_all();
1096 return Err(e);
1097 }
1098 Err(e) => {
1099 join_set.abort_all();
1100 return Err(FetchError::Http(format!("task join error: {e}")));
1101 }
1102 }
1103 }
1104
1105 results.sort_by(|a, b| a.0.cmp(&b.0));
1106
1107 Ok(results)
1108}
1109
1110/// A `(filename, size_bytes)` enumeration of safetensors files in a repo,
1111/// paired with the commit SHA of the resolved revision (when known).
1112///
1113/// The same tuple shape serves both local and remote listings: [`list_cached_safetensors`]
1114/// produces it from a cached snapshot; `repo::list_repo_files_with_commit` filtered to
1115/// `*.safetensors` produces it from the `HuggingFace` API. Callers that need a uniform
1116/// view over "what safetensors can I inspect?" regardless of source use this alias.
1117pub type SafetensorsListing = (Vec<(String, u64)>, Option<String>);
1118
1119/// Lists `.safetensors` files in the cached snapshot for `repo_id`@`revision`.
1120///
1121/// Returns `(entries, commit_sha)` where `entries` is a sorted list of
1122/// `(filename, size_bytes)` tuples, and `commit_sha` is the snapshot's commit
1123/// hash (same value stored in `refs/<revision>`). Returns empty lists when the
1124/// repo or revision is not cached. Unlike [`inspect_repo_safetensors_cached`],
1125/// this does **not** parse any headers — it is a cheap name-and-size enumeration
1126/// intended for discovery UI (e.g. `inspect --list --cached`).
1127///
1128/// # Blocking I/O
1129///
1130/// Performs a synchronous recursive directory walk with a `stat` call per
1131/// `.safetensors` entry. On local SSDs the cost is sub-millisecond; on
1132/// networked caches (NFS/CIFS) a large sharded repo can take seconds. Wrap
1133/// in [`tokio::task::spawn_blocking`] from async contexts.
1134///
1135/// # Errors
1136///
1137/// Returns [`FetchError::Io`] if the snapshot directory cannot be read.
1138pub fn list_cached_safetensors(
1139 repo_id: &str,
1140 revision: Option<&str>,
1141) -> Result<SafetensorsListing, FetchError> {
1142 let rev = revision.unwrap_or("main");
1143 let cache_dir = cache::hf_cache_dir()?;
1144 let repo_dir = cache_layout::repo_dir(&cache_dir, repo_id);
1145
1146 let Some(commit_hash) = cache::read_ref(&repo_dir, rev) else {
1147 return Ok((Vec::new(), None));
1148 };
1149
1150 let snapshot_dir = cache_layout::snapshot_dir(&repo_dir, &commit_hash);
1151 if !snapshot_dir.exists() {
1152 return Ok((Vec::new(), Some(commit_hash)));
1153 }
1154
1155 let mut results = Vec::new();
1156 collect_safetensors_names_sizes(&snapshot_dir, "", &mut results)?;
1157 results.sort_by(|a, b| a.0.cmp(&b.0));
1158 Ok((results, Some(commit_hash)))
1159}
1160
1161/// Recursively collects `(filename, size)` pairs for `.safetensors` files.
1162fn collect_safetensors_names_sizes(
1163 dir: &Path,
1164 prefix: &str,
1165 results: &mut Vec<(String, u64)>,
1166) -> Result<(), FetchError> {
1167 let entries = std::fs::read_dir(dir).map_err(|e| FetchError::Io {
1168 path: dir.to_path_buf(),
1169 source: e,
1170 })?;
1171
1172 for entry in entries {
1173 let Ok(entry) = entry else { continue };
1174 let path = entry.path();
1175 // BORROW: explicit .to_string_lossy() for OsString → str conversion
1176 let name = entry.file_name().to_string_lossy().to_string();
1177
1178 if path.is_dir() {
1179 let child_prefix = if prefix.is_empty() {
1180 name
1181 } else {
1182 format!("{prefix}/{name}")
1183 };
1184 collect_safetensors_names_sizes(&path, &child_prefix, results)?;
1185 } else if name.ends_with(".safetensors") {
1186 let filename = if prefix.is_empty() {
1187 name
1188 } else {
1189 format!("{prefix}/{name}")
1190 };
1191 let size = entry.metadata().map_or(0, |m| m.len());
1192 results.push((filename, size));
1193 }
1194 }
1195
1196 Ok(())
1197}
1198
1199/// Inspects all `.safetensors` files in a cached repository (no network).
1200///
1201/// Walks the snapshot directory and inspects each `.safetensors` file's
1202/// header from local disk. Returns results in filename order.
1203///
1204/// # Blocking I/O
1205///
1206/// Walks the snapshot directory and reads each header synchronously. In async
1207/// contexts, wrap in [`tokio::task::spawn_blocking`] to avoid stalling the
1208/// runtime — multi-shard repos on network-mounted caches can take seconds.
1209///
1210/// # Errors
1211///
1212/// Returns [`FetchError::Io`] if the cache directory cannot be read.
1213/// Returns [`FetchError::SafetensorsHeader`] if any header is malformed.
1214pub fn inspect_repo_safetensors_cached(
1215 repo_id: &str,
1216 revision: Option<&str>,
1217) -> Result<Vec<(String, SafetensorsHeaderInfo)>, FetchError> {
1218 let rev = revision.unwrap_or("main");
1219 let cache_dir = cache::hf_cache_dir()?;
1220 let repo_dir = cache_layout::repo_dir(&cache_dir, repo_id);
1221
1222 let Some(commit_hash) = cache::read_ref(&repo_dir, rev) else {
1223 return Ok(Vec::new());
1224 };
1225
1226 let snapshot_dir = cache_layout::snapshot_dir(&repo_dir, &commit_hash);
1227 if !snapshot_dir.exists() {
1228 return Ok(Vec::new());
1229 }
1230
1231 let mut results = Vec::new();
1232 collect_safetensors_recursive(&snapshot_dir, "", &mut results)?;
1233 results.sort_by(|a, b| a.0.cmp(&b.0));
1234
1235 Ok(results)
1236}
1237
1238/// Recursively finds and inspects `.safetensors` files in a snapshot directory.
1239fn collect_safetensors_recursive(
1240 dir: &Path,
1241 prefix: &str,
1242 results: &mut Vec<(String, SafetensorsHeaderInfo)>,
1243) -> Result<(), FetchError> {
1244 let entries = std::fs::read_dir(dir).map_err(|e| FetchError::Io {
1245 path: dir.to_path_buf(),
1246 source: e,
1247 })?;
1248
1249 for entry in entries {
1250 let Ok(entry) = entry else { continue };
1251 let path = entry.path();
1252 // BORROW: explicit .to_string_lossy() for OsString → str conversion
1253 let name = entry.file_name().to_string_lossy().to_string();
1254
1255 if path.is_dir() {
1256 let child_prefix = if prefix.is_empty() {
1257 name
1258 } else {
1259 format!("{prefix}/{name}")
1260 };
1261 collect_safetensors_recursive(&path, &child_prefix, results)?;
1262 } else if name.ends_with(".safetensors") {
1263 let filename = if prefix.is_empty() {
1264 name
1265 } else {
1266 format!("{prefix}/{name}")
1267 };
1268 let info = inspect_safetensors_local(&path)?;
1269 results.push((filename, info));
1270 }
1271 }
1272
1273 Ok(())
1274}
1275
1276// -----------------------------------------------------------------------
1277// Shard index
1278// -----------------------------------------------------------------------
1279
1280/// Raw JSON structure of `model.safetensors.index.json`.
1281#[derive(serde::Deserialize)]
1282struct RawShardIndex {
1283 weight_map: HashMap<String, String>,
1284 #[serde(default)]
1285 metadata: Option<HashMap<String, serde_json::Value>>,
1286}
1287
1288/// Fetches and parses the shard index for a sharded `.safetensors` model (cache-first).
1289///
1290/// Returns `Ok(None)` if the repo has no `model.safetensors.index.json` (i.e.,
1291/// the model is not sharded or uses a single `.safetensors` file).
1292///
1293/// # Errors
1294///
1295/// Returns [`FetchError::Http`] if the index fetch fails.
1296/// Returns [`FetchError::SafetensorsHeader`] if the index JSON is malformed.
1297pub async fn fetch_shard_index(
1298 repo_id: &str,
1299 token: Option<&str>,
1300 revision: Option<&str>,
1301) -> Result<Option<ShardedIndex>, FetchError> {
1302 let rev = revision.unwrap_or("main");
1303 let index_filename = "model.safetensors.index.json";
1304
1305 // Try local cache first.
1306 if let Some(cached_path) = resolve_cached_path(repo_id, rev, index_filename) {
1307 let content = std::fs::read_to_string(&cached_path).map_err(|e| FetchError::Io {
1308 path: cached_path,
1309 source: e,
1310 })?;
1311 let index = parse_shard_index_json(&content, repo_id)?;
1312 return Ok(Some(index));
1313 }
1314
1315 // Fall back to HTTP.
1316 let client = chunked::build_client(token)?;
1317 let url = chunked::build_download_url(repo_id, rev, index_filename);
1318
1319 // BORROW: explicit .as_str() instead of Deref coercion
1320 let response =
1321 client.get(url.as_str()).send().await.map_err(|e| {
1322 FetchError::Http(format!("failed to fetch shard index for {repo_id}: {e}"))
1323 })?;
1324
1325 if response.status() == reqwest::StatusCode::NOT_FOUND {
1326 return Ok(None);
1327 }
1328
1329 if !response.status().is_success() {
1330 return Err(FetchError::Http(format!(
1331 "shard index request for {repo_id} returned status {}",
1332 response.status()
1333 )));
1334 }
1335
1336 let content = response
1337 .text()
1338 .await
1339 .map_err(|e| FetchError::Http(format!("failed to read shard index for {repo_id}: {e}")))?;
1340
1341 let index = parse_shard_index_json(&content, repo_id)?;
1342 Ok(Some(index))
1343}
1344
1345/// Fetches the shard index from cache only (no network).
1346///
1347/// Returns `Ok(None)` if the index file is not cached.
1348///
1349/// # Errors
1350///
1351/// Returns [`FetchError::Io`] if the cached file cannot be read.
1352/// Returns [`FetchError::SafetensorsHeader`] if the index JSON is malformed.
1353pub fn fetch_shard_index_cached(
1354 repo_id: &str,
1355 revision: Option<&str>,
1356) -> Result<Option<ShardedIndex>, FetchError> {
1357 let rev = revision.unwrap_or("main");
1358 let index_filename = "model.safetensors.index.json";
1359
1360 let Some(cached_path) = resolve_cached_path(repo_id, rev, index_filename) else {
1361 return Ok(None);
1362 };
1363
1364 let content = std::fs::read_to_string(&cached_path).map_err(|e| FetchError::Io {
1365 path: cached_path,
1366 source: e,
1367 })?;
1368
1369 let index = parse_shard_index_json(&content, repo_id)?;
1370 Ok(Some(index))
1371}
1372
1373/// Parses shard index JSON into a `ShardedIndex`.
1374fn parse_shard_index_json(content: &str, repo_id: &str) -> Result<ShardedIndex, FetchError> {
1375 let raw: RawShardIndex =
1376 serde_json::from_str(content).map_err(|e| FetchError::SafetensorsHeader {
1377 filename: "model.safetensors.index.json".to_owned(),
1378 reason: format!("failed to parse shard index for {repo_id}: {e}"),
1379 })?;
1380
1381 // Collect unique shard filenames in sorted order.
1382 let mut shard_set: Vec<String> = raw.weight_map.values().cloned().collect();
1383 shard_set.sort();
1384 shard_set.dedup();
1385
1386 Ok(ShardedIndex {
1387 weight_map: raw.weight_map,
1388 shards: shard_set,
1389 metadata: raw.metadata,
1390 })
1391}
1392
1393// -----------------------------------------------------------------------
1394// Param formatting helper
1395// -----------------------------------------------------------------------
1396
1397/// Formats a parameter count with a compact suffix (e.g., `927.0M`, `1.02B`).
1398#[must_use]
1399pub fn format_params(count: u64) -> String {
1400 // CAST: u64 → f64, precision loss acceptable; value is a display-only scalar
1401 #[allow(clippy::cast_precision_loss, clippy::as_conversions)]
1402 let val = count as f64;
1403
1404 if count >= 1_000_000_000 {
1405 format!("{:.2}B", val / 1_000_000_000.0)
1406 } else if count >= 1_000_000 {
1407 format!("{:.1}M", val / 1_000_000.0)
1408 } else if count >= 1_000 {
1409 format!("{:.1}K", val / 1_000.0)
1410 } else {
1411 count.to_string()
1412 }
1413}
1414
1415// -----------------------------------------------------------------------
1416// Adapter config
1417// -----------------------------------------------------------------------
1418
1419/// Raw JSON structure of `adapter_config.json`.
1420#[derive(serde::Deserialize)]
1421struct RawAdapterConfig {
1422 #[serde(default)]
1423 peft_type: Option<String>,
1424 #[serde(default)]
1425 base_model_name_or_path: Option<String>,
1426 #[serde(default)]
1427 r: Option<u32>,
1428 #[serde(default)]
1429 lora_alpha: Option<f64>,
1430 #[serde(default)]
1431 target_modules: Option<AdapterTargetModules>,
1432 #[serde(default)]
1433 task_type: Option<String>,
1434}
1435
1436/// `target_modules` in adapter configs can be a list of strings or a single string.
1437#[derive(serde::Deserialize)]
1438#[serde(untagged)]
1439enum AdapterTargetModules {
1440 /// A list of module name strings.
1441 List(Vec<String>),
1442 /// A single module name string.
1443 Single(String),
1444}
1445
1446/// Fetches and parses `adapter_config.json` for a `PEFT` adapter repository (cache-first).
1447///
1448/// Returns `Ok(None)` if the file does not exist (HTTP 404), meaning the
1449/// repository is not a `PEFT` adapter.
1450///
1451/// # Errors
1452///
1453/// Returns [`FetchError::Http`] if the request fails (other than 404).
1454/// Returns [`FetchError::SafetensorsHeader`] if the JSON is malformed.
1455pub async fn fetch_adapter_config(
1456 repo_id: &str,
1457 token: Option<&str>,
1458 revision: Option<&str>,
1459) -> Result<Option<AdapterConfig>, FetchError> {
1460 let rev = revision.unwrap_or("main");
1461 let config_filename = "adapter_config.json";
1462
1463 // Try local cache first.
1464 if let Some(cached_path) = resolve_cached_path(repo_id, rev, config_filename) {
1465 let content = std::fs::read_to_string(&cached_path).map_err(|e| FetchError::Io {
1466 path: cached_path,
1467 source: e,
1468 })?;
1469 let config = parse_adapter_config_json(&content, repo_id)?;
1470 return Ok(Some(config));
1471 }
1472
1473 // Fall back to HTTP.
1474 let client = chunked::build_client(token)?;
1475 let url = chunked::build_download_url(repo_id, rev, config_filename);
1476
1477 // BORROW: explicit .as_str() instead of Deref coercion
1478 let response = client.get(url.as_str()).send().await.map_err(|e| {
1479 FetchError::Http(format!("failed to fetch adapter config for {repo_id}: {e}"))
1480 })?;
1481
1482 if response.status() == reqwest::StatusCode::NOT_FOUND {
1483 return Ok(None);
1484 }
1485
1486 if !response.status().is_success() {
1487 return Err(FetchError::Http(format!(
1488 "adapter config request for {repo_id} returned status {}",
1489 response.status()
1490 )));
1491 }
1492
1493 let content = response.text().await.map_err(|e| {
1494 FetchError::Http(format!("failed to read adapter config for {repo_id}: {e}"))
1495 })?;
1496
1497 let config = parse_adapter_config_json(&content, repo_id)?;
1498 Ok(Some(config))
1499}
1500
1501/// Fetches the adapter config from cache only (no network).
1502///
1503/// Returns `Ok(None)` if the file is not cached.
1504///
1505/// # Errors
1506///
1507/// Returns [`FetchError::Io`] if the cached file cannot be read.
1508/// Returns [`FetchError::SafetensorsHeader`] if the JSON is malformed.
1509pub fn fetch_adapter_config_cached(
1510 repo_id: &str,
1511 revision: Option<&str>,
1512) -> Result<Option<AdapterConfig>, FetchError> {
1513 let rev = revision.unwrap_or("main");
1514 let config_filename = "adapter_config.json";
1515
1516 let Some(cached_path) = resolve_cached_path(repo_id, rev, config_filename) else {
1517 return Ok(None);
1518 };
1519
1520 let content = std::fs::read_to_string(&cached_path).map_err(|e| FetchError::Io {
1521 path: cached_path,
1522 source: e,
1523 })?;
1524
1525 let config = parse_adapter_config_json(&content, repo_id)?;
1526 Ok(Some(config))
1527}
1528
1529/// Parses adapter config JSON into an [`AdapterConfig`].
1530fn parse_adapter_config_json(content: &str, repo_id: &str) -> Result<AdapterConfig, FetchError> {
1531 let raw: RawAdapterConfig =
1532 serde_json::from_str(content).map_err(|e| FetchError::SafetensorsHeader {
1533 filename: "adapter_config.json".to_owned(),
1534 reason: format!("failed to parse adapter config for {repo_id}: {e}"),
1535 })?;
1536
1537 let target_modules = match raw.target_modules {
1538 Some(AdapterTargetModules::List(v)) => v,
1539 Some(AdapterTargetModules::Single(s)) => vec![s],
1540 None => Vec::new(),
1541 };
1542
1543 Ok(AdapterConfig {
1544 peft_type: raw.peft_type,
1545 base_model_name_or_path: raw.base_model_name_or_path,
1546 r: raw.r,
1547 lora_alpha: raw.lora_alpha,
1548 target_modules,
1549 task_type: raw.task_type,
1550 })
1551}
1552
1553/// Raw JSON structure of `config.json` (only the fields hf-fm reads for
1554/// KV-cache budgeting).
1555///
1556/// Serde aliases absorb the legacy GPT-NeoX / Falcon spellings. `text_config`
1557/// holds the nested language-model config of multimodal repos (Gemma-3) and
1558/// is used as a fallback when the attention dims are absent at top level.
1559#[derive(serde::Deserialize)]
1560struct RawModelConfig {
1561 #[serde(default)]
1562 model_type: Option<String>,
1563 #[serde(default, alias = "n_layer")]
1564 num_hidden_layers: Option<u32>,
1565 #[serde(default, alias = "n_head")]
1566 num_attention_heads: Option<u32>,
1567 #[serde(default, alias = "num_kv_heads", alias = "n_head_kv")]
1568 num_key_value_heads: Option<u32>,
1569 #[serde(default, alias = "attention_head_dim")]
1570 head_dim: Option<u32>,
1571 #[serde(default)]
1572 hidden_size: Option<u32>,
1573 #[serde(default)]
1574 torch_dtype: Option<String>,
1575 #[serde(default)]
1576 sliding_window: Option<u32>,
1577 #[serde(default)]
1578 sliding_window_pattern: Option<u32>,
1579 #[serde(default)]
1580 use_sliding_window: Option<bool>,
1581 #[serde(default)]
1582 kv_lora_rank: Option<u32>,
1583 #[serde(default)]
1584 qk_rope_head_dim: Option<u32>,
1585 #[serde(default)]
1586 layer_types: Option<Vec<String>>,
1587 #[serde(default)]
1588 hybrid_override_pattern: Option<String>,
1589 #[serde(default)]
1590 attn_layer_indices: Option<Vec<u32>>,
1591 #[serde(default)]
1592 full_attention_interval: Option<u32>,
1593 #[serde(default, alias = "mamba_num_heads")]
1594 mamba_n_heads: Option<u32>,
1595 #[serde(default, alias = "mamba_head_dim")]
1596 mamba_d_head: Option<u32>,
1597 #[serde(default, alias = "ssm_state_size")]
1598 mamba_d_state: Option<u32>,
1599 #[serde(default, alias = "conv_kernel")]
1600 mamba_d_conv: Option<u32>,
1601 #[serde(default, alias = "n_groups")]
1602 mamba_n_groups: Option<u32>,
1603 #[serde(default)]
1604 text_config: Option<Box<RawModelConfig>>,
1605}
1606
1607/// Lowers a [`RawModelConfig`] into the public [`ModelConfig`].
1608///
1609/// Multimodal configs (Gemma-3) nest the language-model dims under
1610/// `text_config`; when the top level carries no attention dims, this recurses
1611/// into that nested config so the KV estimator sees the real numbers.
1612fn model_config_from_raw(raw: RawModelConfig) -> ModelConfig {
1613 if raw.num_hidden_layers.is_none()
1614 && raw.num_attention_heads.is_none()
1615 && raw.hidden_size.is_none()
1616 {
1617 if let Some(text) = raw.text_config {
1618 return model_config_from_raw(*text);
1619 }
1620 }
1621
1622 ModelConfig {
1623 model_type: raw.model_type,
1624 num_hidden_layers: raw.num_hidden_layers,
1625 num_attention_heads: raw.num_attention_heads,
1626 num_key_value_heads: raw.num_key_value_heads,
1627 head_dim: raw.head_dim,
1628 hidden_size: raw.hidden_size,
1629 torch_dtype: raw.torch_dtype,
1630 sliding_window: raw.sliding_window,
1631 sliding_window_pattern: raw.sliding_window_pattern,
1632 use_sliding_window: raw.use_sliding_window,
1633 kv_lora_rank: raw.kv_lora_rank,
1634 qk_rope_head_dim: raw.qk_rope_head_dim,
1635 layer_types: raw.layer_types,
1636 hybrid_override_pattern: raw.hybrid_override_pattern,
1637 attn_layer_indices: raw.attn_layer_indices,
1638 full_attention_interval: raw.full_attention_interval,
1639 mamba_n_heads: raw.mamba_n_heads,
1640 mamba_d_head: raw.mamba_d_head,
1641 mamba_d_state: raw.mamba_d_state,
1642 mamba_d_conv: raw.mamba_d_conv,
1643 mamba_n_groups: raw.mamba_n_groups,
1644 }
1645}
1646
1647/// Parses `config.json` content into a [`ModelConfig`].
1648fn parse_model_config_json(content: &str, repo_id: &str) -> Result<ModelConfig, FetchError> {
1649 let raw: RawModelConfig =
1650 serde_json::from_str(content).map_err(|e| FetchError::SafetensorsHeader {
1651 filename: "config.json".to_owned(),
1652 reason: format!("failed to parse model config for {repo_id}: {e}"),
1653 })?;
1654
1655 Ok(model_config_from_raw(raw))
1656}
1657
1658/// Fetches and parses a model's `config.json` (cache-first, then HTTP).
1659///
1660/// Returns `Ok(None)` when the repository has no `config.json` (HTTP 404) —
1661/// e.g. a non-model repo or a raw-weights upload.
1662///
1663/// # Errors
1664///
1665/// Returns [`FetchError::Http`] if the request fails (other than 404).
1666/// Returns [`FetchError::Io`] if a cached `config.json` cannot be read.
1667/// Returns [`FetchError::SafetensorsHeader`] if the JSON is malformed.
1668pub async fn fetch_model_config(
1669 repo_id: &str,
1670 token: Option<&str>,
1671 revision: Option<&str>,
1672) -> Result<Option<ModelConfig>, FetchError> {
1673 let rev = revision.unwrap_or("main");
1674 let config_filename = "config.json";
1675
1676 // Try local cache first.
1677 if let Some(cached_path) = resolve_cached_path(repo_id, rev, config_filename) {
1678 let content = std::fs::read_to_string(&cached_path).map_err(|e| FetchError::Io {
1679 path: cached_path,
1680 source: e,
1681 })?;
1682 let config = parse_model_config_json(&content, repo_id)?;
1683 return Ok(Some(config));
1684 }
1685
1686 // Fall back to HTTP.
1687 let client = chunked::build_client(token)?;
1688 let url = chunked::build_download_url(repo_id, rev, config_filename);
1689
1690 // BORROW: explicit .as_str() instead of Deref coercion
1691 let response = client.get(url.as_str()).send().await.map_err(|e| {
1692 FetchError::Http(format!("failed to fetch model config for {repo_id}: {e}"))
1693 })?;
1694
1695 if response.status() == reqwest::StatusCode::NOT_FOUND {
1696 return Ok(None);
1697 }
1698
1699 if !response.status().is_success() {
1700 return Err(FetchError::Http(format!(
1701 "model config request for {repo_id} returned status {}",
1702 response.status()
1703 )));
1704 }
1705
1706 let content = response
1707 .text()
1708 .await
1709 .map_err(|e| FetchError::Http(format!("failed to read model config for {repo_id}: {e}")))?;
1710
1711 let config = parse_model_config_json(&content, repo_id)?;
1712 Ok(Some(config))
1713}
1714
1715/// Fetches a model's `config.json` from the local cache only (no network).
1716///
1717/// Returns `Ok(None)` if the file is not cached.
1718///
1719/// # Errors
1720///
1721/// Returns [`FetchError::Io`] if the cached file cannot be read.
1722/// Returns [`FetchError::SafetensorsHeader`] if the JSON is malformed.
1723pub fn fetch_model_config_cached(
1724 repo_id: &str,
1725 revision: Option<&str>,
1726) -> Result<Option<ModelConfig>, FetchError> {
1727 let rev = revision.unwrap_or("main");
1728 let config_filename = "config.json";
1729
1730 let Some(cached_path) = resolve_cached_path(repo_id, rev, config_filename) else {
1731 return Ok(None);
1732 };
1733
1734 let content = std::fs::read_to_string(&cached_path).map_err(|e| FetchError::Io {
1735 path: cached_path,
1736 source: e,
1737 })?;
1738
1739 let config = parse_model_config_json(&content, repo_id)?;
1740 Ok(Some(config))
1741}