Files
Loki/rust/loki-rs/src/gguf.rs
T
MichaelandClaude Opus 5.5 d805d9f1ba Lot 4 : lecteur GGUF, couches du modèle et emplacements portés en Rust
gguf.rs reprend backend_gguf.go à l'identique : en-tête, clés et table des
tenseurs lus sous les mêmes bornes (chaînes, compteurs, profondeur des
tableaux, alignement, premier octet du dernier tenseur), v1/v2/v3 et
gros-boutiste, modèles en tranches (shardFamily porté avec), cache par
empreinte taille/date, mêmes messages d'erreur. Un panic éventuel reste
rattrapé en « GGUF illisible ». Tous les tests Go sont portés un pour un,
avec le même constructeur de GGUF synthétique.

Pour /api/model/layers : resolveModelPath / resolveServeModelPath et leurs
dossiers de modèles (models/, state/model_dirs, LOKI_MODEL_DIRS), la liste
stockée étant passée par l'appelant qui la lit via le store ; le corps JSON
de handleModelLayers est reproduit tel quel.

paths.rs sert /api/paths avec le même JSON que lokiPaths. Le « binaire en
cours » reste celui du serveur Go supervisé, passé par l'appelant, comme le
projet actif (state/active_project) et LOKI_WORKSPACE. Les équivalents de
filepath.Clean/Join/Dir/Abs/IsAbs garantissent les mêmes chaînes que le Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:39 +02:00

1492 lines
52 KiB
Rust

