diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 54578fb..7836ebc 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -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) -> Response { .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::().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 { + 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::().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 { + 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(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 { + 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 { auth::json(StatusCode::NOT_FOUND, &json!({ "error": "route inconnue" })) } diff --git a/rust/loki-rs/src/auth.rs b/rust/loki-rs/src/auth.rs index 91dbd9c..b3fabb7 100644 --- a/rust/loki-rs/src/auth.rs +++ b/rust/loki-rs/src/auth.rs @@ -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 { +pub fn json(status: StatusCode, v: &T) -> Response { 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()); diff --git a/rust/loki-rs/src/engstatus.rs b/rust/loki-rs/src/engstatus.rs index 5ffcfc6..7ea8bdd 100644 --- a/rust/loki-rs/src/engstatus.rs +++ b/rust/loki-rs/src/engstatus.rs @@ -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 { - let mut raw: Vec = 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 { - 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 -/// `-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 { - 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). diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 8977dd2..c8a8d90 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -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] diff --git a/rust/loki-rs/tests/lot4.rs b/rust/loki-rs/tests/lot4.rs index 1db13b9..e198f16 100644 --- a/rust/loki-rs/tests/lot4.rs +++ b/rust/loki-rs/tests/lot4.rs @@ -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" + ); +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index fe85af9..3418dc2 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -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();