Skip to main content

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}