Lot 8 : toutes les routes des modèles tenues par la façade (LOKI_MODELS=rust)

La variable LOKI_DOWNLOADS devient LOKI_MODELS et couvre, en plus des
téléchargements, la liste du sélecteur (avec qui référence chaque .gguf),
la suppression (refus si en service, 409 avec les presets sans force,
tranches emportées), les dossiers de modèles et le catalogue Hugging Face
(recherche, fichiers d'un dépôt avec verdict mémoire, jeton vérifié puis
enregistré, cache vidé à son changement).

Côté Go, facade_models.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais.

Parité : parity_models_test.go tire du Go les fonctions pures du catalogue
et un scénario sur disque de 20 appels (liste, dossiers, suppressions),
rejoués à l'identique par tests/models.rs ; catalogue et routes testés de
bout en bout contre un faux Hugging Face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 13:25:45 +00:00
1 parent bbea5fcd3f
commit a33e5ccffe
22 files changed
+3395 -187

No files matched your search

+3 -2
View File
@@ -26,9 +26,10 @@ pub struct Ctx {
pub engine: engine::Paths,
/// Conversation tenue par la façade (`LOKI_CHAT=rust`) ; `None` : au Go.
pub chat: Option<std::sync::Arc<crate::chat::generate::ChatService>>,
/// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) ;
/// Routes des modèles (liste, dossiers, suppression, téléchargements,
/// catalogue Hugging Face) tenues par la façade (`LOKI_MODELS=rust`) ;
/// `None` : au Go.
pub downloads: Option<std::sync::Arc<crate::downloads::Downloads>>,
pub models: Option<std::sync::Arc<crate::models::Models>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
+1 -1
View File
@@ -2,7 +2,7 @@
//! des téléchargements de `backend_models.go`, `backend_models_shards.go`,
//! `backend_model_dirs.go` et `backend_hf.go` (lot 8 de la migration).
//!
//! Tenus par la façade quand `LOKI_DOWNLOADS=rust` (expérimental ; le Go
//! Tenus par la façade quand `LOKI_MODELS=rust` (expérimental ; le Go
//! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`,
//! `GET /api/models/download/status`. Le Go remet alors à la façade ces
//! routes quand elles lui arrivent par le tunnel du relais, et ne vide plus
+735
View File
@@ -0,0 +1,735 @@
//! Catalogue Hugging Face — port de `backend_hf.go`, `web_hf.go` et
//! `backend_catalog.go` (lot 8 de la migration), servi par la façade quand
//! `LOKI_MODELS=rust` :
//!
//! - `GET /api/hf/search?q=…` : dépôts GGUF ;
//! - `GET /api/hf/files?repo=auteur/dépôt` : modèles, projecteurs et poids
//! MTP du dépôt, chaque modèle avec son verdict mémoire ;
//! - `GET|POST /api/hf/token` : le jeton (dépôts verrouillés), vérifié avant
//! d'être enregistré, jamais rendu en clair.
//!
//! Rien n'est téléchargé ici : les liens produits passent par
//! [`crate::downloads`]. Réponses de l'API mises en cache (60 s pour une
//! recherche, 10 min pour un dépôt) ; un nouveau jeton vide le cache.
//!
//! Écarts assumés : erreur de transport au format de reqwest ; mémoire vive
//! inconnue hors Linux (la façade n'est livrée que pour Linux).
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use bytes::Bytes;
use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::gguf;
use crate::models::quant_from_name;
use crate::paths::base_name;
use crate::proxy::Body;
use crate::store::BK_STATE;
pub const HF_HOST: &str = "https://huggingface.co";
const SEARCH_TTL: Duration = Duration::from_secs(60);
const FILES_TTL: Duration = Duration::from_secs(600);
const MAX_REPOS: usize = 25;
const MAX_QUERY: usize = 100;
const TIMEOUT: Duration = Duration::from_secs(10);
/// Routes servies ici.
pub fn handles(path: &str) -> bool {
matches!(path, "/api/hf/search" | "/api/hf/files" | "/api/hf/token")
}
// ---------------------------------------------------------------------------
// Fonctions pures
// ---------------------------------------------------------------------------
/// `hfRepoRe`.
pub fn repo_ok(repo: &str) -> bool {
let part = |s: &str| {
let mut c = s.chars();
c.next().is_some_and(|f| f.is_ascii_alphanumeric())
&& c.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
};
match repo.split_once('/') {
Some((a, b)) => part(a) && part(b),
None => false,
}
}
/// `maskHFToken` : assez de caractères pour reconnaître le jeton posé.
pub fn mask_hf_token(t: &str) -> String {
let t = t.trim();
if t.is_empty() {
return String::new();
}
let b = t.as_bytes();
let s = |r: std::ops::Range<usize>| String::from_utf8_lossy(&b[r]).into_owned();
if b.len() <= 8 {
return format!("…{}", s(b.len().saturating_sub(2)..b.len()));
}
format!("{}…{}", s(0..3), s(b.len() - 4..b.len()))
}
/// `hfGatedFlag` : `false`, `"auto"` ou `"manual"` — seul compte le verrou.
pub fn gated_flag(v: &Value) -> bool {
match v {
Value::Bool(b) => *b,
Value::String(s) => !matches!(
s.trim().to_lowercase().as_str(),
"" | "false" | "none" | "no"
),
_ => false,
}
}
/// `url.PathEscape`.
pub fn go_path_escape(s: &str) -> String {
let mut out = String::new();
for &c in s.as_bytes() {
if c.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&c) {
out.push(c as char);
} else {
out.push_str(&format!("%{c:02X}"));
}
}
out
}
/// `url.QueryEscape` (sur des octets : la recherche est tronquée à l'octet
/// comme dans le Go).
pub fn go_query_escape(s: &[u8]) -> String {
let mut out = String::new();
for &c in s {
if c.is_ascii_alphanumeric() || b"-_.~".contains(&c) {
out.push(c as char);
} else if c == b' ' {
out.push('+');
} else {
out.push_str(&format!("%{c:02X}"));
}
}
out
}
/// Un `.gguf` installable (`hfEntry`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HfEntry {
pub name: String,
pub url: String,
pub size: i64,
pub quant: String,
pub shards: i64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub verdict: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub why: String,
}
/// `null` quand vide, comme une tranche nil du Go.
mod nil_vec {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer, T: Serialize>(v: &[T], s: S) -> Result<S::Ok, S::Error> {
if v.is_empty() {
s.serialize_none()
} else {
v.serialize(s)
}
}
pub fn deserialize<'de, D: Deserializer<'de>, T: Deserialize<'de>>(
d: D,
) -> Result<Vec<T>, D::Error> {
Ok(Option::<Vec<T>>::deserialize(d)?.unwrap_or_default())
}
}
/// Le contenu utile d'un dépôt (`hfListing`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct HfListing {
pub repo: String,
pub gated: bool,
#[serde(with = "nil_vec")]
pub models: Vec<HfEntry>,
#[serde(with = "nil_vec")]
pub projectors: Vec<HfEntry>,
#[serde(with = "nil_vec")]
pub drafts: Vec<HfEntry>,
}
/// `hfResolveURL` : chaque segment échappé à part.
pub fn resolve_url(base: &str, repo: &str, file_path: &str) -> String {
let segs: Vec<String> = file_path.split('/').map(go_path_escape).collect();
format!("{base}/{repo}/resolve/main/{}", segs.join("/"))
}
/// `hfClassify` : modèles, projecteurs (`mmproj*`) et poids MTP (`mtp-*`),
/// une famille de tranches comptée en entier, triés par taille.
pub fn classify(base: &str, repo: &str, files: &[(String, i64)]) -> HfListing {
let mut size: HashMap<&str, i64> = HashMap::new();
let mut paths = Vec::new();
for (p, n) in files {
if !p.to_lowercase().ends_with(".gguf") {
continue;
}
size.insert(p, *n);
paths.push(p.as_str());
}
let mut out = HfListing {
repo: repo.into(),
..Default::default()
};
for p in paths {
let name = base_name(p);
if gguf::is_follower_shard(&name) {
continue;
}
let fam = gguf::shard_family(&name);
let dir = p.rfind('/').map_or("", |i| &p[..=i]);
let total = fam
.iter()
.map(|n| size.get(format!("{dir}{n}").as_str()).copied().unwrap_or(0))
.sum();
let e = HfEntry {
url: resolve_url(base, repo, p),
size: total,
quant: quant_from_name(&name),
shards: fam.len() as i64,
name: name.clone(),
verdict: String::new(),
why: String::new(),
};
let low = name.to_lowercase();
if low.starts_with("mmproj") {
out.projectors.push(e);
} else if low.starts_with("mtp-") {
out.drafts.push(e);
} else {
out.models.push(e);
}
}
// sort.Slice n'est pas stable ; à tailles égales l'ordre du Go n'est pas
// garanti non plus.
for v in [&mut out.models, &mut out.projectors, &mut out.drafts] {
v.sort_by_key(|e| e.size);
}
out
}
/// `hfPickProjector` : le Q8_0 d'abord, sinon le plus léger.
pub fn pick_projector(list: &[HfEntry]) -> Option<&HfEntry> {
if let Some(e) = list.iter().find(|e| e.quant.eq_ignore_ascii_case("Q8_0")) {
return Some(e);
}
let mut best = list.first()?;
for e in &list[1..] {
if e.size < best.size {
best = e;
}
}
Some(best)
}
/// Une carte (`gpuBrief`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GpuBrief {
pub name: String,
pub vram_gb: f64,
}
/// La machine (`hardwareInfo`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Hardware {
pub os: String,
pub arch: String,
pub ram_gb: f64,
pub vram_gb: f64,
#[serde(with = "nil_vec")]
pub gpus: Vec<GpuBrief>,
}
/// `runtime.GOOS` / `runtime.GOARCH`.
fn go_os_arch() -> (String, String) {
let arch = match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
"x86" => "386",
a => a,
};
(std::env::consts::OS.into(), arch.into())
}
/// `totalRAMGB` (Linux : MemTotal de /proc/meminfo).
fn total_ram_gb() -> f64 {
#[cfg(target_os = "linux")]
{
let Ok(b) = std::fs::read_to_string("/proc/meminfo") else {
return 0.0;
};
for line in b.lines() {
if line.starts_with("MemTotal:") {
return line
.split_whitespace()
.nth(1)
.and_then(|v| v.parse::<f64>().ok())
.map_or(0.0, |kb| kb / (1024.0 * 1024.0));
}
}
0.0
}
#[cfg(not(target_os = "linux"))]
{
0.0
}
}
/// `detectHardware` : la VRAM vient de nvidia-smi (cache partagé des jauges).
pub async fn detect_hardware() -> Hardware {
let (os, arch) = go_os_arch();
let mut h = Hardware {
os,
arch,
ram_gb: total_ram_gb(),
vram_gb: 0.0,
gpus: Vec::new(),
};
for g in crate::sysinfo::gpu_stats_cached().await {
let gb = g.total as f64 / 1024.0;
h.vram_gb += gb;
h.gpus.push(GpuBrief {
name: g.name,
vram_gb: gb,
});
}
h
}
/// `fitVerdict` : « ok », « juste » ou « trop », et pourquoi.
pub fn fit_verdict(h: &Hardware, weights: i64, mmproj: i64, ctx_tokens: i64) -> (String, String) {
const GB: f64 = (1u64 << 30) as f64;
let w = weights as f64 / GB;
let m = mmproj as f64 / GB;
let ctx = if ctx_tokens <= 0 { 32768 } else { ctx_tokens };
let kv = w / 20.0 * (ctx as f64 / 32768.0);
let need = w + m + kv;
let (budget, place) = if h.vram_gb > 0.0 {
(h.vram_gb, "VRAM")
} else {
(h.ram_gb, "RAM")
};
if budget <= 0.0 {
return (String::new(), String::new());
}
let mut detail = format!("{w:.1} Go de poids");
if m > 0.0 {
detail += &format!(" + {m:.1} Go de projecteur");
}
detail += &format!(
" + ~{kv:.1} Go de contexte (estimation) = ~{need:.1} Go pour {budget:.1} Go de {place}"
);
if need > budget {
("trop".into(), format!("ne tient pas : {detail}"))
} else if need > budget * 0.9 {
("juste".into(), format!("ça passe de justesse : {detail}"))
} else {
("ok".into(), detail)
}
}
/// `hfSearch` : l'URL appelée (clés triées comme `url.Values.Encode`).
pub fn search_endpoint(base: &str, q: &str) -> String {
let q = q.trim().as_bytes();
let q = &q[..q.len().min(MAX_QUERY)];
format!(
"{base}/api/models?direction=-1&expand%5B%5D=gated&expand%5B%5D=downloads&expand%5B%5D=likes&filter=gguf&limit={MAX_REPOS}&search={}&sort=downloads",
go_query_escape(q)
)
}
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
/// Appels à l'API Hugging Face, avec cache (`hfGetJSON`).
pub struct HfClient {
base: String,
client: reqwest::Client,
cache: Mutex<HashMap<String, (Instant, Bytes)>>,
}
/// Ce dont un appel a besoin : jeton et version (User-Agent).
pub struct HfEnv {
pub token: String,
pub version: String,
}
#[derive(Deserialize)]
struct RawRepo {
#[serde(default)]
id: String,
#[serde(default)]
downloads: i64,
#[serde(default)]
likes: i64,
#[serde(default)]
gated: Value,
}
#[derive(Deserialize)]
struct RawFile {
#[serde(default, rename = "type")]
kind: String,
#[serde(default)]
path: String,
#[serde(default)]
size: i64,
#[serde(default)]
lfs: Option<RawLfs>,
}
#[derive(Deserialize)]
struct RawLfs {
#[serde(default)]
size: i64,
}
impl HfClient {
/// `base` : `https://huggingface.co` (un faux serveur dans les tests).
pub fn new(base: &str) -> Self {
crate::chat::engine::install_crypto();
HfClient {
base: base.trim_end_matches('/').to_string(),
client: reqwest::Client::builder()
.timeout(TIMEOUT)
.build()
.unwrap_or_default(),
cache: Mutex::new(HashMap::new()),
}
}
pub fn base(&self) -> &str {
&self.base
}
fn cache(&self) -> std::sync::MutexGuard<'_, HashMap<String, (Instant, Bytes)>> {
self.cache.lock().unwrap_or_else(|e| e.into_inner())
}
/// Un nouveau jeton : les réponses obtenues sans lui ne valent plus.
pub fn clear_cache(&self) {
self.cache().clear();
}
async fn get_json<T: serde::de::DeserializeOwned>(
&self,
env: &HfEnv,
endpoint: &str,
ttl: Duration,
) -> Result<T, String> {
let hit = self
.cache()
.get(endpoint)
.filter(|(at, _)| at.elapsed() < ttl)
.map(|(_, b)| b.clone());
if let Some(raw) = hit {
return serde_json::from_slice(&raw).map_err(|e| e.to_string());
}
let mut rb = self
.client
.get(endpoint)
.header("User-Agent", format!("loki/{}", env.version))
.header("Accept", "application/json");
if !env.token.is_empty() {
rb = rb.header("Authorization", format!("Bearer {}", env.token));
}
let resp = rb
.send()
.await
.map_err(|e| format!("Hugging Face injoignable : {e}"))?;
match resp.status().as_u16() {
200 => {}
401 | 403 => {
return Err("dépôt à accès restreint — renseigne HF_TOKEN pour y accéder".into());
}
404 => return Err("dépôt introuvable sur Hugging Face".into()),
s => return Err(format!("Hugging Face a répondu {s}")),
}
let raw = read_capped(resp, 4 << 20).await?;
self.cache()
.insert(endpoint.to_string(), (Instant::now(), raw.clone()));
serde_json::from_slice(&raw).map_err(|e| e.to_string())
}
/// `hfSearch`.
pub async fn search(&self, env: &HfEnv, q: &str) -> Result<Value, String> {
if q.trim().is_empty() {
return Err("recherche vide".into());
}
let raw: Vec<RawRepo> = self
.get_json(env, &search_endpoint(&self.base, q), SEARCH_TTL)
.await?;
Ok(Value::Array(
raw.into_iter()
.map(|r| {
json!({ "id": r.id, "downloads": r.downloads, "likes": r.likes, "gated": gated_flag(&r.gated) })
})
.collect(),
))
}
/// `hfFiles`.
pub async fn files(&self, env: &HfEnv, repo: &str) -> Result<HfListing, String> {
let repo = repo.trim().trim_matches('/');
if !repo_ok(repo) {
return Err("nom de dépôt invalide (attendu « auteur/dépôt »)".into());
}
let endpoint = format!("{}/api/models/{repo}/tree/main?recursive=1", self.base);
let raw: Vec<RawFile> = self.get_json(env, &endpoint, FILES_TTL).await?;
let files: Vec<(String, i64)> = raw
.into_iter()
.filter(|f| f.kind != "directory")
.map(|f| {
let n = f.lfs.map_or(0, |l| l.size);
(f.path, if n == 0 { f.size } else { n })
})
.collect();
let mut out = classify(&self.base, repo, &files);
out.gated = self.repo_gated(env, repo).await;
Ok(out)
}
/// `hfRepoGated` : une erreur ne casse rien (pas d'avertissement, c'est
/// tout).
async fn repo_gated(&self, env: &HfEnv, repo: &str) -> bool {
#[derive(Deserialize)]
struct Info {
#[serde(default)]
gated: Value,
}
let endpoint = format!("{}/api/models/{repo}?expand%5B%5D=gated", self.base);
self.get_json::<Info>(env, &endpoint, FILES_TTL)
.await
.is_ok_and(|i| gated_flag(&i.gated))
}
/// `hfWhoAmI` : le compte du jeton, ou pourquoi il est refusé.
pub async fn whoami(&self, version: &str, tok: &str) -> Result<String, String> {
let resp = self
.client
.get(format!("{}/api/whoami-v2", self.base))
.header("Authorization", format!("Bearer {}", tok.trim()))
.header("User-Agent", format!("loki/{version}"))
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Hugging Face injoignable : {e}"))?;
match resp.status().as_u16() {
200 => {}
401 | 403 => {
return Err("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)".into());
}
s => return Err(format!("Hugging Face a répondu {s}")),
}
#[derive(Deserialize)]
struct Who {
#[serde(default)]
name: String,
}
let raw = read_capped(resp, 1 << 20).await?;
serde_json::from_slice::<Who>(&raw)
.map(|w| w.name)
.map_err(|e| e.to_string())
}
}
/// Lit au plus `max` octets du corps (réponse d'un tiers).
async fn read_capped(mut resp: reqwest::Response, max: usize) -> Result<Bytes, String> {
let mut buf = Vec::new();
while let Some(c) = resp.chunk().await.map_err(|e| e.to_string())? {
let room = max - buf.len();
buf.extend_from_slice(&c[..c.len().min(room)]);
if buf.len() >= max {
break;
}
}
Ok(Bytes::from(buf))
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
/// `hfTokenSource` : « config », « env » ou "".
async fn token_source(ctx: &Ctx) -> (&'static str, String) {
let stored = ctx.store.get_str(BK_STATE, "hf_token").await;
if !stored.trim().is_empty() {
return ("config", stored.trim().to_string());
}
let env = std::env::var("HF_TOKEN").unwrap_or_default();
if !env.trim().is_empty() {
return ("env", env.trim().to_string());
}
("", String::new())
}
fn token_state(src: &str, tok: &str, account: &str) -> Value {
json!({ "ok": true, "set": !src.is_empty(), "source": src, "masked": mask_hf_token(tok), "account": account })
}
fn query_param(uri: &hyper::Uri, key: &str) -> String {
let q = uri.query().unwrap_or("");
for kv in q.split('&') {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
if form_decode(k) == key {
return form_decode(v);
}
}
String::new()
}
/// Décodage `application/x-www-form-urlencoded` (`+` → espace, `%XX`).
fn form_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < b.len() => {
match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
{
Ok(v) => {
out.push(v);
i += 3;
continue;
}
Err(_) => out.push(b'%'),
}
}
c => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub async fn serve(
ctx: &Ctx,
hf: &HfClient,
req: Request<Incoming>,
from_go: bool,
) -> Response<Body> {
let (parts, body) = req.into_parts();
let version = if from_go {
ctx.internal
.auth()
.await
.map(|i| i.version)
.unwrap_or_default()
} else {
match ctx.guard(&parts.headers).await {
Ok(v) => v,
Err(refus) => return *refus,
}
};
let (src, token) = token_source(ctx).await;
let env = HfEnv {
token: token.clone(),
version: version.clone(),
};
let err = |code: StatusCode, e: String| send_json(code, &json!({ "ok": false, "error": e }));
match parts.uri.path() {
"/api/hf/search" => {
let q = query_param(&parts.uri, "q");
if q.trim().is_empty() {
return err(StatusCode::BAD_REQUEST, "précise ce que tu cherches".into());
}
match hf.search(&env, &q).await {
Ok(repos) => send_json(
StatusCode::OK,
&json!({ "ok": true, "hardware": detect_hardware().await, "repos": repos, "hf_token": !token.is_empty() }),
),
Err(e) => err(StatusCode::BAD_GATEWAY, e),
}
}
"/api/hf/files" => {
let repo = query_param(&parts.uri, "repo");
if repo.trim().is_empty() {
return err(StatusCode::BAD_REQUEST, "dépôt manquant".into());
}
let mut list = match hf.files(&env, &repo).await {
Ok(l) => l,
Err(e) => return err(StatusCode::BAD_GATEWAY, e),
};
let hw = detect_hardware().await;
// Contexte du preset actif, défaut de serve.
let ctx_tokens = ctx
.store
.config()
.await
.get("CTX")
.and_then(|v| v.trim().parse::<i64>().ok())
.filter(|v| *v > 0)
.unwrap_or(32768);
let mm = pick_projector(&list.projectors).map_or(0, |p| p.size);
for m in &mut list.models {
(m.verdict, m.why) = fit_verdict(&hw, m.size, mm, ctx_tokens);
}
let vision = !list.projectors.is_empty();
let nil = |v: &Vec<HfEntry>| {
if v.is_empty() { Value::Null } else { json!(v) }
};
send_json(
StatusCode::OK,
&json!({
"ok": true, "hardware": hw, "ctx": ctx_tokens,
"repo": list.repo, "models": nil(&list.models),
"projectors": nil(&list.projectors), "drafts": nil(&list.drafts),
"vision": vision, "gated": list.gated, "hf_token": !token.is_empty(),
}),
)
}
"/api/hf/token" if parts.method == Method::POST => {
let v = match crate::models::body_json(body).await {
Ok(v) => v,
Err(_) => return err(StatusCode::BAD_REQUEST, "requête illisible".into()),
};
let tok = v
.get("token")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
let mut account = String::new();
if !tok.is_empty() {
match hf.whoami(&version, &tok).await {
Ok(a) => account = a,
Err(e) => return err(StatusCode::BAD_REQUEST, e),
}
}
// putStr : une valeur vide efface la clé.
let res = if tok.is_empty() {
ctx.store.del(BK_STATE, "hf_token").await
} else {
ctx.store
.put(BK_STATE, "hf_token", tok.clone().into_bytes())
.await
};
if let Err(e) = res {
return err(StatusCode::INTERNAL_SERVER_ERROR, e);
}
hf.clear_cache();
let (src, token) = token_source(ctx).await;
send_json(StatusCode::OK, &token_state(src, &token, &account))
}
"/api/hf/token" => send_json(StatusCode::OK, &token_state(src, &token, "")),
_ => send_json(
StatusCode::NOT_FOUND,
&json!({ "error": "404 page not found" }),
),
}
}
+2
View File
@@ -19,6 +19,8 @@ pub mod downloads;
pub mod engine;
pub mod engstatus;
pub mod gguf;
pub mod hf;
pub mod models;
pub mod oai;
pub mod paths;
pub mod presets;
+16 -16
View File
@@ -118,17 +118,17 @@ fn serve() -> ExitCode {
// sait (il remet alors à la façade le trafic de chat du tunnel) et
// connaît son adresse locale.
let chat_rust = chat_held_by_facade();
// LOKI_DOWNLOADS=rust : les téléchargements de modèles aussi (le Go
// remet alors leurs routes du tunnel et ne vide plus les .part).
let downloads_rust = downloads_held_by_facade();
// LOKI_MODELS=rust : les routes des modèles aussi (le Go remet alors
// celles du tunnel et ne vide plus les .part).
let models_rust = models_held_by_facade();
let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())];
if chat_rust {
env.push(("LOKI_CHAT".into(), "rust".into()));
}
if downloads_rust {
env.push(("LOKI_DOWNLOADS".into(), "rust".into()));
if models_rust {
env.push(("LOKI_MODELS".into(), "rust".into()));
}
if chat_rust || downloads_rust {
if chat_rust || models_rust {
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
}
let sup = child::Supervisor::spawn(go_bin, go_port, env);
@@ -161,8 +161,8 @@ fn serve() -> ExitCode {
} else {
None
};
let downloads = if downloads_rust {
Some(start_downloads(backend, &token, &engine_paths.home).await)
let models = if models_rust {
Some(start_models(backend, &token, &engine_paths.home).await)
} else {
None
};
@@ -183,7 +183,7 @@ fn serve() -> ExitCode {
host_env: auth::HostEnv::from_env(),
engine: engine_paths,
chat,
downloads,
models,
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
@@ -203,18 +203,18 @@ fn chat_held_by_facade() -> bool {
std::env::var("LOKI_CHAT").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
}
/// `LOKI_DOWNLOADS=rust` : téléchargements de modèles tenus par la façade.
fn downloads_held_by_facade() -> bool {
std::env::var("LOKI_DOWNLOADS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
/// `LOKI_MODELS=rust` : routes des modèles tenues par la façade.
fn models_held_by_facade() -> bool {
std::env::var("LOKI_MODELS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
}
/// Les `.part` d'un téléchargement tué ne reprendront jamais : vidés au
/// démarrage (le Go ne le fait plus quand la façade les tient).
async fn start_downloads(
async fn start_models(
backend: SocketAddr,
token: &str,
home: &std::path::Path,
) -> std::sync::Arc<loki_rs::downloads::Downloads> {
) -> std::sync::Arc<loki_rs::models::Models> {
use loki_rs::{downloads, gguf, store};
let st = store::Store::new(backend, token.to_string());
let stored = gguf::parse_stored_model_dirs(
@@ -230,8 +230,8 @@ async fn start_downloads(
std::env::var(gguf::MODEL_DIRS_ENV).ok().as_deref(),
);
let _ = tokio::task::spawn_blocking(move || downloads::clean_stale_part_files(&dirs)).await;
eprintln!("[loki-rs] téléchargements de modèles tenus par la façade (LOKI_DOWNLOADS=rust)");
std::sync::Arc::new(downloads::Downloads::new())
eprintln!("[loki-rs] modèles tenus par la façade (LOKI_MODELS=rust)");
std::sync::Arc::new(loki_rs::models::Models::new())
}
/// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer)
+591
View File
@@ -0,0 +1,591 @@
//! Les routes des modèles tenues par la façade quand `LOKI_MODELS=rust` (lot
//! 8 de la migration ; le Go par défaut) :
//!
//! - `GET /api/models` : les `.gguf` du sélecteur, avec qui les référence
//! (`handleModels`, `backend_models_refs.go`) ;
//! - `POST /api/models/delete` : suppression, garde-fous compris
//! (`handleModelDelete`, `deleteModelFile`) ;
//! - `GET|POST /api/models/dirs` : dossiers de modèles (`handleModelDirs`) ;
//! - les téléchargements ([`crate::downloads`]) ;
//! - le catalogue Hugging Face ([`crate::hf`]).
//!
//! Le Go remet ces routes à la façade quand elles lui arrivent par le tunnel
//! du relais (`facade_models.go`).
//!
//! Écart assumé : le message d'un corps JSON illisible est celui de serde,
//! pas celui d'`encoding/json` (l'interface n'en envoie jamais).
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use http_body_util::{BodyExt, Limited};
use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Map, Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::downloads::{self, Downloads, ModelDirs, disk_free, disk_free_reliable};
use crate::gguf;
use crate::hf;
use crate::paths::{base_name, go_abs, go_dir, go_join};
use crate::presets;
use crate::proxy::Body;
use crate::store::BK_STATE;
/// Ce que la façade tient quand `LOKI_MODELS=rust`.
pub struct Models {
pub downloads: Arc<Downloads>,
pub hf: hf::HfClient,
}
impl Default for Models {
fn default() -> Self {
Self::new()
}
}
impl Models {
pub fn new() -> Self {
Models {
downloads: Arc::new(Downloads::new()),
hf: hf::HfClient::new(hf::HF_HOST),
}
}
}
/// Routes servies ici quand la façade tient les modèles.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/models" | "/api/models/delete" | "/api/models/dirs"
) || downloads::handles(path)
|| hf::handles(path)
}
// ---------------------------------------------------------------------------
// Fonctions pures
// ---------------------------------------------------------------------------
/// `quantFromName` : l'étiquette de quantification la plus longue d'un nom
/// (segments séparés par `-` et `.`), en majuscules ; "" sinon.
pub fn quant_from_name(name: &str) -> String {
let mut base = name;
if let Some(dot) = base.rfind('.')
&& base[dot..].eq_ignore_ascii_case(".gguf")
{
base = &base[..dot];
}
let mut best = "";
for seg in base.split(['-', '.']).filter(|s| !s.is_empty()) {
if is_quant_seg(seg) && seg.len() > best.len() {
best = seg;
}
}
best.to_uppercase()
}
/// `quantSegRe` : `(?i)^(I?Q\d+(_[A-Za-z0-9]+)*|\d+BPW|BF16|FP16|F16|FP32|F32)$`.
fn is_quant_seg(seg: &str) -> bool {
let u = seg.to_ascii_uppercase();
if matches!(u.as_str(), "BF16" | "FP16" | "F16" | "FP32" | "F32") {
return true;
}
if let Some(d) = u.strip_suffix("BPW") {
return !d.is_empty() && d.bytes().all(|c| c.is_ascii_digit());
}
let rest = u.strip_prefix("IQ").or_else(|| u.strip_prefix('Q'));
let Some(rest) = rest else {
return false;
};
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
if digits == 0 {
return false;
}
let tail = &rest[digits..];
if tail.is_empty() {
return true;
}
// (_[A-Za-z0-9]+)*
tail.strip_prefix('_').is_some_and(|t| {
t.split('_')
.all(|p| !p.is_empty() && p.bytes().all(|c| c.is_ascii_alphanumeric()))
})
}
/// `splitArgs` : découpe EXTRA_ARGS comme un shell simple (guillemets " et ').
pub fn split_args(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for r in s.chars() {
match quote {
Some(q) => {
if r == q {
quote = None;
// Guillemets vides : argument vide explicite.
if cur.is_empty() {
out.push(String::new());
}
} else {
cur.push(r);
}
}
None if r == '"' || r == '\'' => quote = Some(r),
None if matches!(r, ' ' | '\t' | '\n' | '\r') => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
None => cur.push(r),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
/// `os.Stat` sans erreur et pas un dossier.
fn present(p: &str) -> bool {
std::fs::metadata(p).is_ok_and(|m| !m.is_dir())
}
/// `shardFamilySize` : les tranches présentes dans `dir`.
fn family_size(dir: &str, name: &str) -> i64 {
gguf::shard_family(name)
.iter()
.filter_map(|n| std::fs::metadata(go_join(&[dir, n])).ok())
.filter(|m| !m.is_dir())
.map(|m| m.len() as i64)
.sum()
}
/// `shardFamilyMissing` : les tranches absentes de `dir`.
fn family_missing(dir: &str, name: &str) -> Vec<String> {
gguf::shard_family(name)
.into_iter()
.filter(|n| !present(&go_join(&[dir, n])))
.collect()
}
// ---------------------------------------------------------------------------
// Qui référence un .gguf (backend_models_refs.go)
// ---------------------------------------------------------------------------
/// `modelRefIndex` : chemins normalisés → presets qui les nomment, et ceux
/// de la configuration en service.
#[derive(Debug, Default)]
pub struct ModelRefs {
active: HashSet<String>,
presets: HashMap<String, Vec<String>>,
}
impl ModelDirs {
/// `modelRefKey` : valeur MODEL=/MMPROJ= → clé d'index ("" : pas un .gguf).
fn ref_key(&self, v: &str) -> String {
let v = v.trim();
if v.is_empty() {
return String::new();
}
let p = gguf::resolve_serve_model_path(v, &self.home, &self.stored, self.env.as_deref())
.map(|p| p.to_string_lossy().into_owned())
// Introuvable : une clé comparable quand même.
.unwrap_or_else(|_| go_join(&[&self.models_dir(), &base_name(v)]));
gguf::norm_dir(&p)
}
/// `modelKeysOf` : modèle, projecteur, brouillon, `--mmproj` d'EXTRA_ARGS.
fn keys_of(&self, env: &HashMap<String, String>) -> Vec<String> {
let get = |k: &str| env.get(k).cloned().unwrap_or_default();
let mut vals = vec![get("MODEL"), get("MMPROJ"), get("MODEL_DRAFT")];
let args = split_args(&get("EXTRA_ARGS"));
for (i, a) in args.iter().enumerate() {
if a == "--mmproj" && i + 1 < args.len() {
vals.push(args[i + 1].clone());
}
}
vals.iter()
.map(|v| self.ref_key(v))
.filter(|k| !k.is_empty())
.collect()
}
}
impl ModelRefs {
/// `buildModelRefIndex` : la configuration active et les presets
/// (`(nom d'affichage, contenu)`, dans l'ordre de la liste).
pub fn build(
dirs: &ModelDirs,
cfg: &HashMap<String, String>,
presets: &[(String, String)],
) -> Self {
let mut ix = ModelRefs::default();
ix.active.extend(dirs.keys_of(cfg));
for (name, content) in presets {
let mut seen = HashSet::new();
for k in dirs.keys_of(&presets::parse_env(content)) {
if seen.insert(k.clone()) {
ix.presets.entry(k).or_default().push(name.clone());
}
}
}
ix
}
/// `lookup` : presets qui référencent `path`, et s'il est en service.
pub fn lookup(&self, path: &str) -> (Vec<String>, bool) {
let k = gguf::norm_dir(path);
(
self.presets.get(&k).cloned().unwrap_or_default(),
self.active.contains(&k),
)
}
/// `modelUsers` : pour UN modèle (nom de fichier ou chemin).
fn users(&self, dirs: &ModelDirs, name: &str) -> (Vec<String>, bool) {
let k = dirs.ref_key(name);
if k.is_empty() {
return (Vec::new(), false);
}
self.lookup(&k)
}
}
const MODEL_IN_SERVICE: &str =
"modèle en service — bascule sur un autre preset avant de le supprimer";
/// `handleModels` : les `.gguf` des dossiers de modèles (tranches 2..N
/// exclues), avec leurs références.
pub fn list_models(dirs: &ModelDirs, refs: &ModelRefs) -> Value {
let home = dirs.models_dir();
let mut out = Vec::new();
let mut seen = HashSet::new();
for dir in dirs.all() {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
// os.ReadDir : trié par nom.
let mut entries: Vec<_> = rd.filter_map(Result::ok).collect();
entries.sort_by_key(|e| e.file_name());
let is_home = gguf::norm_dir(&dir) == gguf::norm_dir(&home);
for e in entries {
let name = e.file_name().to_string_lossy().into_owned();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir || !name.to_lowercase().ends_with(".gguf") || gguf::is_follower_shard(&name) {
continue;
}
let full = go_join(&[&dir, &name]);
if !seen.insert(gguf::norm_dir(&full)) {
continue;
}
let size = e.metadata().map(|m| m.len() as i64).unwrap_or(0);
let value = if is_home { name.clone() } else { full.clone() };
let mut m = Map::new();
m.insert("name".into(), json!(name));
m.insert("size".into(), json!(size));
m.insert("value".into(), json!(value));
m.insert("path".into(), json!(full));
m.insert("dir".into(), json!(dir));
m.insert("home".into(), json!(is_home));
let (users, active) = refs.lookup(&full);
if !users.is_empty() || active {
if !users.is_empty() {
m.insert("used".into(), json!(users));
}
m.insert("active".into(), json!(active));
}
if let Some((_, total)) = gguf::shard_info(&name) {
m.insert("shards".into(), json!(total));
m.insert("size".into(), json!(family_size(&dir, &name)));
let missing = family_missing(&dir, &name);
if !missing.is_empty() {
m.insert("missing".into(), json!(missing));
}
}
out.push(Value::Object(m));
}
}
Value::Array(out)
}
/// `modelDirCount` : les `.gguf` lisibles d'un dossier (-1 : illisible).
pub fn dir_count(dir: &str) -> i64 {
let Ok(rd) = std::fs::read_dir(dir) else {
return -1;
};
rd.filter_map(Result::ok)
.filter(|e| {
let n = e.file_name().to_string_lossy().into_owned();
!e.file_type().map(|t| t.is_dir()).unwrap_or(false)
&& n.to_lowercase().ends_with(".gguf")
&& !gguf::is_follower_shard(&n)
})
.count() as i64
}
/// `GET /api/models/dirs`.
pub fn dirs_json(dirs: &ModelDirs) -> Value {
let home = dirs.models_dir();
let mut out = vec![json!({
"path": home, "home": true, "count": dir_count(&home), "exists": true,
"free": disk_free(&home), "free_exact": disk_free_reliable(&home),
})];
for d in gguf::extra_model_dirs(&dirs.home, &dirs.stored, dirs.env.as_deref()) {
let n = dir_count(&d);
out.push(json!({
"path": d, "home": false, "count": n, "exists": n >= 0,
"free": disk_free(&d), "free_exact": disk_free_reliable(&d),
}));
}
json!({ "ok": true, "dirs": out, "env": dirs.env.as_deref().is_some_and(|v| !v.is_empty()) })
}
/// `POST /api/models/dirs` : la liste à enregistrer, ou le refus.
pub fn dirs_update(dirs: &ModelDirs, path: &str, action: &str) -> Result<Vec<String>, String> {
let p = path.trim();
if p.is_empty() {
return Err("chemin vide".into());
}
let mut cur = gguf::extra_model_dirs(&dirs.home, &dirs.stored, dirs.env.as_deref());
if action == "remove" {
cur.retain(|d| gguf::norm_dir(d) != gguf::norm_dir(p));
} else {
let abs = go_abs(p).ok_or_else(|| "chemin invalide".to_string())?;
if !std::fs::metadata(&abs).is_ok_and(|m| m.is_dir()) {
return Err(format!("dossier introuvable : {abs}"));
}
cur.push(abs);
}
// saveExtraModelDirs : nettoyée, dédoublonnée, models/ exclu.
Ok(gguf::dedup_dirs(&cur, &dirs.models_dir()))
}
/// `handleModelDelete` : refus si en service, 409 avec la liste des presets
/// sans `force`, sinon le modèle part avec toutes ses tranches.
pub fn delete_model(
dirs: &ModelDirs,
refs: &ModelRefs,
name: &str,
force: bool,
) -> (StatusCode, Value) {
let (users, active) = refs.users(dirs, name);
if active {
return (
StatusCode::CONFLICT,
json!({ "ok": false, "error": MODEL_IN_SERVICE, "active": true }),
);
}
if !users.is_empty() && !force {
return (
StatusCode::CONFLICT,
json!({ "ok": false, "used": users, "error": format!("modèle utilisé par : {}", users.join(", ")) }),
);
}
match delete_model_file(dirs, refs, name) {
Ok(freed) => {
let used = if users.is_empty() {
Value::Null
} else {
json!(users)
};
(
StatusCode::OK,
json!({ "ok": true, "freed": freed, "used": used }),
)
}
Err(e) => (StatusCode::BAD_REQUEST, json!({ "ok": false, "error": e })),
}
}
/// `deleteModelFile` : rend les octets libérés.
fn delete_model_file(dirs: &ModelDirs, refs: &ModelRefs, name: &str) -> Result<i64, String> {
let p = gguf::resolve_model_path(name, &dirs.home, &dirs.stored, dirs.env.as_deref())?
.to_string_lossy()
.into_owned();
if refs.users(dirs, name).1 {
return Err(MODEL_IN_SERVICE.into());
}
let dir = go_dir(&p);
let base = base_name(&p);
let freed = family_size(&dir, &base);
if let Err(e) = std::fs::remove_file(&p) {
if e.kind() == std::io::ErrorKind::NotFound {
return Err(format!("modèle introuvable: {base}"));
}
return Err(crate::paths::os_err("remove", &p, &e));
}
for n in gguf::shard_family(&base) {
let _ = std::fs::remove_file(Path::new(&dir).join(n));
}
Ok(freed)
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
/// Les dossiers de modèles du moment, lus en base.
pub async fn model_dirs(ctx: &Ctx) -> ModelDirs {
let stored = gguf::parse_stored_model_dirs(
ctx.store
.get(BK_STATE, gguf::STATE_MODEL_DIRS_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
ModelDirs {
home: ctx.engine.home.clone(),
stored,
env: std::env::var(gguf::MODEL_DIRS_ENV).ok(),
}
}
/// La configuration en service et les presets (nom, contenu), dans l'ordre
/// de la liste.
async fn refs_inputs(ctx: &Ctx) -> (HashMap<String, String>, Vec<String>) {
let cfg = ctx.store.config().await;
let order = presets::parse_preset_order(
ctx.store
.get(BK_STATE, presets::PRESET_ORDER_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
(cfg, order)
}
/// L'index des références : configuration et presets lus sur le disque.
pub fn build_refs(dirs: &ModelDirs, cfg: &HashMap<String, String>, order: &[String]) -> ModelRefs {
let list = presets::list_presets(&dirs.home, cfg, order).unwrap_or_default();
let presets: Vec<(String, String)> = list
.into_iter()
.filter_map(|p| {
std::fs::read(&p.path)
.ok()
.map(|b| (p.name, String::from_utf8_lossy(&b).into_owned()))
})
.collect();
ModelRefs::build(dirs, cfg, &presets)
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
tokio::task::spawn_blocking(f)
.await
.expect("tâche bloquante interrompue")
}
pub(crate) async fn body_json(body: Incoming) -> Result<Value, String> {
let b = Limited::new(body, 1 << 20)
.collect()
.await
.map_err(|e| e.to_string())?
.to_bytes();
if b.iter().all(u8::is_ascii_whitespace) {
return Err("EOF".into());
}
serde_json::from_slice(&b).map_err(|e| e.to_string())
}
fn bad(e: impl Into<String>) -> Response<Body> {
send_json(
StatusCode::BAD_REQUEST,
&json!({ "ok": false, "error": e.into() }),
)
}
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(
ctx: &Ctx,
models: &Arc<Models>,
req: Request<Incoming>,
from_go: bool,
) -> Response<Body> {
let path = req.uri().path().to_string();
if downloads::handles(&path) {
return downloads::serve(ctx, &models.downloads, req, from_go).await;
}
if hf::handles(&path) {
return hf::serve(ctx, &models.hf, req, from_go).await;
}
let (parts, body) = req.into_parts();
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
return *refus;
}
let dirs = model_dirs(ctx).await;
match path.as_str() {
"/api/models" => {
let (cfg, order) = refs_inputs(ctx).await;
let out = blocking(move || {
let refs = build_refs(&dirs, &cfg, &order);
list_models(&dirs, &refs)
})
.await;
send_json(StatusCode::OK, &out)
}
"/api/models/delete" => {
let v = match body_json(body).await {
Ok(v) => v,
Err(e) => return bad(e),
};
let name = v
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let force = v.get("force").and_then(Value::as_bool).unwrap_or(false);
let (cfg, order) = refs_inputs(ctx).await;
let (code, out) = blocking(move || {
let refs = build_refs(&dirs, &cfg, &order);
delete_model(&dirs, &refs, &name, force)
})
.await;
send_json(code, &out)
}
"/api/models/dirs" if parts.method == Method::POST => {
let v = match body_json(body).await {
Ok(v) => v,
Err(e) => return bad(e),
};
let p = v
.get("path")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let action = v
.get("action")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let res = blocking(move || dirs_update(&dirs, &p, &action)).await;
match res {
Ok(list) => {
let raw = serde_json::to_vec(&list).unwrap_or_default();
match ctx
.store
.put(BK_STATE, gguf::STATE_MODEL_DIRS_KEY, raw)
.await
{
Ok(()) => send_json(StatusCode::OK, &json!({ "ok": true })),
Err(e) => send_json(
StatusCode::INTERNAL_SERVER_ERROR,
&json!({ "ok": false, "error": e }),
),
}
}
Err(e) => bad(e),
}
}
"/api/models/dirs" => {
let out = blocking(move || dirs_json(&dirs)).await;
send_json(StatusCode::OK, &out)
}
_ => send_json(
StatusCode::NOT_FOUND,
&json!({ "error": "404 page not found" }),
),
}
}
+10 -10
View File
@@ -63,9 +63,9 @@ pub struct Config {
/// Conversation tenue par la façade (`LOKI_CHAT=rust`) : ses routes sont
/// servies ici ; `None` : relayées au Go.
pub chat: Option<Arc<crate::chat::generate::ChatService>>,
/// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) :
/// leurs routes sont servies ici ; `None` : relayées au Go.
pub downloads: Option<Arc<crate::downloads::Downloads>>,
/// Routes des modèles tenues par la façade (`LOKI_MODELS=rust`) : servies
/// ici ; `None` : relayées au Go.
pub models: Option<Arc<crate::models::Models>>,
}
struct Proxy {
@@ -93,7 +93,7 @@ pub async fn serve(
host_env: cfg.host_env,
engine: cfg.engine,
chat: cfg.chat,
downloads: cfg.downloads,
models: cfg.models,
internal_token: cfg.internal_token.clone(),
},
});
@@ -133,9 +133,9 @@ pub async fn serve(
impl Proxy {
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
// Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la
// façade le trafic de chat et de téléchargements qui lui arrive sans
// passer par elle.
let from_go = (self.ctx.chat.is_some() || self.ctx.downloads.is_some())
// façade le trafic du chat et des modèles qui lui arrive sans passer
// par elle.
let from_go = (self.ctx.chat.is_some() || self.ctx.models.is_some())
&& self.ctx.from_go(req.headers());
// Jamais de confiance accordée sur la foi d'un en-tête du client :
// ni « je viens du tunnel », ni « je suis la façade ».
@@ -169,10 +169,10 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if let Some(dl) = &self.ctx.downloads
&& crate::downloads::handles(path)
if let Some(m) = &self.ctx.models
&& crate::models::handles(path)
{
return crate::downloads::serve(&self.ctx, dl, req, from_go).await;
return crate::models::serve(&self.ctx, m, req, from_go).await;
}
self.forward(req).await
}
+14 -8
View File
@@ -427,16 +427,17 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
/// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade,
/// état du faux Go, garde d'arrêt).
pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let (f, st, tx, _) = start_opts(home, false, None, false).await;
let (f, st, tx, _) = start_opts(home, false, None, None).await;
(f, st, tx)
}
/// Comme [`start`], avec les téléchargements de modèles tenus par la façade
/// (`LOKI_DOWNLOADS=rust`).
pub async fn start_downloads(
/// Comme [`start`], avec les routes des modèles tenues par la façade
/// (`LOKI_MODELS=rust`) ; `hf` : adresse du faux Hugging Face.
pub async fn start_models(
home: std::path::PathBuf,
hf: &str,
) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let (f, st, tx, _) = start_opts(home, false, None, true).await;
let (f, st, tx, _) = start_opts(home, false, None, Some(hf.to_string())).await;
(f, st, tx)
}
@@ -451,7 +452,7 @@ pub async fn start_chat(
oneshot::Sender<()>,
Arc<loki_rs::chat::generate::ChatService>,
) {
let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), false).await;
let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), None).await;
(f, st, tx, chat.unwrap())
}
@@ -459,7 +460,7 @@ async fn start_opts(
home: std::path::PathBuf,
with_chat: bool,
engine_port: Option<u16>,
with_downloads: bool,
models_hf: Option<String>,
) -> (
SocketAddr,
Arc<FakeGo>,
@@ -516,7 +517,12 @@ async fn start_opts(
service: "loki-engine".into(),
},
chat: chat.clone(),
downloads: with_downloads.then(|| Arc::new(loki_rs::downloads::Downloads::new())),
models: models_hf.map(|hf| {
Arc::new(loki_rs::models::Models {
downloads: Arc::new(loki_rs::downloads::Downloads::new()),
hf: loki_rs::hf::HfClient::new(&hf),
})
}),
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+2 -2
View File
@@ -1,4 +1,4 @@
//! Lot « modèles » : téléchargements tenus par la façade (`LOKI_DOWNLOADS=rust`).
//! Lot 8 : téléchargements de modèles tenus par la façade (`LOKI_MODELS=rust`).
//!
//! - les fonctions pures rejouent le journal tiré du Go
//! (`internal/loki/parity_dl_test.go` → `tests/parity_models/download.json`) ;
@@ -572,7 +572,7 @@ async fn routes_servies_par_la_facade() {
);
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
let (facade, go, _stop) = fakego::start_downloads(home.clone()).await;
let (facade, go, _stop) = fakego::start_models(home.clone(), "http://127.0.0.1:1").await;
let url = format!("http://{addr}/u/r/resolve/main/m.gguf");
let body = format!(r#"{{"url":"{url}","dir":""}}"#);
+1 -1
View File
@@ -94,7 +94,7 @@ async fn banc(cle: Option<&str>) -> Banc {
service: "loki-engine".into(),
},
chat: None,
downloads: None,
models: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
+557
View File
@@ -0,0 +1,557 @@
//! Lot 8 : routes des modèles tenues par la façade (`LOKI_MODELS=rust`).
//!
//! - le journal tiré du Go (`internal/loki/parity_models_test.go` →
//! `tests/parity_models/models.json`) : fonctions pures du catalogue, puis
//! un scénario sur disque (liste, dossiers, suppressions) rejoué à
//! l'identique ;
//! - le catalogue Hugging Face contre un faux serveur (recherche, dépôt,
//! jeton, cache) ;
//! - les routes par la façade.
mod common;
use std::collections::HashMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{Arc, Mutex};
use bytes::Bytes;
use common::fakego;
use http_body_util::Full;
use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode, service::service_fn};
use hyper_util::rt::TokioIo;
use loki_rs::downloads::ModelDirs;
use loki_rs::hf::{self, Hardware, HfEntry, HfEnv, HfListing};
use loki_rs::models;
use serde_json::Value;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_models/models.json")).expect("journal")
}
fn mask_free(v: &mut Value) {
match v {
Value::Object(m) => {
for (k, e) in m.iter_mut() {
if k == "free" || k == "free_exact" {
*e = Value::String("…".into());
} else {
mask_free(e);
}
}
}
Value::Array(a) => a.iter_mut().for_each(mask_free),
_ => {}
}
}
#[test]
fn parite_des_fonctions_pures() {
let fx = fixture();
for (n, want) in fx["quant"].as_object().unwrap() {
assert_eq!(
models::quant_from_name(n),
want.as_str().unwrap(),
"quant({n})"
);
}
for c in fx["split_args"].as_array().unwrap() {
let want: Vec<String> = serde_json::from_value(c["out"].clone()).unwrap();
assert_eq!(models::split_args(c["in"].as_str().unwrap()), want, "{c}");
}
for (t, want) in fx["mask"].as_object().unwrap() {
assert_eq!(hf::mask_hf_token(t), want.as_str().unwrap(), "mask({t:?})");
}
for c in fx["gated"].as_array().unwrap() {
assert_eq!(hf::gated_flag(&c["in"]), c["out"].as_bool().unwrap(), "{c}");
}
let cl = &fx["classify"];
let files: Vec<(String, i64)> = cl["files"]
.as_array()
.unwrap()
.iter()
.map(|f| {
(
f["path"].as_str().unwrap().to_string(),
f["size"].as_i64().unwrap(),
)
})
.collect();
let got = hf::classify(hf::HF_HOST, cl["repo"].as_str().unwrap(), &files);
let want: HfListing = serde_json::from_value(cl["out"].clone()).unwrap();
assert_eq!(got, want);
// Sérialisé comme le Go (listes vides → null).
assert_eq!(serde_json::to_value(&got).unwrap(), cl["out"]);
for c in fx["projector"].as_array().unwrap() {
let list: Vec<HfEntry> = serde_json::from_value(c["in"].clone()).unwrap_or_default();
let got = hf::pick_projector(&list)
.map(|e| e.name.clone())
.unwrap_or_default();
assert_eq!(got, c["name"].as_str().unwrap(), "{c}");
}
for c in fx["verdicts"].as_array().unwrap() {
let hw: Hardware = serde_json::from_value(c["hardware"].clone()).unwrap();
// Sans GPU : null, comme une tranche nil du Go (24.0 contre 24 : même
// nombre en JSON).
let gpus = &serde_json::to_value(&hw).unwrap()["gpus"];
assert_eq!(gpus.is_null(), c["hardware"]["gpus"].is_null());
let (v, why) = hf::fit_verdict(
&hw,
c["weights"].as_i64().unwrap(),
c["mmproj"].as_i64().unwrap(),
c["ctx"].as_i64().unwrap(),
);
assert_eq!(
(v.as_str(), why.as_str()),
(c["verdict"].as_str().unwrap(), c["why"].as_str().unwrap())
);
}
}
fn tree_files(home: &Path) -> Vec<String> {
let mut out = Vec::new();
for top in ["disque", "models"] {
let mut stack = vec![home.join(top)];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else {
continue;
};
for e in rd.filter_map(Result::ok) {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else {
out.push(p.strip_prefix(home).unwrap().to_string_lossy().into_owned());
}
}
}
}
out.sort();
out
}
/// Monte l'arborescence du scénario sous `home`.
fn setup_tree(sc: &Value, home: &Path) -> (Vec<String>, HashMap<String, String>) {
let h = home.to_string_lossy().into_owned();
let unhome = |s: &str| s.replace("{{HOME}}", &h);
for (rel, size) in sc["files"].as_object().unwrap() {
let p = home.join(rel);
let size = size.as_i64().unwrap();
if size < 0 {
std::fs::create_dir_all(&p).unwrap();
continue;
}
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, vec![0u8; size as usize]).unwrap();
}
std::fs::create_dir_all(home.join("presets")).unwrap();
for (id, content) in sc["presets"].as_object().unwrap() {
std::fs::write(
home.join("presets").join(format!("{id}.env")),
unhome(content.as_str().unwrap()),
)
.unwrap();
}
let extra = sc["extra"]
.as_array()
.unwrap()
.iter()
.map(|d| unhome(d.as_str().unwrap()))
.collect::<Vec<_>>();
let cfg = sc["config"]
.as_object()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), unhome(v.as_str().unwrap())))
.collect();
(extra, cfg)
}
#[test]
fn parite_du_scenario_sur_disque() {
let fx = fixture();
let sc = &fx["scenario"];
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
let h = home.to_string_lossy().into_owned();
let (extra, cfg) = setup_tree(sc, &home);
let mut dirs = ModelDirs {
home: home.clone(),
// saveExtraModelDirs : nettoyée, dédoublonnée.
stored: loki_rs::gguf::dedup_dirs(&extra, &format!("{h}/models")),
env: None,
};
let norm = |v: Value| -> Value {
let s = serde_json::to_string(&v).unwrap().replace(&h, "{{HOME}}");
let mut v: Value = serde_json::from_str(&s).unwrap();
mask_free(&mut v);
v
};
for (i, c) in sc["calls"].as_array().unwrap().iter().enumerate() {
let (method, path) = (c["method"].as_str().unwrap(), c["path"].as_str().unwrap());
let body: Value = c
.get("body")
.and_then(Value::as_str)
.map(|b| serde_json::from_str(&b.replace("{{HOME}}", &h)).unwrap())
.unwrap_or(Value::Null);
let s = |k: &str| {
body.get(k)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
let (status, out) = match (method, path) {
("GET", "/api/models") => {
let refs = models::build_refs(&dirs, &cfg, &[]);
(200, models::list_models(&dirs, &refs))
}
("GET", "/api/models/dirs") => (200, models::dirs_json(&dirs)),
("POST", "/api/models/delete") => {
let refs = models::build_refs(&dirs, &cfg, &[]);
let force = body.get("force").and_then(Value::as_bool).unwrap_or(false);
let (code, out) = models::delete_model(&dirs, &refs, &s("name"), force);
(code.as_u16(), out)
}
("POST", "/api/models/dirs") => {
match models::dirs_update(&dirs, &s("path"), &s("action")) {
Ok(list) => {
dirs.stored = list;
(200, serde_json::json!({ "ok": true }))
}
Err(e) => (400, serde_json::json!({ "ok": false, "error": e })),
}
}
_ => panic!("appel inattendu {c}"),
};
let ctx = format!(
"appel {i} : {method} {path} {}",
c.get("body").unwrap_or(&Value::Null)
);
assert_eq!(status as u64, c["status"].as_u64().unwrap(), "{ctx}");
assert_eq!(norm(out), c["out"], "{ctx}");
if let Some(want) = c.get("files") {
let want: Vec<String> = serde_json::from_value(want.clone()).unwrap();
assert_eq!(tree_files(&home), want, "{ctx}");
}
if let Some(want) = c.get("stored") {
let want: Vec<String> = serde_json::from_value(want.clone()).unwrap();
let got: Vec<String> = dirs
.stored
.iter()
.map(|d| d.replace(&h, "{{HOME}}"))
.collect();
assert_eq!(got, want, "{ctx}");
}
}
}
#[test]
fn adresses_et_echappements_comme_le_go() {
assert_eq!(
hf::search_endpoint("https://huggingface.co", " qwen 3 & co "),
"https://huggingface.co/api/models?direction=-1&expand%5B%5D=gated&expand%5B%5D=downloads&expand%5B%5D=likes&filter=gguf&limit=25&search=qwen+3+%26+co&sort=downloads"
);
// Tronquée à 100 octets, même au milieu d'un caractère (q[:100] du Go).
let long = format!("{}é", "a".repeat(99));
assert!(hf::search_endpoint("h", &long).contains(&format!("search={}%C3&", "a".repeat(99))));
assert_eq!(
hf::go_path_escape("a b+c&(d)/é"),
"a%20b+c&%28d%29%2F%C3%A9"
);
assert!(
hf::repo_ok("a/b.c-d_e")
&& !hf::repo_ok("../x")
&& !hf::repo_ok("a/b/c")
&& !hf::repo_ok("-a/b")
);
}
// ---------------------------------------------------------------------------
// Faux Hugging Face
// ---------------------------------------------------------------------------
#[derive(Default)]
struct FakeHf {
log: Mutex<Vec<String>>,
auth: Mutex<Vec<String>>,
gated: Mutex<Value>,
}
async fn hf_handle(st: Arc<FakeHf>, req: Request<Incoming>) -> Response<Full<Bytes>> {
let pq = req
.uri()
.path_and_query()
.map(|p| p.as_str().to_string())
.unwrap_or_default();
st.log.lock().unwrap().push(pq.clone());
let auth = req
.headers()
.get("authorization")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
st.auth.lock().unwrap().push(auth.clone());
let path = req.uri().path();
let (code, body) = if path == "/api/whoami-v2" {
if auth == "Bearer hf_bon_jeton" {
(200, r#"{"name":"moi","type":"user"}"#.to_string())
} else {
(401, r#"{"error":"Invalid credentials"}"#.to_string())
}
} else if path == "/api/models" {
(200, r#"[{"id":"u/a-GGUF","downloads":10,"likes":2,"gated":false},{"id":"u/b-GGUF","downloads":5,"likes":1,"gated":"manual"}]"#.to_string())
} else if path == "/api/models/u/a-GGUF/tree/main" {
(200, r#"[{"type":"directory","path":"Q4"},{"type":"file","path":"Q4/a-Q4_K_M.gguf","size":134,"lfs":{"size":5368709120}},{"type":"file","path":"mmproj-Q8_0.gguf","size":629145600},{"type":"file","path":"README.md","size":10}]"#.to_string())
} else if path == "/api/models/u/a-GGUF" {
(
200,
format!(
r#"{{"id":"u/a-GGUF","gated":{}}}"#,
st.gated.lock().unwrap()
),
)
} else if path == "/api/models/u/verrou/tree/main" {
(401, "{}".to_string())
} else {
(404, "{}".to_string())
};
let mut r = Response::new(Full::new(Bytes::from(body)));
*r.status_mut() = StatusCode::from_u16(code).unwrap();
r
}
async fn fake_hf() -> (SocketAddr, Arc<FakeHf>) {
let st = Arc::new(FakeHf::default());
*st.gated.lock().unwrap() = Value::String("auto".into());
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let s2 = st.clone();
tokio::spawn(async move {
while let Ok((s, _)) = l.accept().await {
let st = s2.clone();
tokio::spawn(async move {
let svc = service_fn(move |req| {
let st = st.clone();
async move { Ok::<_, Infallible>(hf_handle(st, req).await) }
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(s), svc)
.await;
});
}
});
(addr, st)
}
#[tokio::test]
async fn catalogue_contre_un_faux_hugging_face() {
let (addr, st) = fake_hf().await;
let base = format!("http://{addr}");
let c = hf::HfClient::new(&base);
let env = HfEnv {
token: "hf_x".into(),
version: "1.2.3".into(),
};
let repos = c.search(&env, "qwen").await.unwrap();
assert_eq!(repos[1]["gated"], true);
assert_eq!(repos[0]["id"], "u/a-GGUF");
// Deuxième recherche identique : servie par le cache.
c.search(&env, "qwen").await.unwrap();
assert_eq!(st.log.lock().unwrap().len(), 1);
let l = c.files(&env, " /u/a-GGUF/ ").await.unwrap();
assert!(l.gated);
assert_eq!(l.models.len(), 1);
assert_eq!(l.models[0].size, 5368709120, "taille LFS prise");
assert_eq!(
l.models[0].url,
format!("{base}/u/a-GGUF/resolve/main/Q4/a-Q4_K_M.gguf")
);
assert_eq!(l.projectors[0].quant, "Q8_0");
assert!(l.drafts.is_empty());
assert!(st.auth.lock().unwrap().iter().all(|a| a == "Bearer hf_x"));
assert_eq!(
c.files(&env, "u/verrou").await.unwrap_err(),
"dépôt à accès restreint — renseigne HF_TOKEN pour y accéder"
);
assert_eq!(
c.files(&env, "u/absent").await.unwrap_err(),
"dépôt introuvable sur Hugging Face"
);
assert_eq!(
c.files(&env, "../x").await.unwrap_err(),
"nom de dépôt invalide (attendu « auteur/dépôt »)"
);
assert_eq!(c.whoami("1", "hf_bon_jeton").await.unwrap(), "moi");
assert_eq!(
c.whoami("1", "mauvais").await.unwrap_err(),
"jeton refusé par Hugging Face (expiré, révoqué ou mal copié)"
);
// Vider le cache : la recherche repart vers Hugging Face.
let n = st.log.lock().unwrap().len();
c.clear_cache();
c.search(&env, "qwen").await.unwrap();
assert_eq!(st.log.lock().unwrap().len(), n + 1);
}
#[tokio::test]
async fn routes_des_modeles_par_la_facade() {
let (addr, st) = fake_hf().await;
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
std::fs::create_dir_all(home.join("models")).unwrap();
std::fs::write(home.join("models/a.gguf"), b"0123").unwrap();
std::fs::create_dir_all(home.join("presets")).unwrap();
std::fs::write(home.join("presets/p.env"), "NAME=P\nMODEL=a.gguf\n").unwrap();
let ext = home.join("ext");
std::fs::create_dir_all(&ext).unwrap();
let (facade, go, _stop) = fakego::start_models(home.clone(), &format!("http://{addr}")).await;
let (code, _, b) = fakego::get(facade, "/api/models", &[]).await;
assert_eq!(code, StatusCode::OK);
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v[0]["name"], "a.gguf");
assert_eq!(v[0]["used"], serde_json::json!(["P"]));
// Dossier ajouté : rangé dans la base du Go.
let body = format!(r#"{{"path":"{}"}}"#, ext.display());
let (code, _) = fakego::send(facade, "POST", "/api/models/dirs", &[], &body).await;
assert_eq!(code, StatusCode::OK);
let stored: Vec<String> =
serde_json::from_str(&go.get("state", "model_dirs").unwrap()).unwrap();
assert_eq!(stored, [ext.to_string_lossy().into_owned()]);
let (_, _, b) = fakego::get(facade, "/api/models/dirs", &[]).await;
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["dirs"][1]["path"], ext.to_string_lossy().as_ref());
assert_eq!(v["dirs"][1]["count"], 0);
// Utilisé par un preset : 409, puis force.
let (code, b) = fakego::send(
facade,
"POST",
"/api/models/delete",
&[],
r#"{"name":"a.gguf"}"#,
)
.await;
assert_eq!(code, StatusCode::CONFLICT, "{b:?}");
let (code, b) = fakego::send(
facade,
"POST",
"/api/models/delete",
&[],
r#"{"name":"a.gguf","force":true}"#,
)
.await;
assert_eq!(code, StatusCode::OK);
assert_eq!(serde_json::from_slice::<Value>(&b).unwrap()["freed"], 4);
assert!(!home.join("models/a.gguf").exists());
// Catalogue : matériel joint, jeton absent.
let (code, _, b) = fakego::get(facade, "/api/hf/search?q=qwen+3", &[]).await;
assert_eq!(code, StatusCode::OK);
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["hf_token"], false);
assert!(v["hardware"]["os"].is_string());
assert!(st.log.lock().unwrap()[0].contains("search=qwen+3&"));
let (code, _, b) = fakego::get(facade, "/api/hf/search?q=%20", &[]).await;
assert_eq!(code, StatusCode::BAD_REQUEST);
assert_eq!(
serde_json::from_slice::<Value>(&b).unwrap()["error"],
"précise ce que tu cherches"
);
go.set("config", "CTX", "65536");
let (code, _, b) = fakego::get(facade, "/api/hf/files?repo=u/a-GGUF", &[]).await;
assert_eq!(code, StatusCode::OK);
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(
(
v["ctx"].as_i64(),
v["vision"].as_bool(),
v["gated"].as_bool()
),
(Some(65536), Some(true), Some(true))
);
assert_eq!(v["drafts"], Value::Null);
assert!(v["models"][0].get("verdict").is_none() || v["models"][0]["verdict"].is_string());
let (code, _, _) = fakego::get(facade, "/api/hf/files?repo=u/absent", &[]).await;
assert_eq!(code, StatusCode::BAD_GATEWAY);
// Jeton : refusé, accepté (vérifié, enregistré, masqué), effacé.
let (code, b) = fakego::send(
facade,
"POST",
"/api/hf/token",
&[],
r#"{"token":"mauvais"}"#,
)
.await;
assert_eq!(code, StatusCode::BAD_REQUEST, "{b:?}");
let (code, b) = fakego::send(
facade,
"POST",
"/api/hf/token",
&[],
r#"{"token":" hf_bon_jeton "}"#,
)
.await;
assert_eq!(code, StatusCode::OK);
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(
(
v["set"].as_bool(),
v["source"].as_str(),
v["masked"].as_str(),
v["account"].as_str()
),
(Some(true), Some("config"), Some("hf_…eton"), Some("moi"))
);
assert_eq!(go.get("state", "hf_token").as_deref(), Some("hf_bon_jeton"));
let (_, _, b) = fakego::get(facade, "/api/hf/search?q=autre", &[]).await;
assert_eq!(
serde_json::from_slice::<Value>(&b).unwrap()["hf_token"],
true
);
assert_eq!(
st.auth.lock().unwrap().last().unwrap(),
"Bearer hf_bon_jeton"
);
let (code, b) = fakego::send(facade, "POST", "/api/hf/token", &[], r#"{"token":""}"#).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(serde_json::from_slice::<Value>(&b).unwrap()["set"], false);
assert_eq!(go.get("state", "hf_token"), None);
let (code, b) = fakego::send(facade, "POST", "/api/hf/token", &[], "x").await;
assert_eq!(
(
code,
serde_json::from_slice::<Value>(&b).unwrap()["error"].as_str()
),
(StatusCode::BAD_REQUEST, Some("requête illisible"))
);
// Rien n'est parti au Go.
let relayed = go.relayed.lock().unwrap().clone();
assert!(
!relayed
.iter()
.any(|p| p.starts_with("/api/models") || p.starts_with("/api/hf")),
"{relayed:?}"
);
}
#[tokio::test]
async fn sans_la_variable_les_routes_restent_au_go() {
let tmp = tempfile::tempdir().unwrap();
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
for p in [
"/api/models",
"/api/models/dirs",
"/api/hf/token",
"/api/hf/search?q=x",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert!(b.starts_with(b"go:/api/"), "{p} : {b:?}");
}
}
@@ -0,0 +1,933 @@
{
"quant": {
"Qwen3-8B-Q4_K_M.gguf": "Q4_K_M",
"a-4bpw.gguf": "4BPW",
"m-IQ4_XS-00001-of-00002.gguf": "IQ4_XS",
"m-Q4.gguf": "Q4",
"m-Q4_K_XL-Q5_K_M.gguf": "Q4_K_XL",
"mmproj-F16.GGUF": "F16",
"model-bf16.gguf": "BF16",
"noext": "",
"plain.gguf": "",
"x.Q8_0.gguf": "Q8_0"
},
"split_args": [
{
"in": "",
"out": []
},
{
"in": "--mmproj /a/b.gguf -ngl 99",
"out": [
"--mmproj",
"/a/b.gguf",
"-ngl",
"99"
]
},
{
"in": "--chat-template-file \"/etc/x y.jinja\" --x ''",
"out": [
"--chat-template-file",
"/etc/x y.jinja",
"--x",
""
]
},
{
"in": "a\tb\nc",
"out": [
"a",
"b",
"c"
]
},
{
"in": "'un \"deux\"' trois",
"out": [
"un \"deux\"",
"trois"
]
},
{
"in": "--mmproj",
"out": [
"--mmproj"
]
}
],
"mask": {
"": "",
" ": "",
" hf_padded_token ": "hf_…oken",
"abc": "…bc",
"hf_12345678": "hf_…5678",
"hf_abcdefghijklmnop": "hf_…mnop"
},
"gated": [
{
"in": true,
"out": true
},
{
"in": false,
"out": false
},
{
"in": "auto",
"out": true
},
{
"in": "manual",
"out": true
},
{
"in": "false",
"out": false
},
{
"in": "",
"out": false
},
{
"in": "None",
"out": false
},
{
"in": null,
"out": false
},
{
"in": 1,
"out": false
}
],
"classify": {
"repo": "unsloth/Qwen3-8B-GGUF",
"files": [
{
"path": "README.md",
"size": 1000
},
{
"path": "Qwen3-8B-Q8_0.gguf",
"size": 8589934592
},
{
"path": "Qwen3-8B-Q4_K_M.gguf",
"size": 5368709120
},
{
"path": "mmproj-F16.gguf",
"size": 943718400
},
{
"path": "mmproj-Q8_0.gguf",
"size": 629145600
},
{
"path": "mtp-Qwen3-8B-Q8_0.gguf",
"size": 314572800
},
{
"path": "BF16/Qwen3-8B-BF16-00001-of-00002.gguf",
"size": 9663676416
},
{
"path": "BF16/Qwen3-8B-BF16-00002-of-00002.gguf",
"size": 7516192768
},
{
"path": "Q2/Qwen3-8B-BF16-00001-of-00002.gguf",
"size": 1073741824
},
{
"path": "dossier avec espace/Qwen3 8B Q3.gguf",
"size": 4294967296
}
],
"out": {
"repo": "unsloth/Qwen3-8B-GGUF",
"gated": false,
"models": [
{
"name": "Qwen3-8B-BF16-00001-of-00002.gguf",
"url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Q2/Qwen3-8B-BF16-00001-of-00002.gguf",
"size": 1073741824,
"quant": "BF16",
"shards": 2
},
{
"name": "Qwen3 8B Q3.gguf",
"url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/dossier%20avec%20espace/Qwen3%208B%20Q3.gguf",
"size": 4294967296,
"quant": "",
"shards": 1
},
{
"name": "Qwen3-8B-Q4_K_M.gguf",
"url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q4_K_M.gguf",
"size": 5368709120,
"quant": "Q4_K_M",
"shards": 1
},
{
"name": "Qwen3-8B-Q8_0.gguf",
"url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q8_0.gguf",
"size": 8589934592,
"quant": "Q8_0",
"shards": 1
},
{
"name": "Qwen3-8B-BF16-00001-of-00002.gguf",
"url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/BF16/Qwen3-8B-BF16-00001-of-00002.gguf",
"size": 17179869184,
"quant": "BF16",
"shards": 2
}
],
"projectors": [
{
"name": "mmproj-Q8_0.gguf",
"url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mmproj-Q8_0.gguf",
"size": 629145600,
"quant": "Q8_0",
"shards": 1
},
{
"name": "mmproj-F16.gguf",
"url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mmproj-F16.gguf",
"size": 943718400,
"quant": "F16",
"shards": 1
}
],
"drafts": [
{
"name": "mtp-Qwen3-8B-Q8_0.gguf",
"url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mtp-Qwen3-8B-Q8_0.gguf",
"size": 314572800,
"quant": "Q8_0",
"shards": 1
}
]
}
},
"projector": [
{
"in": null,
"name": ""
},
{
"in": [
{
"name": "mmproj-F16.gguf",
"url": "",
"size": 900,
"quant": "F16",
"shards": 0
},
{
"name": "mmproj-BF16.gguf",
"url": "",
"size": 800,
"quant": "BF16",
"shards": 0
}
],
"name": "mmproj-BF16.gguf"
},
{
"in": [
{
"name": "mmproj-F16.gguf",
"url": "",
"size": 900,
"quant": "F16",
"shards": 0
},
{
"name": "mmproj-q8_0.gguf",
"url": "",
"size": 950,
"quant": "q8_0",
"shards": 0
}
],
"name": "mmproj-q8_0.gguf"
}
],
"verdicts": [
{
"hardware": {
"os": "linux",
"arch": "amd64",
"ram_gb": 64,
"vram_gb": 24,
"gpus": [
{
"name": "RTX",
"vram_gb": 24
}
]
},
"weights": 5368709120,
"mmproj": 0,
"ctx": 32768,
"verdict": "ok",
"why": "5.0 Go de poids + ~0.2 Go de contexte (estimation) = ~5.2 Go pour 24.0 Go de VRAM"
},
{
"hardware": {
"os": "linux",
"arch": "amd64",
"ram_gb": 64,
"vram_gb": 24,
"gpus": [
{
"name": "RTX",
"vram_gb": 24
}
]
},
"weights": 21474836480,
"mmproj": 629145600,
"ctx": 32768,
"verdict": "ok",
"why": "20.0 Go de poids + 0.6 Go de projecteur + ~1.0 Go de contexte (estimation) = ~21.6 Go pour 24.0 Go de VRAM"
},
{
"hardware": {
"os": "linux",
"arch": "amd64",
"ram_gb": 64,
"vram_gb": 24,
"gpus": [
{
"name": "RTX",
"vram_gb": 24
}
]
},
"weights": 23622320128,
"mmproj": 0,
"ctx": 32768,
"verdict": "juste",
"why": "ça passe de justesse : 22.0 Go de poids + ~1.1 Go de contexte (estimation) = ~23.1 Go pour 24.0 Go de VRAM"
},
{
"hardware": {
"os": "linux",
"arch": "amd64",
"ram_gb": 64,
"vram_gb": 24,
"gpus": [
{
"name": "RTX",
"vram_gb": 24
}
]
},
"weights": 32212254720,
"mmproj": 0,
"ctx": 0,
"verdict": "trop",
"why": "ne tient pas : 30.0 Go de poids + ~1.5 Go de contexte (estimation) = ~31.5 Go pour 24.0 Go de VRAM"
},
{
"hardware": {
"os": "linux",
"arch": "amd64",
"ram_gb": 16,
"vram_gb": 0,
"gpus": null
},
"weights": 8589934592,
"mmproj": 0,
"ctx": 131072,
"verdict": "ok",
"why": "8.0 Go de poids + ~1.6 Go de contexte (estimation) = ~9.6 Go pour 16.0 Go de RAM"
},
{
"hardware": {
"os": "linux",
"arch": "amd64",
"ram_gb": 0,
"vram_gb": 0,
"gpus": null
},
"weights": 1073741824,
"mmproj": 0,
"ctx": 4096,
"verdict": "",
"why": ""
}
],
"scenario": {
"files": {
"disque/a-Q4_K_M.gguf": 100,
"disque/ext.GGUF": 400,
"models/a-Q4_K_M.gguf": 100,
"models/actif.gguf": 50,
"models/b.gguf": 200,
"models/m-00001-of-00002.gguf": 10,
"models/m-00002-of-00002.gguf": 20,
"models/notes.txt": 3,
"models/sous-dossier.gguf": -1,
"models/t-00001-of-00003.gguf": 5,
"models/t-00003-of-00003.gguf": 7,
"models/vision-mmproj.gguf": 30
},
"extra": [
"{{HOME}}/disque",
"{{HOME}}/absent"
],
"presets": {
"alpha": "NAME=Alpha\nMODEL=a-Q4_K_M.gguf\nMMPROJ=vision-mmproj.gguf\n",
"beta": "NAME=Beta\nMODEL={{HOME}}/disque/ext.GGUF\n",
"delta": "NAME=Delta\nMODEL=m-00001-of-00002.gguf\n",
"gamma": "NAME=Gamma\nMODEL=b.gguf\nEXTRA_ARGS=--mmproj \"vision-mmproj.gguf\" -ngl 99 --mmproj b.gguf\n"
},
"config": {
"MODEL": "actif.gguf",
"MODEL_DRAFT": "{{HOME}}/disque/a-Q4_K_M.gguf"
},
"calls": [
{
"method": "GET",
"path": "/api/models",
"status": 200,
"out": [
{
"active": false,
"dir": "{{HOME}}/models",
"home": true,
"name": "a-Q4_K_M.gguf",
"path": "{{HOME}}/models/a-Q4_K_M.gguf",
"size": 100,
"used": [
"Alpha"
],
"value": "a-Q4_K_M.gguf"
},
{
"active": true,
"dir": "{{HOME}}/models",
"home": true,
"name": "actif.gguf",
"path": "{{HOME}}/models/actif.gguf",
"size": 50,
"value": "actif.gguf"
},
{
"active": false,
"dir": "{{HOME}}/models",
"home": true,
"name": "b.gguf",
"path": "{{HOME}}/models/b.gguf",
"size": 200,
"used": [
"Gamma"
],
"value": "b.gguf"
},
{
"active": false,
"dir": "{{HOME}}/models",
"home": true,
"name": "m-00001-of-00002.gguf",
"path": "{{HOME}}/models/m-00001-of-00002.gguf",
"shards": 2,
"size": 30,
"used": [
"Delta"
],
"value": "m-00001-of-00002.gguf"
},
{
"dir": "{{HOME}}/models",
"home": true,
"missing": [
"t-00002-of-00003.gguf"
],
"name": "t-00001-of-00003.gguf",
"path": "{{HOME}}/models/t-00001-of-00003.gguf",
"shards": 3,
"size": 12,
"value": "t-00001-of-00003.gguf"
},
{
"active": false,
"dir": "{{HOME}}/models",
"home": true,
"name": "vision-mmproj.gguf",
"path": "{{HOME}}/models/vision-mmproj.gguf",
"size": 30,
"used": [
"Alpha",
"Gamma"
],
"value": "vision-mmproj.gguf"
},
{
"active": true,
"dir": "{{HOME}}/disque",
"home": false,
"name": "a-Q4_K_M.gguf",
"path": "{{HOME}}/disque/a-Q4_K_M.gguf",
"size": 100,
"value": "{{HOME}}/disque/a-Q4_K_M.gguf"
},
{
"active": false,
"dir": "{{HOME}}/disque",
"home": false,
"name": "ext.GGUF",
"path": "{{HOME}}/disque/ext.GGUF",
"size": 400,
"used": [
"Beta"
],
"value": "{{HOME}}/disque/ext.GGUF"
}
]
},
{
"method": "GET",
"path": "/api/models/dirs",
"status": 200,
"out": {
"dirs": [
{
"count": 6,
"exists": true,
"free": "…",
"free_exact": "…",
"home": true,
"path": "{{HOME}}/models"
},
{
"count": 2,
"exists": true,
"free": "…",
"free_exact": "…",
"home": false,
"path": "{{HOME}}/disque"
},
{
"count": -1,
"exists": false,
"free": "…",
"free_exact": "…",
"home": false,
"path": "{{HOME}}/absent"
}
],
"env": false,
"ok": true
}
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"actif.gguf\"}",
"status": 409,
"out": {
"active": true,
"error": "modèle en service — bascule sur un autre preset avant de le supprimer",
"ok": false
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/b.gguf",
"models/m-00001-of-00002.gguf",
"models/m-00002-of-00002.gguf",
"models/notes.txt",
"models/t-00001-of-00003.gguf",
"models/t-00003-of-00003.gguf",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"a-Q4_K_M.gguf\"}",
"status": 409,
"out": {
"error": "modèle utilisé par : Alpha",
"ok": false,
"used": [
"Alpha"
]
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/b.gguf",
"models/m-00001-of-00002.gguf",
"models/m-00002-of-00002.gguf",
"models/notes.txt",
"models/t-00001-of-00003.gguf",
"models/t-00003-of-00003.gguf",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"{{HOME}}/disque/a-Q4_K_M.gguf\"}",
"status": 409,
"out": {
"active": true,
"error": "modèle en service — bascule sur un autre preset avant de le supprimer",
"ok": false
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/b.gguf",
"models/m-00001-of-00002.gguf",
"models/m-00002-of-00002.gguf",
"models/notes.txt",
"models/t-00001-of-00003.gguf",
"models/t-00003-of-00003.gguf",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"b.gguf\",\"force\":true}",
"status": 200,
"out": {
"freed": 200,
"ok": true,
"used": [
"Gamma"
]
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/m-00001-of-00002.gguf",
"models/m-00002-of-00002.gguf",
"models/notes.txt",
"models/t-00001-of-00003.gguf",
"models/t-00003-of-00003.gguf",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"m-00001-of-00002.gguf\",\"force\":true}",
"status": 200,
"out": {
"freed": 30,
"ok": true,
"used": [
"Delta"
]
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/notes.txt",
"models/t-00001-of-00003.gguf",
"models/t-00003-of-00003.gguf",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"t-00001-of-00003.gguf\"}",
"status": 200,
"out": {
"freed": 12,
"ok": true,
"used": null
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/notes.txt",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"inconnu.gguf\"}",
"status": 400,
"out": {
"error": "modèle introuvable: inconnu.gguf",
"ok": false
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/notes.txt",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"/etc/passwd.gguf\"}",
"status": 400,
"out": {
"error": "dossier non autorisé : /etc — ajoute-le dans « Dossiers de modèles »",
"ok": false
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/notes.txt",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"notes.txt\"}",
"status": 400,
"out": {
"error": "le modèle doit être un fichier .gguf",
"ok": false
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/notes.txt",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/delete",
"body": "{\"name\":\"\"}",
"status": 400,
"out": {
"error": "nom de modèle invalide",
"ok": false
},
"files": [
"disque/a-Q4_K_M.gguf",
"disque/ext.GGUF",
"models/a-Q4_K_M.gguf",
"models/actif.gguf",
"models/notes.txt",
"models/vision-mmproj.gguf"
]
},
{
"method": "POST",
"path": "/api/models/dirs",
"body": "{\"path\":\"\"}",
"status": 400,
"out": {
"error": "chemin vide",
"ok": false
},
"stored": [
"{{HOME}}/disque",
"{{HOME}}/absent"
]
},
{
"method": "POST",
"path": "/api/models/dirs",
"body": "{\"path\":\"{{HOME}}/nulle-part\"}",
"status": 400,
"out": {
"error": "dossier introuvable : {{HOME}}/nulle-part",
"ok": false
},
"stored": [
"{{HOME}}/disque",
"{{HOME}}/absent"
]
},
{
"method": "POST",
"path": "/api/models/dirs",
"body": "{\"path\":\"{{HOME}}/models/sous-dossier.gguf\"}",
"status": 200,
"out": {
"ok": true
},
"stored": [
"{{HOME}}/disque",
"{{HOME}}/absent",
"{{HOME}}/models/sous-dossier.gguf"
]
},
{
"method": "POST",
"path": "/api/models/dirs",
"body": "{\"path\":\"{{HOME}}/disque/\",\"action\":\"remove\"}",
"status": 200,
"out": {
"ok": true
},
"stored": [
"{{HOME}}/absent",
"{{HOME}}/models/sous-dossier.gguf"
]
},
{
"method": "POST",
"path": "/api/models/dirs",
"body": "{\"path\":\"{{HOME}}/disque/../disque\",\"action\":\"add\"}",
"status": 200,
"out": {
"ok": true
},
"stored": [
"{{HOME}}/absent",
"{{HOME}}/models/sous-dossier.gguf",
"{{HOME}}/disque"
]
},
{
"method": "POST",
"path": "/api/models/dirs",
"body": "{\"path\":\"{{HOME}}/models\",\"action\":\"add\"}",
"status": 200,
"out": {
"ok": true
},
"stored": [
"{{HOME}}/absent",
"{{HOME}}/models/sous-dossier.gguf",
"{{HOME}}/disque"
]
},
{
"method": "GET",
"path": "/api/models",
"status": 200,
"out": [
{
"active": false,
"dir": "{{HOME}}/models",
"home": true,
"name": "a-Q4_K_M.gguf",
"path": "{{HOME}}/models/a-Q4_K_M.gguf",
"size": 100,
"used": [
"Alpha"
],
"value": "a-Q4_K_M.gguf"
},
{
"active": true,
"dir": "{{HOME}}/models",
"home": true,
"name": "actif.gguf",
"path": "{{HOME}}/models/actif.gguf",
"size": 50,
"value": "actif.gguf"
},
{
"active": false,
"dir": "{{HOME}}/models",
"home": true,
"name": "vision-mmproj.gguf",
"path": "{{HOME}}/models/vision-mmproj.gguf",
"size": 30,
"used": [
"Alpha",
"Gamma"
],
"value": "vision-mmproj.gguf"
},
{
"active": true,
"dir": "{{HOME}}/disque",
"home": false,
"name": "a-Q4_K_M.gguf",
"path": "{{HOME}}/disque/a-Q4_K_M.gguf",
"size": 100,
"value": "{{HOME}}/disque/a-Q4_K_M.gguf"
},
{
"active": false,
"dir": "{{HOME}}/disque",
"home": false,
"name": "ext.GGUF",
"path": "{{HOME}}/disque/ext.GGUF",
"size": 400,
"used": [
"Beta"
],
"value": "{{HOME}}/disque/ext.GGUF"
}
]
},
{
"method": "GET",
"path": "/api/models/dirs",
"status": 200,
"out": {
"dirs": [
{
"count": 3,
"exists": true,
"free": "…",
"free_exact": "…",
"home": true,
"path": "{{HOME}}/models"
},
{
"count": -1,
"exists": false,
"free": "…",
"free_exact": "…",
"home": false,
"path": "{{HOME}}/absent"
},
{
"count": 0,
"exists": true,
"free": "…",
"free_exact": "…",
"home": false,
"path": "{{HOME}}/models/sous-dossier.gguf"
},
{
"count": 2,
"exists": true,
"free": "…",
"free_exact": "…",
"home": false,
"path": "{{HOME}}/disque"
}
],
"env": false,
"ok": true
}
}
]
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
service: "loki-engine".into(),
},
chat: None,
downloads: None,
models: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;