//! Lecteur des métadonnées GGUF (port de `backend_gguf.go`), résolution du
//! modèle à lancer (`resolveServeModelPath`, `backend_model_dirs.go`), familles
//! de tranches (`backend_models_shards.go`) et réponse de
//! `GET /api/model/layers` (`handleModelLayers`, `engine_cmdline.go`).
//!
//! Plusieurs réglages du moteur dépendent de ce que le fichier contient
//! VRAIMENT : une tête MTP, le nombre de têtes KV par couche, une architecture
//! hybride à état récurrent. On ne lit que l'en-tête, la section clé/valeur et
//! la table des tenseurs : les poids ne sont jamais touchés. Le fichier peut
//! être en cours de téléchargement, tronqué, ou pas un GGUF : chaque longueur
//! lue est bornée par ce qui reste du fichier, et la moindre incohérence donne
//! une erreur, jamais un plantage ni une allocation géante. Une erreur veut
//! dire « on ne sait pas » : l'appelant reste sur le comportement prudent.
//!
//! Données du store : la liste des dossiers de modèles ajoutés vit dans le
//! bucket `state`, clé `model_dirs` (JSON `[]string`, voir
//! [`STATE_MODEL_DIRS_KEY`] et [`parse_stored_model_dirs`]) ; l'appelant la lit
//! par `crate::store::Store` et la passe aux fonctions de résolution, avec
//! `$LOKI_MODEL_DIRS`.
use serde_json::{Value, json};
use std::collections::HashMap;
use std::fmt::Write as _;
use std::fs::File;
use std::io::{self, BufReader, Read};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::paths::{base_name, go_abs, go_clean, go_dir, go_is_abs, go_join, os_err};
/// Bucket `state`, clé des dossiers de modèles ajoutés (`extraModelDirs`).
pub const STATE_MODEL_DIRS_KEY: &str = "model_dirs";
/// Variable d'environnement des dossiers de modèles supplémentaires.
pub const MODEL_DIRS_ENV: &str = "LOKI_MODEL_DIRS";
/// Ce que Loki retient d'un modèle GGUF (`GGUFInfo` du Go). Les champs absents
/// du fichier restent à zéro.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GgufMeta {
/// version du format (1 à 3)
pub version: i64,
/// general.architecture
pub arch: String,
/// `<arch>.block_count`
pub block_count: i64,
/// `<arch>.nextn_predict_layers` (couches MTP annoncées)
pub next_n: i64,
/// `<arch>.context_length` (contexte d'entraînement)
pub context_length: i64,
/// `<arch>.expert_count` (0 = modèle dense)
pub expert_count: i64,
/// `<arch>.attention.key_length`
pub key_len: i64,
/// `<arch>.attention.value_length`
pub val_len: i64,
/// Valeur unique, ou maximum sur les couches quand le fichier donne un
/// tableau par couche (`head_count_kv_layers`, `None` sinon). Sur un
/// hybride, les couches récurrentes y valent 0.
pub head_count_kv: i64,
pub head_count_kv_layers: Option<Vec<i64>>,
/// `<arch>.full_attention_interval`, si présent
pub full_attn_interval: i64,
/// au moins une clé `<arch>.ssm.*` : couches à état récurrent
pub hybrid: bool,
/// `<arch>.embedding_length` (repli de llama.cpp : n_embd / n_head)
pub embedding_length: i64,
/// `<arch>.attention.head_count` (valeur unique seulement)
pub head_count: i64,
/// `<arch>.ssm.conv_kernel`
pub ssm_conv: i64,
/// `<arch>.ssm.inner_size`
pub ssm_inner: i64,
/// `<arch>.ssm.state_size`
pub ssm_state: i64,
/// `<arch>.ssm.group_count`
pub ssm_groups: i64,
/// Le tenseur `blk.{block_count-1}.nextn.eh_proj.weight` existe dans l'une
/// des tranches : le test de llama.cpp lui-même pour reconnaître une tête
/// MTP (la clé nextn_predict_layers seule ne suffit pas).
pub has_next_n_tensor: bool,
/// total sur toutes les tranches
pub tensor_count: i64,
}
// Bornes de lecture : larges devant les vrais modèles, elles servent seulement
// à refuser un fichier aberrant avant d'allouer quoi que ce soit.
const GGUF_MAX_STRING: u64 = 64 << 20;
const GGUF_MAX_KEY_LEN: u64 = 64 << 10;
const GGUF_MAX_TENSOR_NAME: u64 = 4096; // GGML_MAX_NAME vaut 64
const GGUF_MAX_KV: u64 = 1 << 20;
const GGUF_MAX_TENSORS: u64 = 1 << 22;
const GGUF_MAX_DIMS: u32 = 8; // GGML_MAX_DIMS vaut 4
const GGUF_MAX_ARRAY_DEPTH: u32 = 4;
const GGUF_MAX_LAYER_ARRAY: u64 = 1 << 16;
// Types de valeur GGUF (gguf.h).
pub const GGUF_UINT8: u32 = 0;
pub const GGUF_INT8: u32 = 1;
pub const GGUF_UINT16: u32 = 2;
pub const GGUF_INT16: u32 = 3;
pub const GGUF_UINT32: u32 = 4;
pub const GGUF_INT32: u32 = 5;
pub const GGUF_FLOAT32: u32 = 6;
pub const GGUF_BOOL: u32 = 7;
pub const GGUF_STRING: u32 = 8;
pub const GGUF_ARRAY: u32 = 9;
pub const GGUF_UINT64: u32 = 10;
pub const GGUF_INT64: u32 = 11;
pub const GGUF_FLOAT64: u32 = 12;
const ERR_TRUNCATED: &str = "GGUF tronqué";
fn truncated<T>() -> Result<T, String> {
Err(ERR_TRUNCATED.to_string())
}
/// Taille en octets d'un type de taille fixe, 0 sinon.
fn scalar_size(t: u32) -> i64 {
match t {
GGUF_UINT8 | GGUF_INT8 | GGUF_BOOL => 1,
GGUF_UINT16 | GGUF_INT16 => 2,
GGUF_UINT32 | GGUF_INT32 | GGUF_FLOAT32 => 4,
GGUF_UINT64 | GGUF_INT64 | GGUF_FLOAT64 => 8,
_ => 0,
}
}
/// Lecture séquentielle en comptant ce qui reste du fichier : toute longueur
/// annoncée au-delà est une incohérence, détectée AVANT de lire.
struct Reader<R: Read> {
r: BufReader<R>,
be: bool, // gros-boutiste
v1: bool, // GGUF v1 : compteurs et longueurs sur 32 bits
size: i64, // taille totale du fichier
left: i64, // octets restants dans le fichier
}
impl<R: Read> Reader<R> {
fn fixed(&mut self, n: usize) -> Result<[u8; 8], String> {
if n as i64 > self.left {
return truncated();
}
let mut b = [0u8; 8];
if self.r.read_exact(&mut b[..n]).is_err() {
return truncated();
}
self.left -= n as i64;
Ok(b)
}
fn u16_of(&self, b: &[u8; 8]) -> u16 {
let a = [b[0], b[1]];
if self.be {
u16::from_be_bytes(a)
} else {
u16::from_le_bytes(a)
}
}
fn u32_of(&self, b: &[u8; 8]) -> u32 {
let a = [b[0], b[1], b[2], b[3]];
if self.be {
u32::from_be_bytes(a)
} else {
u32::from_le_bytes(a)
}
}
fn u64_of(&self, b: &[u8; 8]) -> u64 {
if self.be {
u64::from_be_bytes(*b)
} else {
u64::from_le_bytes(*b)
}
}
fn u32(&mut self) -> Result<u32, String> {
let b = self.fixed(4)?;
Ok(self.u32_of(&b))
}
fn u64(&mut self) -> Result<u64, String> {
let b = self.fixed(8)?;
Ok(self.u64_of(&b))
}
/// Compteur ou longueur : 32 bits en v1, 64 bits ensuite.
fn count(&mut self) -> Result<u64, String> {
if self.v1 {
self.u32().map(u64::from)
} else {
self.u64()
}
}
/// Taille sur disque d'un compteur, pour borner les tableaux de chaînes.
fn count_size(&self) -> i64 {
if self.v1 { 4 } else { 8 }
}
fn skip(&mut self, n: i64) -> Result<(), String> {
if n < 0 || n > self.left {
return truncated();
}
let mut lim = Read::by_ref(&mut self.r).take(n as u64);
match io::copy(&mut lim, &mut io::sink()) {
Ok(c) if c == n as u64 => {}
_ => return truncated(),
}
self.left -= n;
Ok(())
}
/// Chaîne d'au plus `max` octets.
fn str(&mut self, max: u64) -> Result<String, String> {
let n = self.count()?;
if n > max {
return Err(format!("chaîne GGUF de {n} octets"));
}
if n as i64 > self.left {
return truncated();
}
let mut b = vec![0u8; n as usize];
if self.r.read_exact(&mut b).is_err() {
return truncated();
}
self.left -= n as i64;
Ok(String::from_utf8_lossy(&b).into_owned())
}
fn skip_str(&mut self) -> Result<(), String> {
let n = self.count()?;
if n > GGUF_MAX_STRING {
return Err(format!("chaîne GGUF de {n} octets"));
}
self.skip(n as i64)
}
/// Valeur entière (ou booléenne) de type `t`. `None` pour les flottants,
/// lus et ignorés : aucune des clés retenues n'en est un.
fn int_val(&mut self, t: u32) -> Result<Option<i64>, String> {
let size = scalar_size(t);
if size == 0 {
return Err(format!("type GGUF {t} inattendu"));
}
let b = self.fixed(size as usize)?;
Ok(match t {
GGUF_UINT8 | GGUF_BOOL => Some(i64::from(b[0])),
GGUF_INT8 => Some(i64::from(b[0] as i8)),
GGUF_UINT16 => Some(i64::from(self.u16_of(&b))),
GGUF_INT16 => Some(i64::from(self.u16_of(&b) as i16)),
GGUF_UINT32 => Some(i64::from(self.u32_of(&b))),
GGUF_INT32 => Some(i64::from(self.u32_of(&b) as i32)),
GGUF_UINT64 => Some(self.u64_of(&b).min(i64::MAX as u64) as i64),
GGUF_INT64 => Some(self.u64_of(&b) as i64),
_ => None, // flottant
})
}
/// Saute `n` éléments de type `et`, tableaux imbriqués compris (profondeur
/// bornée). Le vocabulaire (150 000 chaînes) passe par ici sans rien
/// allouer.
fn skip_array(&mut self, et: u32, n: u64, depth: u32) -> Result<(), String> {
if depth > GGUF_MAX_ARRAY_DEPTH {
return Err("tableaux GGUF trop imbriqués".to_string());
}
let size = scalar_size(et);
if size > 0 {
if n > (self.left / size) as u64 {
return truncated();
}
return self.skip(n as i64 * size);
}
match et {
GGUF_STRING => {
if n > (self.left / self.count_size()) as u64 {
return truncated();
}
for _ in 0..n {
self.skip_str()?;
}
Ok(())
}
GGUF_ARRAY => {
// Chaque sous-tableau coûte au moins son type et son compteur.
if n > (self.left / (4 + self.count_size())) as u64 {
return truncated();
}
for _ in 0..n {
let sub = self.u32()?;
let sn = self.count()?;
self.skip_array(sub, sn, depth + 1)?;
}
Ok(())
}
_ => Err(format!("type GGUF {et} inconnu")),
}
}
}
/// Ce qu'on retient d'UNE tranche avant de résoudre les clés selon
/// l'architecture (general.architecture n'est pas forcément la première clé).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct GgufFile {
pub version: i64,
pub arch: String,
pub ints: HashMap<String, i64>,
/// tableaux d'entiers retenus (têtes KV par couche)
pub arrays: HashMap<String, Vec<i64>>,
/// noms de toutes les clés, pour repérer `<arch>.ssm.*`
pub keys: Vec<String>,
pub tensors: i64,
/// noms des tenseurs `*.nextn.eh_proj.weight`
pub nextn: Vec<String>,
}
/// Les seuls tableaux qu'on lit au lieu de les sauter.
fn keep_int_array(key: &str) -> bool {
key.ends_with(".attention.head_count_kv")
}
/// Lit l'en-tête, les clés et la table des tenseurs d'un fichier.
fn read_gguf_header(path: &str) -> Result<GgufFile, String> {
let fh = match File::open(path) {
Ok(f) => f,
// Windows refuse d'ouvrir un dossier là où le Go y arrive : même
// message que le Go dans ce cas.
Err(e) => {
if std::fs::metadata(path).is_ok_and(|m| m.is_dir()) {
return Err(format!("{} est un dossier", base_name(path)));
}
return Err(os_err("open", path, &e));
}
};
let st = fh.metadata().map_err(|e| os_err("stat", path, &e))?;
if st.is_dir() {
return Err(format!("{} est un dossier", base_name(path)));
}
parse_gguf(fh, st.len() as i64).map_err(|e| format!("{} : {e}", base_name(path)))
}
/// Lit `size` octets au plus de `r`. Un panic éventuel (fichier pathologique
/// non prévu par les bornes) est rattrapé en erreur : lire des métadonnées ne
/// doit jamais faire tomber Loki.
pub fn parse_gguf<R: Read>(r: R, size: i64) -> Result<GgufFile, String> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parse_inner(r, size))) {
Ok(res) => res,
Err(p) => {
let msg = p
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_default();
Err(format!("GGUF illisible : {msg}"))
}
}
}
fn parse_inner<R: Read>(r: R, size: i64) -> Result<GgufFile, String> {
let mut g = Reader {
r: BufReader::with_capacity(1 << 20, r),
be: false,
v1: false,
size,
left: size,
};
let magic = g.fixed(4)?;
if &magic[..4] != b"GGUF" {
return Err("pas un fichier GGUF".to_string());
}
let mut ver = g.u32()?;
// Les GGUF gros-boutistes (s390x) gardent la même signature : seule la
// version lue à l'envers trahit l'ordre des octets.
if ver & 0xFFFF == 0 {
let sw = ver.swap_bytes();
if (1..=3).contains(&sw) {
g.be = true;
ver = sw;
}
}
if !(1..=3).contains(&ver) {
return Err(format!("version GGUF {ver} non prise en charge"));
}
g.v1 = ver == 1;
let n_tensors = g.count()?;
let n_kv = g.count()?;
if n_kv > GGUF_MAX_KV || n_tensors > GGUF_MAX_TENSORS {
return Err(format!(
"compteurs GGUF aberrants ({n_kv} clés, {n_tensors} tenseurs)"
));
}
let mut f = GgufFile {
version: i64::from(ver),
tensors: n_tensors as i64,
..Default::default()
};
for _ in 0..n_kv {
let key = g.str(GGUF_MAX_KEY_LEN)?;
let t = g.u32()?;
f.keys.push(key.clone());
match t {
GGUF_STRING => {
if key == "general.architecture" {
f.arch = g.str(GGUF_MAX_KEY_LEN)?;
} else {
g.skip_str()?;
}
}
GGUF_ARRAY => {
let et = g.u32()?;
let n = g.count()?;
if keep_int_array(&key)
&& et != GGUF_FLOAT32
&& et != GGUF_FLOAT64
&& scalar_size(et) > 0
&& n <= GGUF_MAX_LAYER_ARRAY
{
let mut vals = Vec::with_capacity(n.min(g.left.max(0) as u64) as usize);
for _ in 0..n {
vals.push(g.int_val(et)?.unwrap_or(0));
}
f.arrays.insert(key, vals);
} else {
g.skip_array(et, n, 1)?;
}
}
_ => {
if let Some(v) = g.int_val(t)? {
f.ints.insert(key, v);
}
}
}
}
let dim_size: i64 = if g.v1 { 4 } else { 8 };
let mut max_off: u64 = 0;
for _ in 0..n_tensors {
let name = g.str(GGUF_MAX_TENSOR_NAME)?;
let nd = g.u32()?;
if nd > GGUF_MAX_DIMS {
return Err(format!("tenseur {name:?} à {nd} dimensions"));
}
// dimensions et type (u32), puis position des données (u64)
g.skip(i64::from(nd) * dim_size + 4)?;
let off = g.u64()?;
max_off = max_off.max(off);
if name.ends_with(".nextn.eh_proj.weight") {
f.nextn.push(name);
}
}
// Les données commencent au prochain multiple de general.alignment. Le
// début du dernier tenseur doit tenir dans le fichier : un GGUF coupé dans
// ses poids a un en-tête intact, il faut le voir quand même.
let mut align: i64 = 32;
if let Some(&a) = f.ints.get("general.alignment") {
if a <= 0 || a > 1 << 20 || a & (a - 1) != 0 {
return Err(format!("alignement GGUF {a} invalide"));
}
align = a;
}
if n_tensors > 0 {
let pos = g.size - g.left;
let data_start = (pos + align - 1) / align * align;
if max_off >= g.size as u64 || data_start + max_off as i64 >= g.size {
return truncated();
}
}
Ok(f)
}
/// Ramène une valeur de métadonnée dans [0, i32::MAX] : un champ négatif ou
/// démesuré n'a pas de sens pour un compte de couches ou de têtes.
fn clamp_int(v: i64) -> i64 {
v.clamp(0, i64::from(i32::MAX))
}
/// Résout les clés de la première tranche selon l'architecture et cherche la
/// tête MTP dans les tenseurs de toutes les tranches (`all` inclut `first`).
pub fn gguf_info_from(first: &GgufFile, all: &[GgufFile]) -> GgufMeta {
let p = format!("{}.", first.arch);
let get = |k: &str| clamp_int(first.ints.get(&format!("{p}{k}")).copied().unwrap_or(0));
let mut m = GgufMeta {
version: first.version,
arch: first.arch.clone(),
block_count: get("block_count"),
next_n: get("nextn_predict_layers"),
context_length: get("context_length"),
expert_count: get("expert_count"),
key_len: get("attention.key_length"),
val_len: get("attention.value_length"),
full_attn_interval: get("full_attention_interval"),
embedding_length: get("embedding_length"),
head_count: get("attention.head_count"),
ssm_conv: get("ssm.conv_kernel"),
ssm_inner: get("ssm.inner_size"),
ssm_state: get("ssm.state_size"),
ssm_groups: get("ssm.group_count"),
head_count_kv: get("attention.head_count_kv"),
..Default::default()
};
if let Some(arr) = first.arrays.get(&format!("{p}attention.head_count_kv")) {
let layers: Vec<i64> = arr.iter().map(|&v| clamp_int(v)).collect();
m.head_count_kv = layers.iter().copied().max().unwrap_or(0);
m.head_count_kv_layers = Some(layers);
}
if !first.arch.is_empty() {
let ssm = format!("{p}ssm.");
m.hybrid = first.keys.iter().any(|k| k.starts_with(&ssm));
}
let want = if m.block_count > 0 {
format!("blk.{}.nextn.eh_proj.weight", m.block_count - 1)
} else {
String::new()
};
for f in all {
m.tensor_count += f.tensors;
if f.nextn.contains(&want) {
m.has_next_n_tensor = true;
}
}
m
}
// Cache par chemin : la même liste de modèles est consultée à chaque démarrage
// et à chaque affichage. L'empreinte (taille et date de chaque tranche) suffit
// à voir un fichier remplacé ou un téléchargement terminé ; les erreurs ne sont
// pas gardées.
const GGUF_CACHE_MAX: usize = 256;
type GgufCache = Mutex<HashMap<String, (String, GgufMeta)>>;
fn gguf_cache() -> &'static GgufCache {
static CACHE: OnceLock<GgufCache> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
fn unix_nanos(t: SystemTime) -> i128 {
match t.duration_since(UNIX_EPOCH) {
Ok(d) => d.as_nanos() as i128,
Err(e) => -(e.duration().as_nanos() as i128),
}
}
/// `ggufMeta` : métadonnées du modèle désigné par `path`. Pour un modèle en
/// tranches, `path` peut désigner n'importe laquelle : les clés viennent de la
/// première, les tenseurs de toutes. Une tranche manquante est une erreur.
///
/// Entrées/sorties bloquantes : à appeler hors du runtime async.
pub fn gguf_meta(path: &Path) -> Result<GgufMeta, String> {
let path = go_clean(&path.to_string_lossy());
let dir = go_dir(&path);
let fam = shard_family(&path);
let mut paths = Vec::with_capacity(fam.len());
let mut stamp = String::new();
for n in &fam {
let p = go_join(&[&dir, n]);
let st = std::fs::metadata(&p).map_err(|e| os_err("stat", &p, &e))?;
if st.is_dir() {
return Err(format!("{n} est un dossier"));
}
let mt = st.modified().map(unix_nanos).unwrap_or(0);
let _ = write!(stamp, "{}:{};", st.len(), mt);
paths.push(p);
}
let key = paths[0].clone();
let cached = gguf_cache()
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
.cloned();
if let Some((s, info)) = cached
&& s == stamp
{
return Ok(info);
}
let files = paths
.iter()
.map(|p| read_gguf_header(p))
.collect::<Result<Vec<_>, _>>()?;
let info = gguf_info_from(&files[0], &files);
let mut c = gguf_cache().lock().unwrap_or_else(|e| e.into_inner());
if c.len() >= GGUF_CACHE_MAX {
c.clear();
}
c.insert(key, (stamp, info.clone()));
Ok(info)
}
// --- tranches (backend_models_shards.go) -----------------------------------
/// Équivalent de `(?i)-(\d{2,5})-of-(\d{2,5})\.gguf$` : positions (début, fin)
/// du rang et du total.
fn shard_match(name: &str) -> Option<((usize, usize), (usize, usize))> {
let b = name.as_bytes();
let n = b.len();
if n < 5 || !b[n - 5..].eq_ignore_ascii_case(b".gguf") {
return None;
}
let e2 = n - 5;
let mut s2 = e2;
while s2 > 0 && b[s2 - 1].is_ascii_digit() {
s2 -= 1;
}
if !(2..=5).contains(&(e2 - s2)) || s2 < 4 || !b[s2 - 4..s2].eq_ignore_ascii_case(b"-of-") {
return None;
}
let e1 = s2 - 4;
let mut s1 = e1;
while s1 > 0 && b[s1 - 1].is_ascii_digit() {
s1 -= 1;
}
if !(2..=5).contains(&(e1 - s1)) || s1 < 1 || b[s1 - 1] != b'-' {
return None;
}
Some(((s1, e1), (s2, e2)))
}
/// `shardInfo` : rang (à partir de 1) et nombre total ; `None` pour un modèle
/// d'un seul fichier ou une numérotation incohérente.
pub fn shard_info(name: &str) -> Option<(i64, i64)> {
let base = base_name(name);
let ((s1, e1), (s2, e2)) = shard_match(&base)?;
let idx: i64 = base[s1..e1].parse().ok()?;
let total: i64 = base[s2..e2].parse().ok()?;
if idx < 1 || total < 1 || idx > total {
return None;
}
Some((idx, total))
}
/// `shardNameAt` : nom de la tranche de rang `i` dans la même famille, en
/// conservant la largeur de la numérotation d'origine.
fn shard_name_at(name: &str, i: i64) -> String {
match shard_match(name) {
Some(((s1, e1), (s2, e2))) => format!(
"{}{:0width$}-of-{}{}",
&name[..s1],
i,
&name[s2..e2],
&name[e2..],
width = e1 - s1
),
None => name.to_string(),
}
}
/// `isFollowerShard` : tranche 2..N.
pub fn is_follower_shard(name: &str) -> bool {
shard_info(name).is_some_and(|(idx, _)| idx > 1)
}
/// `shardFamily` : noms de TOUTES les tranches de la famille de `name` (y
/// compris lui), dans l'ordre. Un fichier simple se renvoie lui-même.
pub fn shard_family(name: &str) -> Vec<String> {
let base = base_name(name);
match shard_info(&base) {
None => vec![base],
Some((_, total)) => (1..=total).map(|i| shard_name_at(&base, i)).collect(),
}
}
// --- dossiers de modèles (backend_model_dirs.go) ---------------------------
/// `modelsDir()` : `LOKI_HOME/models`.
pub fn models_dir(home: &Path) -> String {
go_join(&[&home.to_string_lossy(), "models"])
}
/// Séparateur de `$LOKI_MODEL_DIRS` (comme le PATH).
fn model_dirs_list_sep() -> char {
if cfg!(windows) { ';' } else { ':' }
}
/// Décodage de `state/model_dirs` comme `getJSON` dans un `[]string` :
/// absent, vide ou pas un tableau → rien ; éléments non textuels ignorés (le
/// Go les laisse vides, et `dedup_dirs` écarte les vides).
pub fn parse_stored_model_dirs(raw: Option<&[u8]>) -> Vec<String> {
match raw.and_then(|b| serde_json::from_slice::<Value>(b).ok()) {
Some(Value::Array(a)) => a
.into_iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
_ => Vec::new(),
}
}
/// `normDir` : chemin sous forme comparable (séparateurs unifiés en `/`, casse
/// neutralisée sous Windows, slash final retiré).
pub fn norm_dir(p: &str) -> String {
let p = go_clean(p.trim()).replace('\\', "/");
if cfg!(windows) { p.to_lowercase() } else { p }
}
/// `dedupDirs` : vides retirés, chemins absolus normalisés, doublons et `skip`
/// écartés.
pub fn dedup_dirs(list: &[String], skip: &str) -> Vec<String> {
let mut seen = std::collections::HashSet::from([norm_dir(skip)]);
let mut out = Vec::new();
for d in list {
let d = d.trim();
if d.is_empty() {
continue;
}
let Some(abs) = go_abs(d) else { continue };
if seen.insert(norm_dir(&abs)) {
out.push(abs);
}
}
out
}
/// `extraModelDirs` : dossiers déclarés par l'utilisateur (base puis
/// `$LOKI_MODEL_DIRS`), nettoyés et dédoublonnés, models/ exclu.
pub fn extra_model_dirs(home: &Path, stored: &[String], env_dirs: Option<&str>) -> Vec<String> {
let mut raw = stored.to_vec();
if let Some(v) = env_dirs.filter(|v| !v.is_empty()) {
raw.extend(v.split(model_dirs_list_sep()).map(str::to_string));
}
dedup_dirs(&raw, &models_dir(home))
}
/// `modelDirs` : models/ d'abord, puis les dossiers ajoutés.
pub fn model_dirs(home: &Path, stored: &[String], env_dirs: Option<&str>) -> Vec<String> {
let mut v = vec![models_dir(home)];
v.extend(extra_model_dirs(home, stored, env_dirs));
v
}
/// `pathWithin` : le fichier `p` est-il dans le dossier `dir` (ou dessous) ?
pub fn path_within(dir: &str, p: &str) -> bool {
let (d, f) = (norm_dir(dir), norm_dir(p));
f == d || f.starts_with(&format!("{d}/"))
}
fn trim_model_name(name: &str) -> &str {
name.trim().trim_matches('"').trim()
}
fn is_file(p: &str) -> bool {
std::fs::metadata(p).is_ok_and(|m| !m.is_dir())
}
/// `resolveModelPath` : valeur MODEL= (nom de fichier OU chemin absolu) →
/// chemin absolu d'un .gguf. Un chemin absolu n'est accepté que dans un des
/// dossiers déclarés ; un simple nom est cherché dans chaque dossier, models/
/// en premier (introuvable : chemin dans models/, l'appelant décidera).
pub fn resolve_model_path(
name: &str,
home: &Path,
stored_dirs: &[String],
env_model_dirs: Option<&str>,
) -> Result<PathBuf, String> {
let s = trim_model_name(name);
if s.is_empty() {
return Err("nom de modèle invalide".to_string());
}
if !s.to_lowercase().ends_with(".gguf") {
return Err("le modèle doit être un fichier .gguf".to_string());
}
let dirs = model_dirs(home, stored_dirs, env_model_dirs);
if go_is_abs(s) {
let abs = go_clean(s);
if dirs.iter().any(|d| path_within(d, &abs)) {
return Ok(PathBuf::from(abs));
}
return Err(format!(
"dossier non autorisé : {} — ajoute-le dans « Dossiers de modèles »",
go_dir(&abs)
));
}
let base = base_name(s);
if base.is_empty() || base == "." {
return Err("nom de modèle invalide".to_string());
}
for d in &dirs {
let p = go_join(&[d, &base]);
if is_file(&p) {
return Ok(PathBuf::from(p));
}
}
Ok(PathBuf::from(go_join(&[&models_dir(home), &base])))
}
/// `resolveServeModelPath` : comme [`resolve_model_path`], mais un chemin
/// absolu qui pointe sur un .gguf existant est accepté même hors des dossiers
/// déclarés (lancer le moteur n'est qu'une lecture ; l'allowlist protège les
/// opérations destructrices pilotées depuis l'UI).
///
/// - `stored_dirs` : [`parse_stored_model_dirs`] de `state/model_dirs` ;
/// - `env_model_dirs` : `$LOKI_MODEL_DIRS`.
pub fn resolve_serve_model_path(
model: &str,
home: &Path,
stored_dirs: &[String],
env_model_dirs: Option<&str>,
) -> Result<PathBuf, String> {
let err = match resolve_model_path(model, home, stored_dirs, env_model_dirs) {
Ok(p) => return Ok(p),
Err(e) => e,
};
let s = trim_model_name(model);
if go_is_abs(s) && s.to_lowercase().ends_with(".gguf") {
let abs = go_clean(s);
if is_file(&abs) {
return Ok(PathBuf::from(abs));
}
}
Err(err)
}
/// Corps JSON de `GET /api/model/layers` (`handleModelLayers`, toujours en
/// 200) : nombre de blocs du modèle ; llama.cpp en offloade `block_count + 1`
/// (la couche de sortie en plus), d'où `ngl_max`.
///
/// - `query_model` : paramètre `?model=` ; s'il est vide après nettoyage des
/// espaces, `config_model` (clé `MODEL` du bucket `config`) prend le relais ;
/// - `stored_dirs` / `env_model_dirs` : voir [`resolve_serve_model_path`].
///
/// Entrées/sorties bloquantes : à appeler hors du runtime async.
pub fn model_layers_json(
query_model: Option<&str>,
config_model: Option<&str>,
home: &Path,
stored_dirs: &[String],
env_model_dirs: Option<&str>,
) -> Value {
let mut model = query_model.unwrap_or("").trim();
if model.is_empty() {
model = config_model.unwrap_or("").trim();
}
if model.is_empty() {
return json!({"ok": false, "error": "aucun modèle"});
}
let path = match resolve_serve_model_path(model, home, stored_dirs, env_model_dirs) {
Ok(p) => p,
Err(e) => return json!({"ok": false, "error": e}),
};
match gguf_meta(&path) {
Ok(info) if info.block_count > 0 => json!({
"ok": true,
"layers": info.block_count,
"ngl_max": info.block_count + 1,
}),
Ok(_) => json!({"ok": false, "error": "nombre de couches absent de l'en-tête"}),
Err(e) => json!({"ok": false, "error": e}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::FileTimes;
use std::time::Duration;
/// Écrit un GGUF synthétique : en-tête, clés typées, table des tenseurs,
/// sans aucune donnée de poids (le lecteur n'en lit jamais).
struct Builder {
be: bool,
version: u32,
n_kv: usize,
n_t: usize,
kv: Vec<u8>,
tensors: Vec<u8>,
}
fn new_gguf() -> Builder {
Builder {
be: false,
version: 3,
n_kv: 0,
n_t: 0,
kv: Vec::new(),
tensors: Vec::new(),
}
}
fn put_u32(be: bool, w: &mut Vec<u8>, v: u32) {
w.extend_from_slice(&if be { v.to_be_bytes() } else { v.to_le_bytes() });
}
fn put_u64(be: bool, w: &mut Vec<u8>, v: u64) {
w.extend_from_slice(&if be { v.to_be_bytes() } else { v.to_le_bytes() });
}
impl Builder {
fn cnt(&self, w: &mut Vec<u8>, n: u64) {
if self.version == 1 {
put_u32(self.be, w, n as u32);
} else {
put_u64(self.be, w, n);
}
}
fn str_to(&self, w: &mut Vec<u8>, s: &str) {
self.cnt(w, s.len() as u64);
w.extend_from_slice(s.as_bytes());
}
fn key(&mut self, k: &str, t: u32) {
self.n_kv += 1;
let mut kv = std::mem::take(&mut self.kv);
self.str_to(&mut kv, k);
put_u32(self.be, &mut kv, t);
self.kv = kv;
}
fn u32(mut self, k: &str, v: u32) -> Self {
self.key(k, GGUF_UINT32);
put_u32(self.be, &mut self.kv, v);
self
}
fn i64(mut self, k: &str, v: i64) -> Self {
self.key(k, GGUF_INT64);
put_u64(self.be, &mut self.kv, v as u64);
self
}
fn f32(mut self, k: &str, v: f32) -> Self {
self.key(k, GGUF_FLOAT32);
put_u32(self.be, &mut self.kv, v.to_bits());
self
}
fn boolean(mut self, k: &str, v: bool) -> Self {
self.key(k, GGUF_BOOL);
self.kv.push(u8::from(v));
self
}
fn s(mut self, k: &str, v: &str) -> Self {
self.key(k, GGUF_STRING);
let mut kv = std::mem::take(&mut self.kv);
self.str_to(&mut kv, v);
self.kv = kv;
self
}
fn str_arr(mut self, k: &str, vs: &[&str]) -> Self {
self.key(k, GGUF_ARRAY);
let mut kv = std::mem::take(&mut self.kv);
put_u32(self.be, &mut kv, GGUF_STRING);
self.cnt(&mut kv, vs.len() as u64);
for v in vs {
self.str_to(&mut kv, v);
}
self.kv = kv;
self
}
fn i32_arr(mut self, k: &str, vs: &[i32]) -> Self {
self.key(k, GGUF_ARRAY);
let mut kv = std::mem::take(&mut self.kv);
put_u32(self.be, &mut kv, GGUF_INT32);
self.cnt(&mut kv, vs.len() as u64);
for &v in vs {
put_u32(self.be, &mut kv, v as u32);
}
self.kv = kv;
self
}
/// Tableau de deux tableaux de u8, pour vérifier le saut récursif.
fn nested(mut self, k: &str) -> Self {
self.key(k, GGUF_ARRAY);
let mut kv = std::mem::take(&mut self.kv);
put_u32(self.be, &mut kv, GGUF_ARRAY);
self.cnt(&mut kv, 2);
for _ in 0..2 {
put_u32(self.be, &mut kv, GGUF_UINT8);
self.cnt(&mut kv, 3);
kv.extend_from_slice(&[1, 2, 3]);
}
self.kv = kv;
self
}
fn tensor(mut self, name: &str, dims: &[u64]) -> Self {
let mut t = std::mem::take(&mut self.tensors);
self.str_to(&mut t, name);
put_u32(self.be, &mut t, dims.len() as u32);
for &d in dims {
if self.version == 1 {
put_u32(self.be, &mut t, d as u32);
} else {
put_u64(self.be, &mut t, d);
}
}
put_u32(self.be, &mut t, 0); // type F32
put_u64(self.be, &mut t, (self.n_t * 32) as u64); // position des données
self.tensors = t;
self.n_t += 1;
self
}
fn bytes(&self) -> Vec<u8> {
let mut out = b"GGUF".to_vec();
put_u32(self.be, &mut out, self.version);
self.cnt(&mut out, self.n_t as u64);
self.cnt(&mut out, self.n_kv as u64);
out.extend_from_slice(&self.kv);
out.extend_from_slice(&self.tensors);
// bourrage jusqu'à l'alignement par défaut, puis 32 octets de
// « poids » par tenseur
if self.n_t > 0 {
let pad = (32 - out.len() % 32) % 32 + 32 * self.n_t;
out.resize(out.len() + pad, 0);
}
out
}
}
fn write_gguf(dir: &Path, name: &str, data: &[u8]) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, data).unwrap();
p
}
/// Modèle hybride à 4 couches, têtes KV par couche (0 sur les couches
/// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter.
fn qwen_mtp(b: Builder) -> Builder {
let long = "c".repeat(300);
b.u32("general.alignment", 32)
.str_arr("tokenizer.ggml.tokens", &["<s>", "a", "bb", &long])
.s("general.architecture", "qwen35")
.s("general.name", "Qwen synthétique")
.u32("qwen35.block_count", 4)
.u32("qwen35.nextn_predict_layers", 1)
.i64("qwen35.context_length", 262144)
.u32("qwen35.attention.key_length", 256)
.u32("qwen35.attention.value_length", 256)
.i32_arr("qwen35.attention.head_count_kv", &[0, 0, 0, 4])
.u32("qwen35.full_attention_interval", 4)
.u32("qwen35.ssm.conv_kernel", 4)
.f32("qwen35.rope.freq_base", 1e7)
.boolean("tokenizer.ggml.add_bos_token", false)
.nested("bidon.imbrique")
.tensor("token_embd.weight", &[1024, 32000])
.tensor("blk.0.attn_norm.weight", &[1024])
.tensor("blk.3.nextn.eh_proj.weight", &[2048, 1024])
}
#[test]
fn gguf_meta_qwen_hybride_mtp() {
let dir = tempfile::tempdir().unwrap();
let p = write_gguf(dir.path(), "qwen.gguf", &qwen_mtp(new_gguf()).bytes());
let got = gguf_meta(&p).unwrap();
let want = GgufMeta {
version: 3,
arch: "qwen35".into(),
block_count: 4,
next_n: 1,
context_length: 262144,
key_len: 256,
val_len: 256,
head_count_kv: 4,
head_count_kv_layers: Some(vec![0, 0, 0, 4]),
full_attn_interval: 4,
hybrid: true,
ssm_conv: 4,
has_next_n_tensor: true,
tensor_count: 3,
..Default::default()
};
assert_eq!(got, want);
}
/// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la
/// DERNIÈRE couche compte, comme dans llama.cpp.
#[test]
fn gguf_meta_tete_mtp_absente_ou_mal_placee() {
let dir = tempfile::tempdir().unwrap();
let base = || {
new_gguf()
.s("general.architecture", "glm4moe")
.u32("glm4moe.block_count", 47)
.u32("glm4moe.nextn_predict_layers", 1)
.u32("glm4moe.expert_count", 128)
.u32("glm4moe.attention.head_count_kv", 8)
};
let cases = [
(
"sans-tenseur.gguf",
base().tensor("blk.45.ffn_up.weight", &[8, 8]),
),
(
"mauvaise-couche.gguf",
base().tensor("blk.45.nextn.eh_proj.weight", &[8, 8]),
),
];
for (name, b) in cases {
let got = gguf_meta(&write_gguf(dir.path(), name, &b.bytes()))
.unwrap_or_else(|e| panic!("{name} : {e}"));
assert!(
!(got.has_next_n_tensor
|| got.next_n != 1
|| got.expert_count != 128
|| got.head_count_kv != 8
|| got.head_count_kv_layers.is_some()
|| got.hybrid),
"{name} : {got:?}"
);
}
let got = gguf_meta(&write_gguf(
dir.path(),
"ok.gguf",
&base()
.tensor("blk.46.nextn.eh_proj.weight", &[8, 8])
.bytes(),
));
assert!(
got.as_ref().is_ok_and(|g| g.has_next_n_tensor),
"tête MTP en dernière couche non vue : {got:?}"
);
}
/// Modèle en tranches : les clés sont dans la première, la tête MTP dans
/// la dernière. N'importe quelle tranche peut être désignée.
#[test]
fn gguf_meta_tranches() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let first = || {
new_gguf()
.s("general.architecture", "qwen35")
.u32("qwen35.block_count", 4)
.u32("split.count", 2)
.tensor("token_embd.weight", &[8, 8])
};
let second = new_gguf()
.u32("split.no", 1)
.u32("split.count", 2)
.tensor("blk.3.nextn.eh_proj.weight", &[8, 8])
.tensor("output.weight", &[8, 8]);
let p1 = write_gguf(d, "m-00001-of-00002.gguf", &first().bytes());
write_gguf(d, "m-00002-of-00002.gguf", &second.bytes());
for p in [p1, d.join("m-00002-of-00002.gguf")] {
let got = gguf_meta(&p).unwrap();
assert!(
got.arch == "qwen35" && got.has_next_n_tensor && got.tensor_count == 3,
"{} : {got:?}",
base_name(&p.to_string_lossy())
);
}
// tranche manquante : on ne sait pas, donc erreur
write_gguf(d, "n-00001-of-00002.gguf", &first().bytes());
assert!(
gguf_meta(&d.join("n-00001-of-00002.gguf")).is_err(),
"tranche manquante acceptée"
);
// gguf-split --no-tensor-first-split : la première tranche ne porte
// que les clés, aucun tenseur ni aucune donnée. Elle doit passer.
let meta = new_gguf()
.s("general.architecture", "glm4moe")
.u32("glm4moe.block_count", 47)
.u32("split.count", 2);
let rest = new_gguf()
.u32("split.no", 1)
.tensor("blk.46.nextn.eh_proj.weight", &[8, 8]);
write_gguf(d, "s-00001-of-00002.gguf", &meta.bytes());
write_gguf(d, "s-00002-of-00002.gguf", &rest.bytes());
let got = gguf_meta(&d.join("s-00001-of-00002.gguf"));
assert!(
got.as_ref()
.is_ok_and(|g| g.arch == "glm4moe" && g.has_next_n_tensor && g.tensor_count == 1),
"première tranche sans tenseur : {got:?}"
);
}
/// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3.
#[test]
fn gguf_meta_versions_et_ordre() {
let dir = tempfile::tempdir().unwrap();
let mut v1 = new_gguf();
v1.version = 1;
let mut v2 = new_gguf();
v2.version = 2;
let mut be = new_gguf();
be.be = true;
for (name, b) in [("v1.gguf", v1), ("v2.gguf", v2), ("be.gguf", be)] {
let version = i64::from(b.version);
let got = gguf_meta(&write_gguf(dir.path(), name, &qwen_mtp(b).bytes()))
.unwrap_or_else(|e| panic!("{name} : {e}"));
assert!(
got.arch == "qwen35"
&& got.block_count == 4
&& got.has_next_n_tensor
&& got.head_count_kv_layers == Some(vec![0, 0, 0, 4])
&& got.version == version,
"{name} : {got:?}"
);
}
}
/// Fichier en cours de téléchargement : chaque coupure donne une erreur,
/// jamais un panic ni un faux résultat.
#[test]
fn gguf_meta_tronque() {
let dir = tempfile::tempdir().unwrap();
let full = qwen_mtp(new_gguf()).bytes();
// Toutes les coupures jusqu'au premier octet du dernier tenseur.
for cut in 0..=full.len() - 32 {
assert!(
parse_gguf(&full[..cut], cut as i64).is_err(),
"coupure à {cut}/{} acceptée",
full.len()
);
}
if let Err(e) = parse_gguf(&full[..], full.len() as i64) {
panic!("fichier complet refusé : {e}");
}
// et par le chemin fichier, pour une coupure dans l'en-tête
let p = write_gguf(dir.path(), "cut.gguf", &full[..full.len() / 3]);
assert!(gguf_meta(&p).is_err(), "fichier coupé accepté");
}
#[test]
fn gguf_meta_fichiers_aberrants() {
let dir = tempfile::tempdir().unwrap();
let hdr = |ver: u32, n_t: u64, n_kv: u64| {
let mut w = b"GGUF".to_vec();
put_u32(false, &mut w, ver);
put_u64(false, &mut w, n_t);
put_u64(false, &mut w, n_kv);
w
};
// clé annoncée à 1 Eo
let mut huge_key = hdr(3, 0, 1);
put_u64(false, &mut huge_key, 1 << 60);
// tableau de chaînes annoncé à 2^62 éléments
let mut huge_arr = hdr(3, 0, 1);
put_u64(false, &mut huge_arr, 1);
huge_arr.push(b'k');
put_u32(false, &mut huge_arr, GGUF_ARRAY);
put_u32(false, &mut huge_arr, GGUF_STRING);
put_u64(false, &mut huge_arr, 1 << 62);
// type de valeur inconnu
let mut bad_type = hdr(3, 0, 1);
put_u64(false, &mut bad_type, 1);
bad_type.push(b'k');
put_u32(false, &mut bad_type, 99);
put_u64(false, &mut bad_type, 0);
// tenseur à 1000 dimensions
let mut bad_dims = hdr(3, 1, 0);
put_u64(false, &mut bad_dims, 1);
bad_dims.push(b't');
put_u32(false, &mut bad_dims, 1000);
bad_dims.extend_from_slice(&[0u8; 64]);
let mut signature = b"GGML\x03\x00\x00\x00".to_vec();
signature.extend_from_slice(&[0u8; 16]);
let cases: Vec<(&str, Vec<u8>)> = vec![
("vide", Vec::new()),
(
"alignement",
new_gguf()
.u32("general.alignment", 24)
.tensor("t", &[1])
.bytes(),
),
("signature", signature),
("version", hdr(4, 0, 0)),
("compteurs", hdr(3, 1 << 40, 1 << 40)),
("cle-geante", huge_key),
("tableau", huge_arr),
("type-inconnu", bad_type),
("dimensions", bad_dims),
];
for (name, data) in cases {
let p = write_gguf(dir.path(), &format!("{name}.gguf"), &data);
assert!(gguf_meta(&p).is_err(), "{name} : accepté");
}
assert!(
gguf_meta(&dir.path().join("absent.gguf")).is_err(),
"fichier absent accepté"
);
}
/// Octet corrompu n'importe où dans l'en-tête (v1 et v3) : le lecteur doit
/// rendre la main vite, sans panic remonté ni allocation démesurée.
#[test]
fn gguf_meta_octet_corrompu() {
let mut v1 = new_gguf();
v1.version = 1;
for b in [new_gguf(), v1] {
let version = b.version;
let full = qwen_mtp(b).bytes();
let hdr = full.len() - 3 * 32; // bourrage et « poids » jamais lus
for i in 0..hdr {
for v in [0xFFu8, 0x7F, 0x00] {
let mut m = full.clone();
m[i] = v;
// le filet de sécurité existe, mais aucune entrée ne doit
// y tomber
if let Err(e) = parse_gguf(&m[..], m.len() as i64) {
assert!(
!e.contains("illisible"),
"v{version}, octet {i} = {v:#x} : {e}"
);
}
}
}
}
}
fn set_mtime(p: &Path, t: SystemTime) {
let f = File::options().write(true).open(p).unwrap();
f.set_times(FileTimes::new().set_accessed(t).set_modified(t))
.unwrap();
}
/// Le cache tient tant que taille et date sont les mêmes, et se renouvelle
/// dès qu'elles changent.
#[test]
fn gguf_meta_cache() {
let dir = tempfile::tempdir().unwrap();
let data = qwen_mtp(new_gguf()).bytes();
let p = write_gguf(dir.path(), "c.gguf", &data);
// 2026-01-02T03:04:05Z
let stamp = UNIX_EPOCH + Duration::from_secs(1_767_323_045);
set_mtime(&p, stamp);
let mut a = gguf_meta(&p).unwrap();
a.head_count_kv_layers.as_mut().unwrap()[0] = 99; // ne doit pas atteindre le cache
// même taille, même date, contenu illisible : le cache répond
write_gguf(dir.path(), "c.gguf", &vec![0u8; data.len()]);
set_mtime(&p, stamp);
let b = gguf_meta(&p);
assert!(
b.as_ref().is_ok_and(
|b| b.head_count_kv_layers.as_ref().unwrap()[0] == 0 && b.has_next_n_tensor
),
"cache non utilisé ou altéré : {b:?}"
);
// date différente : relecture, donc erreur sur le contenu illisible
set_mtime(&p, stamp + Duration::from_secs(1));
assert!(
gguf_meta(&p).is_err(),
"fichier modifié servi depuis le cache"
);
}
// --- tranches (backend_models_shards_test.go) ---
#[test]
fn shard_info_cas() {
let cases = [
("Laguna-S-2.1-UD-IQ4_XS-00001-of-00003.gguf", Some((1, 3))),
("Laguna-S-2.1-UD-IQ4_XS-00002-of-00003.gguf", Some((2, 3))),
("m-01-of-03.gguf", Some((1, 3))),
("Qwen2.5-7B-Instruct-Q4_K_M.gguf", None),
("bidon-00004-of-00003.gguf", None), // numérotation incohérente
("pas-un-gguf-00001-of-00002.bin", None),
];
for (name, want) in cases {
assert_eq!(shard_info(name), want, "shard_info({name:?})");
}
}
#[test]
fn shard_family_garde_la_largeur() {
assert_eq!(
shard_family("Laguna-00002-of-00003.gguf"),
[
"Laguna-00001-of-00003.gguf",
"Laguna-00002-of-00003.gguf",
"Laguna-00003-of-00003.gguf",
]
);
assert_eq!(shard_family("simple.gguf"), ["simple.gguf"]);
assert!(is_follower_shard("x-02-OF-03.GGUF"));
assert!(!is_follower_shard("x-01-of-03.gguf"));
assert!(shard_info("x-000001-of-00003.gguf").is_none());
}
// --- résolution du modèle (backend_model_dirs_test.go) ---
/// Un modèle posé hors de models/ doit être utilisable dès que son
/// dossier est déclaré — et refusé tant qu'il ne l'est pas.
#[test]
fn resolve_model_path_extra_dir() {
let home = tempfile::tempdir().unwrap();
let ext = tempfile::tempdir().unwrap();
let ext_s = ext.path().to_string_lossy().into_owned();
let ext_model = go_join(&[&ext_s, "gros.gguf"]);
std::fs::write(&ext_model, b"x").unwrap();
assert!(
resolve_model_path(&ext_model, home.path(), &[], Some("")).is_err(),
"un dossier non déclaré devrait être refusé"
);
// … sauf pour LANCER le moteur : le fichier existe
assert_eq!(
resolve_serve_model_path(&ext_model, home.path(), &[], None),
Ok(PathBuf::from(&ext_model))
);
let stored =
parse_stored_model_dirs(Some(serde_json::to_string(&[&ext_s]).unwrap().as_bytes()));
let got = resolve_model_path(&ext_model, home.path(), &stored, Some(""));
assert_eq!(got, Ok(PathBuf::from(go_clean(&ext_model))));
// Le simple nom de fichier doit aussi être retrouvé dans le dossier ajouté.
let got = resolve_model_path("gros.gguf", home.path(), &stored, Some(""));
assert_eq!(got, Ok(PathBuf::from(&ext_model)));
}
/// models/ garde la priorité et les non-.gguf restent refusés.
#[test]
fn resolve_model_path_home_first() {
let home = tempfile::tempdir().unwrap();
let ext = tempfile::tempdir().unwrap();
let ext_s = ext.path().to_string_lossy().into_owned();
let models = models_dir(home.path());
std::fs::create_dir_all(&models).unwrap();
for d in [&models, &ext_s] {
std::fs::write(go_join(&[d, "m.gguf"]), b"x").unwrap();
}
let got = resolve_model_path("m.gguf", home.path(), &[], Some(&ext_s));
assert_eq!(got, Ok(PathBuf::from(go_join(&[&models, "m.gguf"]))));
assert!(resolve_model_path("/etc/passwd", home.path(), &[], Some(&ext_s)).is_err());
assert!(resolve_serve_model_path("/etc/passwd", home.path(), &[], None).is_err());
}
#[test]
fn dossiers_stockes() {
assert!(parse_stored_model_dirs(None).is_empty());
assert!(parse_stored_model_dirs(Some(b"")).is_empty());
assert!(parse_stored_model_dirs(Some(b"{}")).is_empty());
assert_eq!(
parse_stored_model_dirs(Some(br#"["/a", 1, "/b"]"#)),
["/a", "/b"]
);
let home = Path::new(if cfg!(windows) { r"C:\h" } else { "/h" });
let models = models_dir(home);
let extra = extra_model_dirs(
home,
&[models.clone(), " ".into(), "/x/".into(), "/x".into()],
None,
);
assert_eq!(extra, [go_abs("/x").unwrap()]);
assert!(path_within(&models, &go_join(&[&models, "sous", "m.gguf"])));
assert!(!path_within(&models, &format!("{models}2/m.gguf")));
}
#[test]
fn couches_du_modele() {
let home = tempfile::tempdir().unwrap();
let models = models_dir(home.path());
std::fs::create_dir_all(&models).unwrap();
write_gguf(Path::new(&models), "q.gguf", &qwen_mtp(new_gguf()).bytes());
write_gguf(
Path::new(&models),
"sans.gguf",
&new_gguf().s("general.architecture", "x").bytes(),
);
let h = home.path();
assert_eq!(
model_layers_json(Some(" "), Some(" q.gguf "), h, &[], None),
json!({"ok": true, "layers": 4, "ngl_max": 5})
);
assert_eq!(
model_layers_json(Some("q.gguf"), None, h, &[], None),
json!({"ok": true, "layers": 4, "ngl_max": 5})
);
assert_eq!(
model_layers_json(None, Some(""), h, &[], None),
json!({"ok": false, "error": "aucun modèle"})
);
assert_eq!(
model_layers_json(Some("x.bin"), None, h, &[], None),
json!({"ok": false, "error": "le modèle doit être un fichier .gguf"})
);
assert_eq!(
model_layers_json(Some("sans.gguf"), None, h, &[], None),
json!({"ok": false, "error": "nombre de couches absent de l'en-tête"})
);
let absent = model_layers_json(Some("absent.gguf"), None, h, &[], None);
assert_eq!(absent["ok"], false);
assert!(
absent["error"].as_str().unwrap().starts_with("stat "),
"{absent}"
);
}
}