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