Skip to main content

hf_fetch_model/
inspect.rs

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