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}