From 73db4459d737fd9317ca6bb205a77626353db93c Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 14 Oct 2026 17:57:53 +0200 Subject: [PATCH] Lot 4 : /api/status, /api/service/log, /api/paths, /api/model/layers en Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- rust/loki-rs/src/api.rs | 193 ++++++++++++++++++++++++- rust/loki-rs/src/auth.rs | 2 +- rust/loki-rs/src/engstatus.rs | 263 ++-------------------------------- rust/loki-rs/tests/lot2.rs | 6 +- rust/loki-rs/tests/lot4.rs | 88 ++++++++++++ rust/loki-rs/tests/proxy.rs | 2 +- 6 files changed, 293 insertions(+), 261 deletions(-) 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();