Lot 4 : /api/status, /api/service/log, /api/paths, /api/model/layers en Rust

L'en-tête de l'interface interroge /api/status en boucle : il est
désormais assemblé par la façade, à partir des aides portées (état du
service par le fichier PID, sonde /health, preset externe, cause d'un
échec de chargement lue dans le journal, pourcentage de chargement, preset
actif, alertes de volume et de FUSE). Sur une base réelle, sa réponse est
octet pour octet celle du Go.

Journal du moteur, emplacements et nombre de couches du modèle suivent.
L'état du moteur s'appuie sur la résolution des modèles de gguf.rs plutôt
que sur sa propre copie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-14 17:57:53 +02:00
1 parent d805d9f1ba
commit 73db4459d7
6 files changed
+293 -261

No files matched your search

+191 -2
View File
@@ -14,7 +14,7 @@ use crate::auth::{self, HostEnv, InternalClient};
use crate::engine;
use crate::proxy::Body;
use crate::store::{BK_STATE, Store};
use crate::sysinfo;
use crate::{engstatus, gguf, paths, presets, sysinfo};
use std::collections::BTreeMap;
/// Ce dont les routes ont besoin, partagé par toutes les connexions.
@@ -42,7 +42,15 @@ impl Ctx {
pub fn handles(_method: &Method, path: &str) -> bool {
matches!(
path,
"/api/ping" | "/api/config" | "/api/engine/cmdline" | "/api/ram" | "/api/vram"
"/api/ping"
| "/api/config"
| "/api/engine/cmdline"
| "/api/ram"
| "/api/vram"
| "/api/status"
| "/api/service/log"
| "/api/paths"
| "/api/model/layers"
)
}
@@ -85,10 +93,191 @@ pub async fn serve(ctx: &Ctx, req: Request<Incoming>) -> Response<Body> {
.collect();
auth::json(StatusCode::OK, &json!(gpus))
}
"/api/status" => status(ctx, version).await,
// Fin du journal du moteur : POURQUOI un modèle ne charge pas.
"/api/service/log" => {
let n = query_param(&parts.uri, "n")
.and_then(|v| v.parse::<usize>().ok())
.filter(|k| (1..=500).contains(k))
.unwrap_or(80);
let paths = ctx.engine.clone();
let log = blocking(move || engstatus::service_log_tail(&paths, n)).await;
auth::json(StatusCode::OK, &json!({ "log": log }))
}
// Bloc « Emplacements » de l'UI.
"/api/paths" => {
let project = ctx
.store
.get_str(BK_STATE, paths::ST_ACTIVE_PROJECT_KEY)
.await;
let home = ctx.engine.home.clone();
let p = blocking(move || {
let exe = std::env::current_exe().unwrap_or_default();
let go_exe = crate::child::go_bin(std::env::var("LOKI_GO_BIN").ok(), &exe);
let ws = std::env::var(paths::WORKSPACE_ENV).ok();
paths::current_paths(&home, &go_exe, &project, ws.as_deref())
})
.await;
auth::json(StatusCode::OK, &p)
}
// Nombre de couches du modèle, lu dans l'en-tête GGUF.
"/api/model/layers" => {
let q = query_param(&parts.uri, "model");
let cfg_model = ctx.store.config().await.remove("MODEL");
let stored = stored_model_dirs(ctx).await;
let home = ctx.engine.home.clone();
let v = blocking(move || {
let env = std::env::var(gguf::MODEL_DIRS_ENV).ok();
gguf::model_layers_json(
q.as_deref(),
cfg_model.as_deref(),
&home,
&stored,
env.as_deref(),
)
})
.await;
auth::json(StatusCode::OK, &v)
}
_ => not_found(),
}
}
/// `handleStatus` : état du moteur pour l'en-tête de l'UI.
async fn status(ctx: &Ctx, version: String) -> Response<Body> {
let cfg = ctx.store.config().await;
let stored = stored_model_dirs(ctx).await;
let order = presets::parse_preset_order(
ctx.store
.get(BK_STATE, presets::PRESET_ORDER_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
let paths = ctx.engine.clone();
let pid = engine::read_pid(&paths.pid());
let mut active = pid_alive(pid);
let mut state = if active { "active" } else { "inactive" };
let mut health = active && engstatus::health_check(&cfg).await;
// Preset externe : pas de moteur local, mais le chat EST prêt.
let external = engstatus::is_external(&cfg);
if external {
(active, state, health) = (true, "active", true);
}
let ctx_len = cfg
.get("CTX")
.and_then(|v| v.parse::<i64>().ok())
.filter(|n| *n > 0)
.unwrap_or(32768);
let port = engstatus::llm_port(&cfg);
let home = paths.home.clone();
let (load_err, load_pct, preset, data_warn) = {
let (cfg, stored) = (cfg.clone(), stored.clone());
blocking(move || {
let mut load_err = String::new();
let mut load_pct = -1;
if !health {
load_err =
engstatus::model_load_error_from(&engstatus::service_log_tail(&paths, 200));
if active && load_err.is_empty() {
load_pct = engstatus::engine_load_pct_dirs(&paths, &cfg, &stored);
}
}
let preset = presets::active_preset_id(&home, &cfg, &order);
(load_err, load_pct, preset, sysinfo::data_volume_warning())
})
.await
};
// appWarning() n'a de contenu que sous macOS : en conteneur, seule
// l'alerte de volume compte.
let warn = data_warn;
let home = ctx.engine.home.clone();
let hint = sysinfo::storage_fuse_hint(move || async move {
let env = std::env::var(gguf::MODEL_DIRS_ENV).ok();
gguf::model_dirs(&home, &stored, env.as_deref())
})
.await;
auth::json(
StatusCode::OK,
&json!({
"state": state,
"active": active,
"health": health,
"port": port,
"ctx": ctx_len,
"version": version,
"warn": warn,
"load_error": load_err,
"load_pct": load_pct,
"external": external,
"preset": preset,
"hint": hint,
}),
)
}
/// Dossiers de modèles déclarés dans l'UI (`state/model_dirs`).
async fn stored_model_dirs(ctx: &Ctx) -> Vec<String> {
gguf::parse_stored_model_dirs(
ctx.store
.get(BK_STATE, gguf::STATE_MODEL_DIRS_KEY)
.await
.ok()
.flatten()
.as_deref(),
)
}
/// E/S de fichiers hors du runtime asynchrone.
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
tokio::task::spawn_blocking(f)
.await
.expect("tâche bloquante interrompue")
}
#[cfg(target_os = "linux")]
fn pid_alive(pid: i32) -> bool {
engine::proctree::linux::pid_alive(pid)
}
#[cfg(not(target_os = "linux"))]
fn pid_alive(_: i32) -> bool {
false
}
/// Paramètre de requête décodé comme `url.Query().Get` (`+` = espace).
pub fn query_param(uri: &hyper::Uri, key: &str) -> Option<String> {
uri.query()?.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
(pct_decode(k) == key).then(|| pct_decode(v))
})
}
fn pct_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 += 2;
}
Err(_) => out.push(b'%'),
}
}
c => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn not_found() -> Response<Body> {
auth::json(StatusCode::NOT_FOUND, &json!({ "error": "route inconnue" }))
}
+1 -1
View File
@@ -233,7 +233,7 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
}
/// JSON comme `sendJSON` du Go (json.Encoder : saut de ligne final).
pub fn json(status: StatusCode, v: &serde_json::Value) -> Response<Body> {
pub fn json<T: serde::Serialize + ?Sized>(status: StatusCode, v: &T) -> Response<Body> {
let mut s = serde_json::to_string(v).unwrap_or_else(|_| "{}".into());
s.push('\n');
let mut r = Response::new(Full::new(Bytes::from(s)).map_err(|n| match n {}).boxed());
+9 -254
View File
@@ -14,7 +14,7 @@
//! (`Store::config`) : ce module ne parle pas à la base.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::time::Duration;
use crate::engine::Paths;
@@ -295,9 +295,13 @@ mod linux {
if model.is_empty() {
return -1;
}
let models = paths.home.join("models");
let extra = extra_model_dirs(stored_dirs);
let Some(path) = resolve_serve_model_path(model, &models, &extra) else {
let env_dirs = std::env::var(crate::gguf::MODEL_DIRS_ENV).ok();
let Ok(path) = crate::gguf::resolve_serve_model_path(
model,
&paths.home,
stored_dirs,
env_dirs.as_deref(),
) else {
return -1;
};
let size = model_total_size(&path);
@@ -377,191 +381,9 @@ fn go_parse_int64(s: &str) -> i64 {
// --- Résolution du modèle (port local de backend_model_dirs.go, le temps que
// `crate::paths` le porte en entier) -----------------------------------------
/// Séparateur de `$LOKI_MODEL_DIRS` (':' sous Unix, ';' sous Windows).
fn model_dirs_list_sep() -> char {
if cfg!(windows) { ';' } else { ':' }
}
/// `extraModelDirs` : dossiers enregistrés en base puis `$LOKI_MODEL_DIRS`,
/// vides retirés, rendus absolus. Les doublons ne sont pas retirés : ils ne
/// changent ni « premier dossier qui contient le fichier » ni « chemin dans un
/// des dossiers ».
pub fn extra_model_dirs(stored: &[String]) -> Vec<PathBuf> {
let mut raw: Vec<String> = stored.to_vec();
if let Ok(v) = std::env::var("LOKI_MODEL_DIRS")
&& !v.is_empty()
{
raw.extend(v.split(model_dirs_list_sep()).map(str::to_string));
}
raw.iter()
.map(|d| d.trim())
.filter(|d| !d.is_empty())
.filter_map(|d| std::path::absolute(d).ok())
.collect()
}
/// `resolveServeModelPath` : MODEL= (nom de fichier ou chemin absolu) → chemin
/// du .gguf à lancer ; `None` = nom invalide ou chemin absolu refusé.
pub fn resolve_serve_model_path(name: &str, models: &Path, extra: &[PathBuf]) -> Option<PathBuf> {
let s = name.trim().trim_matches('"').trim();
if s.is_empty() || !s.to_lowercase().ends_with(".gguf") {
return None;
}
let dirs: Vec<&Path> = std::iter::once(models)
.chain(extra.iter().map(PathBuf::as_path))
.collect();
if Path::new(s).is_absolute() {
let abs = clean(s);
// resolveModelPath : dans un des dossiers déclarés, existant ou non.
if dirs.iter().any(|d| path_within(&d.to_string_lossy(), &abs)) {
return Some(PathBuf::from(abs));
}
// resolveServeModelPath : ailleurs, seulement un fichier qui existe.
return std::fs::metadata(&abs)
.ok()
.filter(|m| !m.is_dir())
.map(|_| PathBuf::from(abs));
}
let base = base_name(s);
if base.is_empty() || base == "." {
return None;
}
for d in &dirs {
let p = d.join(base);
if std::fs::metadata(&p).is_ok_and(|m| !m.is_dir()) {
return Some(p);
}
}
Some(models.join(base)) // introuvable : la taille dira -1
}
/// `filepath.Clean` (forme Unix ; sous Windows, '\' est d'abord unifié en '/').
fn clean(p: &str) -> String {
let p = if cfg!(windows) {
p.replace('\\', "/")
} else {
p.to_string()
};
if p.is_empty() {
return ".".into();
}
let rooted = p.starts_with('/');
let mut out: Vec<&str> = Vec::new();
for seg in p.split('/') {
match seg {
"" | "." => {}
".." => match out.last() {
Some(&last) if last != ".." => {
out.pop();
}
_ if !rooted => out.push(".."),
_ => {}
},
s => out.push(s),
}
}
let body = out.join("/");
if rooted {
format!("/{body}")
} else if body.is_empty() {
".".into()
} else {
body
}
}
/// `normDir` : chemin comparable (Clean, séparateurs unifiés, casse neutralisée
/// sous Windows).
fn norm_dir(p: &str) -> String {
let p = clean(p.trim()).replace('\\', "/");
if cfg!(windows) { p.to_lowercase() } else { p }
}
/// `pathWithin` : le fichier `p` est-il dans le dossier `dir` ?
fn path_within(dir: &str, p: &str) -> bool {
let (d, f) = (norm_dir(dir), norm_dir(p));
f == d || f.starts_with(&format!("{d}/"))
}
/// `baseName` : nom de fichier en acceptant les DEUX séparateurs.
fn base_name(p: &str) -> &str {
let p = p.trim().trim_end_matches(['/', '\\']);
match p.rfind(['/', '\\']) {
Some(i) => &p[i + 1..],
None => p,
}
}
/// `shardInfo` : rang (1-based) et nombre total d'une tranche
/// `<base>-00001-of-00003.gguf` ; `None` pour un fichier simple ou une
/// numérotation incohérente.
pub fn shard_info(name: &str) -> Option<(u32, u32)> {
let (_, idx, total, _) = shard_match(base_name(name))?;
let (idx, total): (u32, u32) = (idx.parse().ok()?, total.parse().ok()?);
if idx < 1 || total < 1 || idx > total {
return None;
}
Some((idx, total))
}
/// `shardRe` (`(?i)-(\d{2,5})-of-(\d{2,5})\.gguf$`) : (préfixe avant le rang,
/// rang, total, suffixe après le total).
fn shard_match(name: &str) -> Option<(&str, &str, &str, &str)> {
let n = name.len();
if n < 5 || !name.is_char_boundary(n - 5) || !name[n - 5..].eq_ignore_ascii_case(".gguf") {
return None;
}
let digits_before = |end: usize| {
name.as_bytes()[..end]
.iter()
.rev()
.take_while(|b| b.is_ascii_digit())
.count()
};
let t_end = n - 5;
let t_len = digits_before(t_end);
if !(2..=5).contains(&t_len) {
return None;
}
let t_start = t_end - t_len;
if t_start < 4 || !name.as_bytes()[t_start - 4..t_start].eq_ignore_ascii_case(b"-of-") {
return None;
}
let i_end = t_start - 4;
let i_len = digits_before(i_end);
if !(2..=5).contains(&i_len) {
return None;
}
let i_start = i_end - i_len;
if i_start < 1 || name.as_bytes()[i_start - 1] != b'-' {
return None;
}
Some((
&name[..i_start],
&name[i_start..i_end],
&name[t_start..t_end],
&name[t_end..],
))
}
/// `shardFamily` : les noms de TOUTES les tranches de la famille de `name`, en
/// gardant la largeur de la numérotation ; un fichier simple se renvoie
/// lui-même.
pub fn shard_family(name: &str) -> Vec<String> {
let base = base_name(name);
let (Some((_, total)), Some((pre, idx, tot, suf))) = (shard_info(base), shard_match(base))
else {
return vec![base.to_string()];
};
let width = idx.len();
(1..=total)
.map(|i| format!("{pre}{i:0width$}-of-{tot}{suf}"))
.collect()
}
/// `shardFamilySize` : somme des tranches PRÉSENTES dans `dir`.
pub fn shard_family_size(dir: &Path, name: &str) -> i64 {
shard_family(name)
crate::gguf::shard_family(name)
.iter()
.filter_map(|n| std::fs::metadata(dir.join(n)).ok())
.filter(|m| !m.is_dir())
@@ -793,60 +615,6 @@ mod tests {
);
}
#[test]
fn resolution_du_modele() {
let dir = tempfile::tempdir().unwrap();
let models = dir.path().join("models");
let extra = dir.path().join("ailleurs");
std::fs::create_dir_all(&models).unwrap();
std::fs::create_dir_all(&extra).unwrap();
std::fs::write(extra.join("x.gguf"), b"12345").unwrap();
let extras = vec![extra.clone()];
assert_eq!(
resolve_serve_model_path(" \"x.gguf\" ", &models, &extras),
Some(extra.join("x.gguf"))
);
assert_eq!(
resolve_serve_model_path("y.gguf", &models, &extras),
Some(models.join("y.gguf")),
"introuvable : models/ par défaut"
);
assert_eq!(resolve_serve_model_path("y.bin", &models, &extras), None);
assert_eq!(resolve_serve_model_path(" ", &models, &extras), None);
assert_eq!(model_total_size(&extra.join("x.gguf")), 5);
assert_eq!(model_total_size(&models.join("y.gguf")), 0);
}
/// TestShardInfo (backend_models_shards_test.go).
#[test]
fn shard_info_cas() {
let cases: [(&str, Option<(u32, u32)>); 6] = [
("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:?})");
}
}
/// TestShardFamilyGardeLaLargeur (backend_models_shards_test.go).
#[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"]);
}
/// TestShardFamilyTailleEtManquantes, partie taille
/// (backend_models_shards_test.go) ; et modelTotalSize sur la famille.
#[test]
@@ -861,19 +629,6 @@ mod tests {
);
}
#[test]
fn chemins_comme_go() {
assert_eq!(clean("/a/b/../c/./d/"), "/a/c/d");
assert_eq!(clean("/../a"), "/a");
assert_eq!(clean("a/../../b"), "../b");
assert_eq!(clean(""), ".");
assert!(path_within("/data/models", "/data/models/x.gguf"));
assert!(path_within("/data/models/", "/data/models/sub/../x.gguf"));
assert!(!path_within("/data/models", "/data/models2/x.gguf"));
assert_eq!(base_name(r"C:\models\x.gguf"), "x.gguf");
assert_eq!(base_name("/m/x.gguf/ "), "x.gguf");
}
// --- healthCheck --------------------------------------------------------
/// Serveur local qui répond `status` sur /health (404 ailleurs).
+3 -3
View File
@@ -189,11 +189,11 @@ async fn api_interne_fermee_de_l_exterieur() {
#[tokio::test]
async fn jeton_entrant_efface_avant_relais() {
let b = banc(Some("")).await;
let (code, _, body) = get(&b, "/api/status", &[("x-loki-internal", JETON)]).await;
let (code, _, body) = get(&b, "/api/relayee-au-go", &[("x-loki-internal", JETON)]).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(&body[..], b"go:/api/status");
assert_eq!(&body[..], b"go:/api/relayee-au-go");
let vus = b.etat.vus.lock().unwrap().clone();
assert_eq!(vus, vec![("/api/status".to_string(), None)]);
assert_eq!(vus, vec![("/api/relayee-au-go".to_string(), None)]);
}
#[tokio::test]
+88
View File
@@ -80,3 +80,91 @@ async fn ram_et_vram() {
assert!(v.is_array(), "{v}");
assert!(go.relayed.lock().unwrap().is_empty());
}
fn json(body: &[u8]) -> serde_json::Value {
serde_json::from_slice(body).unwrap()
}
#[tokio::test]
async fn statut_moteur_arrete_puis_preset_externe() {
let t = tempfile::tempdir().unwrap();
let (f, go, _stop) = fakego::start(t.path().into()).await;
go.set("config", "CTX", "65536");
go.set("config", "PORT", "8099");
let (code, _, body) = fakego::get(f, "/api/status", &[]).await;
assert_eq!(code, StatusCode::OK);
let v = json(&body);
assert_eq!(v["state"], "inactive");
assert_eq!(v["active"], false);
assert_eq!(v["health"], false);
assert_eq!(v["ctx"], 65536);
assert_eq!(v["port"], 8099);
assert_eq!(v["version"], "0.15.0");
assert_eq!(v["load_pct"], -1);
assert_eq!(v["external"], false);
for k in ["warn", "load_error", "preset", "hint"] {
assert!(v[k].is_string(), "{k} : {v}");
}
// Preset externe : prêt sans moteur local.
go.set("config", "EXTERNAL", "1");
let (_, _, body) = fakego::get(f, "/api/status", &[]).await;
let v = json(&body);
assert_eq!(
(v["active"].clone(), v["health"].clone(), v["state"].clone()),
(true.into(), true.into(), "active".into())
);
assert_eq!(v["external"], true);
}
#[tokio::test]
async fn statut_explique_un_echec_de_chargement() {
let t = tempfile::tempdir().unwrap();
let (f, _go, _stop) = fakego::start(t.path().into()).await;
std::fs::write(
t.path().join("loki-engine.log"),
"llama_model_load: loading model\nllama_model_load: error loading model: unknown model architecture: 'qwen9'\n",
)
.unwrap();
let (_, _, body) = fakego::get(f, "/api/status", &[]).await;
let v = json(&body);
assert!(
v["load_error"]
.as_str()
.unwrap()
.starts_with("Modèle incompatible avec le moteur"),
"{v}"
);
let (_, _, body) = fakego::get(f, "/api/service/log?n=1", &[]).await;
assert_eq!(
json(&body)["log"],
"llama_model_load: error loading model: unknown model architecture: 'qwen9'\n"
);
}
#[tokio::test]
async fn emplacements_et_couches() {
let t = tempfile::tempdir().unwrap();
let (f, _go, _stop) = fakego::start(t.path().into()).await;
let (code, _, body) = fakego::get(f, "/api/paths", &[]).await;
assert_eq!(code, StatusCode::OK);
let v = json(&body);
for k in [
"home",
"database",
"exe",
"installed",
"workspace",
"memory",
"scripts",
"presets",
"models",
"backends",
] {
assert!(v[k].is_string(), "{k} : {v}");
}
let (_, _, body) = fakego::get(f, "/api/model/layers", &[]).await;
assert_eq!(
&body[..],
b"{\"error\":\"aucun mod\xc3\xa8le\",\"ok\":false}\n"
);
}
+1 -1
View File
@@ -208,7 +208,7 @@ async fn backend_absent_donne_502() {
.unwrap();
let (f, _stop) = facade(dead).await;
// Route relayée au Go (« / » est servie par la façade elle-même).
let req = Request::get(format!("http://{f}/api/status"))
let req = Request::get(format!("http://{f}/api/relayee-au-go"))
.body(Full::new(Bytes::new()))
.unwrap();
let resp = client().request(req).await.unwrap();