mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
Lot 16 : pilotage du moteur tenu par la façade (LOKI_ENGINE=rust)
La façade sert /api/start, /api/stop, /api/restart, /api/vram/unload et /api/vram/reload quand LOKI_ENGINE=rust (expérimental ; le Go reste le défaut). Elle lance elle-même « loki-rs engine <action> », le superviseur que le Go appelait déjà en conteneur, avec l'environnement qu'il lui passait et de quoi joindre l'API interne du Go ; sa dernière ligne d'erreur devient l'erreur rendue. Le contrôle d'avant démarrage (preflightEngine : preset externe, Strata, BIN, MODEL, tranches) et le bilan de VRAM du déchargement (gpuSettle) sont portés. La façade ne tient ce lot qu'en conteneur : sous systemd ou avec LOKI_ENGINE_SUPERVISOR=go, loki-rs ne passe pas la variable au Go. Restent au Go derrière son API interne (facade_engine.go) : le verrou de l'optimiseur avant tout démarrage (/internal/v1/service/prestart), la réponse en cours quand il tient la conversation (/internal/v1/chat/generating) et l'arrêt de la dictée quand il la tient (/internal/v1/dictate/stop). Parité : parity_engine_test.go tire du Go dix bilans de VRAM, vingt-six installations passées au préflight, les messages du superviseur et dix réponses de route, rejoués par tests/enginectl.rs ; démarrage, arrêt, redémarrage, déchargement chiffré et rechargement vérifiés de bout en bout avec un faux superviseur et un faux nvidia-smi ; TestFacadeMoteur côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
19 files changed
+2225
-2
No files matched your search
@@ -42,6 +42,8 @@ pub struct Ctx {
|
||||
pub mem: Option<std::sync::Arc<crate::memory::Memory>>,
|
||||
/// Presets tenus par la façade (`LOKI_PRESETS=rust`).
|
||||
pub presets: bool,
|
||||
/// Pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`).
|
||||
pub engine_ctl: Option<std::sync::Arc<crate::enginectl::EngineCtl>>,
|
||||
/// Jeton de l'API interne : une requête qui le porte vient du Go.
|
||||
pub internal_token: String,
|
||||
}
|
||||
@@ -58,6 +60,7 @@ impl Ctx {
|
||||
|| self.push.is_some()
|
||||
|| self.mem.is_some()
|
||||
|| self.presets
|
||||
|| self.engine_ctl.is_some()
|
||||
}
|
||||
|
||||
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
//! Pilotage du moteur tenu par la façade (lot 16 de la migration,
|
||||
//! `LOKI_ENGINE=rust` ; expérimental, le Go reste le défaut) :
|
||||
//!
|
||||
//! - `/api/start|stop|restart` — port de `svcHandler` (`web_api.go`) et de la
|
||||
//! voie conteneur de `serviceAction` (`sys_service.go`,
|
||||
//! `sys_service_rust_linux.go`) : la façade lance elle-même
|
||||
//! `loki-rs engine <action>`, le superviseur que le Go appelait déjà ;
|
||||
//! - `/api/vram/unload` et `/api/vram/reload` — port de `handleVramUnload` et
|
||||
//! `handleVramReload` (`web_vram.go`), bilan de VRAM (`gpuSettle`) compris ;
|
||||
//! - `preflightEngine` (`sys_service.go`) : ce sans quoi le moteur ne PEUT
|
||||
//! pas démarrer, dit avant de le lancer.
|
||||
//!
|
||||
//! Restent au Go, derrière son API interne, jusqu'au lot qui les portera : le
|
||||
//! verrou de l'optimiseur (`/internal/v1/service/prestart` avant tout
|
||||
//! démarrage, `/internal/v1/tune/guard`), la génération en cours quand il
|
||||
//! tient la conversation (`/internal/v1/chat/generating`) et l'arrêt de la
|
||||
//! dictée quand il la tient (`/internal/v1/dictate/stop`).
|
||||
//!
|
||||
//! La façade ne tient ce lot qu'en conteneur, avec son superviseur : sous
|
||||
//! systemd ou avec `LOKI_ENGINE_SUPERVISOR=go`, le Go le garde ([`usable`]).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use http_body_util::{BodyExt, Limited};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::header::{ALLOW, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use regex::Regex;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::api::Ctx;
|
||||
use crate::auth::json as send_json;
|
||||
use crate::gguf;
|
||||
use crate::gojson::{self, Kind};
|
||||
use crate::paths::{go_dir, go_is_abs, go_join};
|
||||
use crate::proxy::Body;
|
||||
use crate::serve::{self, Cfg};
|
||||
use crate::store::Store;
|
||||
use crate::sysinfo::{self, GpuStat};
|
||||
|
||||
/// Ce que la façade sait du superviseur du moteur.
|
||||
pub struct EngineCtl {
|
||||
/// Le binaire lancé en `engine <action>` (`loki-rs` lui-même).
|
||||
pub bin: PathBuf,
|
||||
/// Variables passées au superviseur, en plus de l'environnement de la
|
||||
/// façade : `LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE` (comme
|
||||
/// `rustSvcAction`), et l'adresse et le jeton de l'API interne du Go, que
|
||||
/// `loki-rs engine serve` (`LOKI_SERVE=rust`) lit.
|
||||
pub env: Vec<(String, String)>,
|
||||
/// nvidia-smi (un faux dans les tests).
|
||||
pub nvidia_smi: String,
|
||||
/// Pause entre deux lectures du bilan de VRAM (500 ms).
|
||||
pub settle_pause: Duration,
|
||||
}
|
||||
|
||||
impl EngineCtl {
|
||||
pub fn new(bin: PathBuf, env: Vec<(String, String)>) -> Self {
|
||||
EngineCtl {
|
||||
bin,
|
||||
env,
|
||||
nvidia_smi: "nvidia-smi".into(),
|
||||
settle_pause: Duration::from_millis(500),
|
||||
}
|
||||
}
|
||||
|
||||
/// `rustSvcAction` : `loki-rs engine <action>`. Sa sortie s'affiche telle
|
||||
/// quelle ; en cas d'échec, sa dernière ligne d'erreur devient l'erreur
|
||||
/// rendue (c'est elle que l'UI montre), sans le préfixe du binaire.
|
||||
pub async fn run(&self, action: &str) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
let out = tokio::process::Command::new(&self.bin)
|
||||
.args(["engine", action])
|
||||
.envs(self.env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("loki-rs engine {action} : {e}"))?;
|
||||
let _ = std::io::stdout().write_all(&out.stdout);
|
||||
let _ = std::io::stderr().write_all(&out.stderr);
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let msg = last_error_line(&String::from_utf8_lossy(&out.stderr));
|
||||
if !msg.is_empty() {
|
||||
return Err(msg);
|
||||
}
|
||||
Err(format!(
|
||||
"loki-rs engine {action} : {}",
|
||||
exit_text(&out.status)
|
||||
))
|
||||
}
|
||||
|
||||
/// `gpuStats` : lecture FRAÎCHE, vide sans mesure.
|
||||
async fn gpus(&self) -> Vec<GpuStat> {
|
||||
sysinfo::nvidia_smi_query_bin(&self.nvidia_smi)
|
||||
.await
|
||||
.map(|o| sysinfo::parse_nvidia_smi(&String::from_utf8_lossy(&o)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ProcessState.String()` du Go : « exit status 1 », « signal: killed ».
|
||||
fn exit_text(st: &std::process::ExitStatus) -> String {
|
||||
if let Some(c) = st.code() {
|
||||
return format!("exit status {c}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
if let Some(s) = st.signal() {
|
||||
let name = match s {
|
||||
1 => "hangup".to_string(),
|
||||
2 => "interrupt".to_string(),
|
||||
6 => "aborted".to_string(),
|
||||
9 => "killed".to_string(),
|
||||
11 => "segmentation fault".to_string(),
|
||||
15 => "terminated".to_string(),
|
||||
n => format!("signal {n}"),
|
||||
};
|
||||
return format!("signal: {name}");
|
||||
}
|
||||
}
|
||||
st.to_string()
|
||||
}
|
||||
|
||||
/// La façade peut-elle tenir ce lot ? `Err(raison)` sinon : sous systemd
|
||||
/// (`systemdAvailable`), le Go pilote son unité ; `LOKI_ENGINE_SUPERVISOR=go`
|
||||
/// est la porte de sortie du superviseur Rust.
|
||||
pub fn usable(container_env: Option<&str>, supervisor_env: Option<&str>) -> Result<(), String> {
|
||||
if supervisor_env.is_some_and(|v| v.trim().eq_ignore_ascii_case("go")) {
|
||||
return Err("LOKI_ENGINE_SUPERVISOR=go".into());
|
||||
}
|
||||
if container_env != Some("1") && Path::new("/run/systemd/system").is_dir() {
|
||||
return Err("systemd pilote le moteur".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `lastErrorLine`.
|
||||
pub fn last_error_line(s: &str) -> String {
|
||||
let t = s.trim();
|
||||
let last = t.rsplit('\n').next().unwrap_or("").trim();
|
||||
last.strip_prefix("[loki-rs engine]")
|
||||
.unwrap_or(last)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
static ANSI: LazyLock<Regex> = LazyLock::new(|| Regex::new("\x1b\\[[0-9;]*m").expect("motif ANSI"));
|
||||
|
||||
/// `plainErr` : les séquences ANSI des messages écrits pour le terminal
|
||||
/// retirées.
|
||||
pub fn plain_err(s: &str) -> String {
|
||||
ANSI.replace_all(s, "").into_owned()
|
||||
}
|
||||
|
||||
/// `gpuUsedMB`.
|
||||
pub fn gpu_used_mb(gpus: &[GpuStat]) -> i64 {
|
||||
gpus.iter().map(|g| g.used).sum()
|
||||
}
|
||||
|
||||
/// `gpuSettle` : attend que la VRAM ait fini de redescendre avant de la
|
||||
/// relire. Une mesure ratée n'est pas « 0 Mo » (sautée) ; un palier ne
|
||||
/// conclut qu'une fois la baisse commencée ; deux lectures égales de suite
|
||||
/// après la baisse, et la valeur est rendue — sinon la dernière lecture après
|
||||
/// huit essais. `sample` rend (Mo occupés, lecture réussie).
|
||||
pub async fn gpu_settle<F, Fut>(before: i64, mut sample: F, pause: Duration) -> i64
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = (i64, bool)>,
|
||||
{
|
||||
let (mut last, mut lu) = (before, false);
|
||||
let (mut baisse, mut palier) = (false, 0);
|
||||
for _ in 0..8 {
|
||||
if !pause.is_zero() {
|
||||
tokio::time::sleep(pause).await;
|
||||
}
|
||||
let (cur, ok) = sample().await;
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
if cur < before {
|
||||
baisse = true;
|
||||
}
|
||||
if lu && cur == last {
|
||||
palier += 1;
|
||||
} else {
|
||||
palier = 0;
|
||||
}
|
||||
(last, lu) = (cur, true);
|
||||
if baisse && palier >= 1 {
|
||||
return cur;
|
||||
}
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
/// `strataPreflight`.
|
||||
fn strata_preflight(cfg: &Cfg) -> Result<(), String> {
|
||||
let path = serve::trim(serve::get(cfg, "STRATA_CONFIG"));
|
||||
if path.is_empty() {
|
||||
return Err(
|
||||
"STRATA_CONFIG non défini : réinstalle le modèle depuis Paramètres → Moteur → Strata"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if std::fs::metadata(path).is_err() {
|
||||
return Err(format!(
|
||||
"configuration de Strata introuvable : {path} — réinstalle le modèle"
|
||||
));
|
||||
}
|
||||
let py = go_join(&[&go_dir(path), ".venv", "bin", "python"]);
|
||||
if std::fs::metadata(&py).is_err() {
|
||||
return Err(format!(
|
||||
"environnement Python de Strata absent ({py}) : réinstalle le modèle"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `preflightEngine` (message déjà débarrassé de ses couleurs, comme
|
||||
/// `plainErr` le rend à l'UI). Entrées/sorties bloquantes.
|
||||
///
|
||||
/// - `stored_dirs` / `env_model_dirs` : voir
|
||||
/// [`gguf::resolve_serve_model_path`].
|
||||
pub fn preflight_engine(
|
||||
home: &Path,
|
||||
cfg: &Cfg,
|
||||
stored_dirs: &[String],
|
||||
env_model_dirs: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// Preset externe : aucun llama-server local à lancer.
|
||||
if serve::is_external(cfg) {
|
||||
return Ok(());
|
||||
}
|
||||
// Strata n'a ni BIN ni MODEL : son installeur a tout rangé.
|
||||
if serve::is_strata(cfg) {
|
||||
return strata_preflight(cfg);
|
||||
}
|
||||
let mut bin = serve::trim(serve::get(cfg, "BIN")).to_string();
|
||||
if bin.is_empty() {
|
||||
return Err(
|
||||
"BIN non défini — installe un moteur : loki llamacpp install (ou renseigne BIN avec loki edit)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if !go_is_abs(&bin) {
|
||||
bin = go_join(&[&home.to_string_lossy(), &bin]);
|
||||
}
|
||||
let eng = serve::run::EnginePaths {
|
||||
home: home.to_path_buf(),
|
||||
};
|
||||
if std::fs::metadata(eng.prebuilt_resolve_bin(&bin)).is_err() {
|
||||
return Err(format!(
|
||||
"moteur introuvable : {bin} — relance loki llamacpp install"
|
||||
));
|
||||
}
|
||||
let model = serve::trim(serve::get(cfg, "MODEL"));
|
||||
if model.is_empty() {
|
||||
return Err(format!(
|
||||
"MODEL non défini — indique un .gguf avec loki edit (dossier des modèles : {})",
|
||||
gguf::models_dir(home)
|
||||
));
|
||||
}
|
||||
let p = gguf::resolve_serve_model_path(model, home, stored_dirs, env_model_dirs)
|
||||
.map_err(|e| format!("MODEL={model} : {e}"))?;
|
||||
let p = p.to_string_lossy().into_owned();
|
||||
if std::fs::metadata(&p).is_err() {
|
||||
return Err(format!(
|
||||
"modèle introuvable : {p} — corrige MODEL avec loki edit"
|
||||
));
|
||||
}
|
||||
// Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au
|
||||
// moment où llama-server réclame un tenseur absent.
|
||||
let (dir, base) = (go_dir(&p), go_base(&p));
|
||||
let missing = gguf::shard_family_missing(Path::new(&dir), &base);
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"modèle incomplet : il manque {} dans {dir} — ce modèle tient en {} fichiers, télécharge-les tous",
|
||||
missing.join(", "),
|
||||
gguf::shard_family(&base).len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `filepath.Base`.
|
||||
fn go_base(p: &str) -> String {
|
||||
let t = p.trim_end_matches('/');
|
||||
if t.is_empty() {
|
||||
return if p.is_empty() { ".".into() } else { "/".into() };
|
||||
}
|
||||
t.rsplit('/').next().unwrap_or(t).to_string()
|
||||
}
|
||||
|
||||
/// Les routes servies par ce module.
|
||||
pub fn handles(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/api/start" | "/api/stop" | "/api/restart" | "/api/vram/unload" | "/api/vram/reload"
|
||||
)
|
||||
}
|
||||
|
||||
/// Avant un démarrage : refus pendant une optimisation, puis ménage d'un
|
||||
/// verrou périmé — au Go (`enginePrestart`).
|
||||
async fn prestart(store: &Store) -> Result<(), String> {
|
||||
let (code, v) = store
|
||||
.internal_post("/internal/v1/service/prestart", &json!({}))
|
||||
.await
|
||||
.map_err(|e| format!("verrou de l'optimiseur : {e}"))?;
|
||||
if !code.is_success() {
|
||||
return Err(format!("verrou de l'optimiseur : {code}"));
|
||||
}
|
||||
match v.get("error").and_then(Value::as_str) {
|
||||
Some(e) if !e.is_empty() => Err(e.to_string()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `serviceAction` en conteneur : garde-fous du Go avant un démarrage, preset
|
||||
/// externe (rien à lancer : `loki serve` sortirait aussitôt et le superviseur
|
||||
/// le prendrait pour un plantage), puis le superviseur.
|
||||
pub async fn service_action(ctx: &Ctx, ctl: &EngineCtl, action: &str) -> Result<(), String> {
|
||||
if action == "start" || action == "restart" {
|
||||
prestart(&ctx.store).await?;
|
||||
}
|
||||
match action {
|
||||
"start" | "restart" => {
|
||||
if serve::is_external(&ctx.store.config().await) {
|
||||
if action == "restart" {
|
||||
let _ = ctl.run("stop").await;
|
||||
}
|
||||
println!("[info] preset externe actif : pas de moteur local à démarrer");
|
||||
return Ok(());
|
||||
}
|
||||
ctl.run(action).await
|
||||
}
|
||||
"stop" | "status" => ctl.run(action).await,
|
||||
other => Err(format!("action inconnue: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `serviceIsActive` en conteneur : le PID du fichier vit.
|
||||
fn service_is_active(ctx: &Ctx) -> bool {
|
||||
pid_alive(crate::engine::read_pid(&ctx.engine.pid()))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pid_alive(pid: i32) -> bool {
|
||||
crate::engine::proctree::linux::pid_alive(pid)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn pid_alive(_: i32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// La conversation écrit-elle une réponse ? Celle de la façade si elle la
|
||||
/// tient, sinon celle du Go.
|
||||
async fn generating(ctx: &Ctx) -> bool {
|
||||
if let Some(chat) = &ctx.chat {
|
||||
return chat.sessions.conv().is_generating();
|
||||
}
|
||||
ctx.store
|
||||
.internal_json(Method::GET, "/internal/v1/chat/generating", None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.get("generating").and_then(Value::as_bool))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `asrShutdownVite` : la dictée de la façade si elle la tient, sinon celle
|
||||
/// du Go.
|
||||
async fn dictate_stop(ctx: &Ctx) {
|
||||
if let Some(d) = &ctx.dictate {
|
||||
d.shutdown_fast().await;
|
||||
return;
|
||||
}
|
||||
if let Err(e) = ctx
|
||||
.store
|
||||
.internal_post("/internal/v1/dictate/stop", &json!({}))
|
||||
.await
|
||||
{
|
||||
eprintln!("[dictée] arrêt demandé au Go : {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Le verrou de l'optimiseur (au Go) : `Some(réponse 409)` s'il tient le
|
||||
/// moteur.
|
||||
async fn tune_deny(store: &Store) -> Option<Response<Body>> {
|
||||
let v = store
|
||||
.internal_json(Method::GET, "/internal/v1/tune/guard", None)
|
||||
.await
|
||||
.ok()?;
|
||||
if v.get("busy").and_then(Value::as_bool) == Some(true) {
|
||||
return Some(send_json(
|
||||
StatusCode::CONFLICT,
|
||||
&json!({ "ok": false, "tuning": true, "error": v.get("error").and_then(Value::as_str).unwrap_or("") }),
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `postOnly` : ces routes arrêtent ou relancent des processus ; un simple GET
|
||||
/// (une balise `<img>` sur une page tierce) ne doit pas y suffire.
|
||||
fn post_only(method: &Method) -> Option<Response<Body>> {
|
||||
if method == Method::POST {
|
||||
return None;
|
||||
}
|
||||
let mut r = send_json(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
&json!({ "ok": false, "error": format!("méthode {method} refusée : POST attendu") }),
|
||||
);
|
||||
r.headers_mut()
|
||||
.insert(ALLOW, HeaderValue::from_static("POST"));
|
||||
Some(r)
|
||||
}
|
||||
|
||||
const UNLOAD_REQ_TYPE: &str = r#"struct { Force bool "json:\"force\"" }"#;
|
||||
|
||||
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
|
||||
pub async fn serve(
|
||||
ctx: &Ctx,
|
||||
ctl: &EngineCtl,
|
||||
req: Request<Incoming>,
|
||||
from_go: bool,
|
||||
) -> Response<Body> {
|
||||
let (parts, body) = req.into_parts();
|
||||
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
|
||||
return *refus;
|
||||
}
|
||||
match parts.uri.path() {
|
||||
p @ ("/api/start" | "/api/stop" | "/api/restart") => {
|
||||
let action = &p["/api/".len()..];
|
||||
let res = service_action(ctx, ctl, action).await;
|
||||
let msg = match &res {
|
||||
Ok(()) => "ok".to_string(),
|
||||
Err(e) => e.clone(),
|
||||
};
|
||||
send_json(StatusCode::OK, &json!({ "ok": res.is_ok(), "out": msg }))
|
||||
}
|
||||
"/api/vram/unload" => {
|
||||
if let Some(r) = post_only(&parts.method) {
|
||||
return r;
|
||||
}
|
||||
let raw = Limited::new(body, 1 << 20)
|
||||
.collect()
|
||||
.await
|
||||
.map(|b| b.to_bytes().to_vec())
|
||||
.unwrap_or_default();
|
||||
let force = gojson::decode_struct(&raw, UNLOAD_REQ_TYPE, &[("force", Kind::Bool)])
|
||||
.bool("force");
|
||||
if !force && generating(ctx).await {
|
||||
return send_json(
|
||||
StatusCode::CONFLICT,
|
||||
&json!({ "ok": false, "generating": true,
|
||||
"error": "une réponse est en cours d'écriture — décharger maintenant l'interromprait" }),
|
||||
);
|
||||
}
|
||||
unload(ctx, ctl).await
|
||||
}
|
||||
"/api/vram/reload" => {
|
||||
if let Some(r) = post_only(&parts.method) {
|
||||
return r;
|
||||
}
|
||||
if let Some(r) = tune_deny(&ctx.store).await {
|
||||
return r;
|
||||
}
|
||||
if service_is_active(ctx) {
|
||||
return send_json(StatusCode::OK, &json!({ "ok": true, "already": true }));
|
||||
}
|
||||
let cfg = ctx.store.config().await;
|
||||
let d = crate::models::model_dirs(ctx).await;
|
||||
let pre = tokio::task::spawn_blocking(move || {
|
||||
preflight_engine(&d.home, &cfg, &d.stored, d.env.as_deref())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
if let Err(e) = pre {
|
||||
return send_json(
|
||||
StatusCode::OK,
|
||||
&json!({ "ok": false, "error": plain_err(&e) }),
|
||||
);
|
||||
}
|
||||
if let Err(e) = service_action(ctx, ctl, "start").await {
|
||||
return send_json(
|
||||
StatusCode::OK,
|
||||
&json!({ "ok": false, "error": plain_err(&e) }),
|
||||
);
|
||||
}
|
||||
send_json(StatusCode::OK, &json!({ "ok": true }))
|
||||
}
|
||||
_ => send_json(StatusCode::NOT_FOUND, &json!({ "error": "route inconnue" })),
|
||||
}
|
||||
}
|
||||
|
||||
/// `handleVramUnload` après les refus : moteur arrêté, dictée coupée, bilan
|
||||
/// de la VRAM rendue.
|
||||
async fn unload(ctx: &Ctx, ctl: &EngineCtl) -> Response<Body> {
|
||||
let gpus = ctl.gpus().await;
|
||||
let before = gpu_used_mb(&gpus);
|
||||
// `engineNeedsStop` : en conteneur, actif suffit (les états transitoires
|
||||
// n'existent que sous systemd, où ce lot reste au Go).
|
||||
let was_active = service_is_active(ctx);
|
||||
let mut errs = Vec::new();
|
||||
if was_active && let Err(e) = service_action(ctx, ctl, "stop").await {
|
||||
errs.push(format!("arrêt du moteur : {}", plain_err(&e)));
|
||||
}
|
||||
dictate_stop(ctx).await;
|
||||
let mut after = before;
|
||||
if !gpus.is_empty() {
|
||||
after = gpu_settle(
|
||||
before,
|
||||
|| async {
|
||||
let g = ctl.gpus().await;
|
||||
(gpu_used_mb(&g), !g.is_empty())
|
||||
},
|
||||
ctl.settle_pause,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// Les jauges ne doivent pas montrer, deux secondes encore, une carte
|
||||
// pleine qu'on vient d'annoncer libérée.
|
||||
sysinfo::GPU_MONITOR.invalidate().await;
|
||||
let freed = (before - after).max(0);
|
||||
send_json(
|
||||
StatusCode::OK,
|
||||
&json!({
|
||||
"ok": errs.is_empty(),
|
||||
"error": errs.join(" ; "),
|
||||
"was_active": was_active,
|
||||
"active": service_is_active(ctx),
|
||||
"gpu": !gpus.is_empty(),
|
||||
"before_mb": before,
|
||||
"used_mb": after,
|
||||
"freed_mb": freed,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_comme_go() {
|
||||
assert_eq!(go_base("/a/b.gguf"), "b.gguf");
|
||||
assert_eq!(go_base("/a/b/"), "b");
|
||||
assert_eq!(go_base("/"), "/");
|
||||
assert_eq!(go_base(""), ".");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn porte_de_sortie() {
|
||||
assert!(usable(Some("1"), Some(" Go ")).is_err());
|
||||
assert!(usable(Some("1"), None).is_ok());
|
||||
assert!(usable(Some("1"), Some("rust")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -665,6 +665,16 @@ pub fn shard_family(name: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `shardFamilyMissing` : tranches ABSENTES de `dir` (un dossier du même nom
|
||||
/// ne compte pas). Un modèle dont il manque une tranche démarre puis meurt sur
|
||||
/// une erreur de tenseur introuvable.
|
||||
pub fn shard_family_missing(dir: &Path, name: &str) -> Vec<String> {
|
||||
shard_family(name)
|
||||
.into_iter()
|
||||
.filter(|n| !std::fs::metadata(dir.join(n)).is_ok_and(|m| !m.is_dir()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- dossiers de modèles (backend_model_dirs.go) ---------------------------
|
||||
|
||||
/// `modelsDir()` : `LOKI_HOME/models`.
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod convfiles;
|
||||
pub mod dictate;
|
||||
pub mod downloads;
|
||||
pub mod engine;
|
||||
pub mod enginectl;
|
||||
pub mod engstatus;
|
||||
pub mod gguf;
|
||||
pub mod gojson;
|
||||
|
||||
@@ -148,6 +148,20 @@ fn serve() -> ExitCode {
|
||||
// LOKI_PRESETS=rust : les presets (liste, éditeur, aperçus, externes,
|
||||
// bascule ; l'application au moteur reste au Go).
|
||||
let presets_rust = held_by_facade("LOKI_PRESETS");
|
||||
// LOKI_ENGINE=rust : le pilotage du moteur (démarrage, arrêt,
|
||||
// déchargement de la VRAM), en conteneur avec le superviseur Rust
|
||||
// seulement — sous systemd, le Go pilote son unité.
|
||||
let engine_rust = held_by_facade("LOKI_ENGINE")
|
||||
&& match loki_rs::enginectl::usable(
|
||||
std::env::var("LOKI_CONTAINER").ok().as_deref(),
|
||||
std::env::var("LOKI_ENGINE_SUPERVISOR").ok().as_deref(),
|
||||
) {
|
||||
Ok(()) => true,
|
||||
Err(why) => {
|
||||
eprintln!("[loki-rs] pilotage du moteur tenu par le Go ({why})");
|
||||
false
|
||||
}
|
||||
};
|
||||
let mut env = vec![
|
||||
(auth::INTERNAL_ENV.to_string(), token.clone()),
|
||||
// Adresse de l'API interne du Go, héritée par ses enfants :
|
||||
@@ -163,6 +177,7 @@ fn serve() -> ExitCode {
|
||||
("LOKI_PUSH", push_rust),
|
||||
("LOKI_MEM", mem_rust),
|
||||
("LOKI_PRESETS", presets_rust),
|
||||
("LOKI_ENGINE", engine_rust),
|
||||
];
|
||||
for (var, on) in held {
|
||||
if on {
|
||||
@@ -172,6 +187,7 @@ fn serve() -> ExitCode {
|
||||
if held.iter().any(|(_, on)| *on) {
|
||||
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
|
||||
}
|
||||
let go_bin_path = go_bin.clone();
|
||||
let sup = child::Supervisor::spawn(go_bin, go_port, env);
|
||||
wait_for(backend, Duration::from_secs(30)).await;
|
||||
let engine_paths = engine::Paths::from_env();
|
||||
@@ -230,6 +246,26 @@ fn serve() -> ExitCode {
|
||||
};
|
||||
eprintln!("[loki-rs] façade : http://{addr} → {backend}");
|
||||
let mem_home = engine_paths.home.clone();
|
||||
// Le superviseur reçoit ce que le Go lui passait (rustSvcAction), et de
|
||||
// quoi joindre l'API interne du Go pour « loki-rs engine serve ».
|
||||
let engine_ctl = engine_rust.then(|| {
|
||||
std::sync::Arc::new(loki_rs::enginectl::EngineCtl::new(
|
||||
exe(),
|
||||
vec![
|
||||
(
|
||||
"LOKI_GO_BIN".into(),
|
||||
go_bin_path.to_string_lossy().into_owned(),
|
||||
),
|
||||
(
|
||||
"LOKI_HOME".into(),
|
||||
engine_paths.home.to_string_lossy().into_owned(),
|
||||
),
|
||||
("LOKI_SERVICE".into(), engine_paths.service.clone()),
|
||||
("LOKI_GO_ADDR".into(), backend.to_string()),
|
||||
(auth::INTERNAL_ENV.into(), token.clone()),
|
||||
],
|
||||
))
|
||||
});
|
||||
let cfg = proxy::Config {
|
||||
backend,
|
||||
internal_token: token,
|
||||
@@ -243,6 +279,7 @@ fn serve() -> ExitCode {
|
||||
push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())),
|
||||
mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))),
|
||||
presets: presets_rust,
|
||||
engine_ctl,
|
||||
};
|
||||
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
|
||||
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
|
||||
|
||||
@@ -79,6 +79,9 @@ pub struct Config {
|
||||
pub mem: Option<Arc<crate::memory::Memory>>,
|
||||
/// Presets tenus par la façade (`LOKI_PRESETS=rust`).
|
||||
pub presets: bool,
|
||||
/// Pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`) :
|
||||
/// démarrage, arrêt, déchargement de la VRAM ; `None` : au Go.
|
||||
pub engine_ctl: Option<Arc<crate::enginectl::EngineCtl>>,
|
||||
}
|
||||
|
||||
struct Proxy {
|
||||
@@ -113,6 +116,7 @@ pub async fn serve(
|
||||
push: cfg.push,
|
||||
mem: cfg.mem,
|
||||
presets: cfg.presets,
|
||||
engine_ctl: cfg.engine_ctl,
|
||||
internal_token: cfg.internal_token.clone(),
|
||||
},
|
||||
});
|
||||
@@ -200,6 +204,11 @@ impl Proxy {
|
||||
{
|
||||
return chatapi::serve(&self.ctx, chat, req, from_go).await;
|
||||
}
|
||||
if let Some(e) = &self.ctx.engine_ctl
|
||||
&& crate::enginectl::handles(path)
|
||||
{
|
||||
return crate::enginectl::serve(&self.ctx, e, req, from_go).await;
|
||||
}
|
||||
if self.ctx.presets && crate::presets_api::handles(path) {
|
||||
return crate::presets_api::serve(&self.ctx, req, from_go).await;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ pub async fn nvidia_smi_query() -> io::Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// [`nvidia_smi_query`] avec un exécutable choisi (tests).
|
||||
async fn nvidia_smi_query_bin(bin: &str) -> io::Result<Vec<u8>> {
|
||||
pub(crate) async fn nvidia_smi_query_bin(bin: &str) -> io::Result<Vec<u8>> {
|
||||
let mut cmd = tokio::process::Command::new(bin);
|
||||
cmd.args([
|
||||
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
|
||||
|
||||
Reference in new issue
Block a user