mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 20:57:05 +02:00
Les neuf routes que la façade sert toujours (ping, status, config, ligne de commande du moteur, couches du modèle, chemins, RAM, VRAM, journal du service) arrivent désormais à la façade par le tunnel du relais aussi : withFacadeChat les lui remet dès qu'elle tourne (facade_core.go). Les handlers du Go ne servent plus que sans façade (LOKI_FACADE=0). Côté Rust, une requête relayée par le Go (jeton interne) n'est pas réauthentifiée, comme pour les routes des lots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
387 lines
14 KiB
Rust
387 lines
14 KiB
Rust
//! Routes `/api/*` servies par Rust. Toute route listée dans [`handles`] est
|
|
//! traitée ici ; les autres partent au Go.
|
|
//!
|
|
//! Chaque route de pilotage passe par [`Ctx::guard`], port de
|
|
//! `requireWebAuth` (clé relue à chaque requête, fermeture sur erreur).
|
|
|
|
use bytes::Bytes;
|
|
use hyper::body::Incoming;
|
|
use hyper::header::HeaderMap;
|
|
use hyper::{Method, Request, Response, StatusCode};
|
|
use serde_json::json;
|
|
|
|
use crate::auth::{self, HostEnv, InternalClient};
|
|
use crate::engine;
|
|
use crate::proxy::Body;
|
|
use crate::store::{BK_STATE, Store};
|
|
use crate::{engstatus, gguf, paths, presets, sysinfo};
|
|
use std::collections::BTreeMap;
|
|
|
|
/// Ce dont les routes ont besoin, partagé par toutes les connexions.
|
|
#[derive(Clone)]
|
|
pub struct Ctx {
|
|
pub store: Store,
|
|
pub internal: InternalClient,
|
|
pub host_env: HostEnv,
|
|
/// `$LOKI_HOME` et nom du service moteur (fichiers PID / journal).
|
|
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>>,
|
|
/// 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 models: Option<std::sync::Arc<crate::models::Models>>,
|
|
/// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) ; `None` : au Go.
|
|
pub dictate: Option<std::sync::Arc<crate::dictate::Dictation>>,
|
|
/// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`).
|
|
pub settings: bool,
|
|
/// Fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`).
|
|
pub files: Option<std::sync::Arc<crate::convfiles::ConvFiles>>,
|
|
/// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`).
|
|
pub push: Option<std::sync::Arc<crate::push::Push>>,
|
|
/// Mémoire tenue par la façade (`LOKI_MEM=rust`).
|
|
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>>,
|
|
/// Interrupteurs système tenus par la façade (`LOKI_SYSTEM=rust`).
|
|
pub system: Option<std::sync::Arc<crate::system::System>>,
|
|
/// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`).
|
|
pub projects: bool,
|
|
/// Serveurs MCP tenus par la façade (`LOKI_MCP=rust`).
|
|
pub mcp: Option<std::sync::Arc<crate::mcp::Mcp>>,
|
|
/// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`).
|
|
pub update: bool,
|
|
/// Backends llama.cpp et job des moteurs tenus par la façade
|
|
/// (`LOKI_BACKENDS=rust`).
|
|
pub backends: Option<std::sync::Arc<crate::backends::Backends>>,
|
|
/// Panneau « moteur de l'image » tenu par la façade
|
|
/// (`LOKI_ENGINE_IMAGE=rust`, avec les backends).
|
|
pub engineimg: Option<std::sync::Arc<crate::engineimg::EngineImg>>,
|
|
/// Strata (écran, réglages, installation) tenu par la façade
|
|
/// (`LOKI_STRATA=rust`, avec les backends).
|
|
pub strata: Option<std::sync::Arc<crate::strata::api::Strata>>,
|
|
/// Tâches planifiées (routes, planificateur, tâches script) tenues par la
|
|
/// façade (`LOKI_TASKS=rust`).
|
|
pub tasks: Option<std::sync::Arc<crate::tasks::Tasks>>,
|
|
/// Banc d'essai tenu par la façade (`LOKI_BENCH=rust`).
|
|
pub bench: Option<std::sync::Arc<crate::bench::job::Bench>>,
|
|
/// Optimiseur tenu par la façade (`LOKI_TUNE=rust`).
|
|
pub tune: Option<std::sync::Arc<crate::tune::job::Tune>>,
|
|
/// Jeton de l'API interne : une requête qui le porte vient du Go.
|
|
pub internal_token: String,
|
|
}
|
|
|
|
impl Ctx {
|
|
/// La façade tient-elle au moins un lot (le Go lui remet alors le trafic
|
|
/// de ce lot qui lui arrive par le tunnel) ?
|
|
pub fn holds_any(&self) -> bool {
|
|
self.chat.is_some()
|
|
|| self.models.is_some()
|
|
|| self.dictate.is_some()
|
|
|| self.settings
|
|
|| self.files.is_some()
|
|
|| self.push.is_some()
|
|
|| self.mem.is_some()
|
|
|| self.presets
|
|
|| self.engine_ctl.is_some()
|
|
|| self.system.is_some()
|
|
|| self.projects
|
|
|| self.mcp.is_some()
|
|
|| self.update
|
|
|| self.backends.is_some()
|
|
|| self.engineimg.is_some()
|
|
|| self.strata.is_some()
|
|
|| self.tasks.is_some()
|
|
|| self.bench.is_some()
|
|
|| self.tune.is_some()
|
|
}
|
|
|
|
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
|
|
/// lui (tunnel du relais, redistribution E2E).
|
|
pub fn from_go(&self, headers: &HeaderMap) -> bool {
|
|
!self.internal_token.is_empty()
|
|
&& headers.get(crate::auth::INTERNAL_HEADER).is_some_and(|v| {
|
|
auth::constant_time_eq(v.as_bytes(), self.internal_token.as_bytes())
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Ctx {
|
|
/// `Ok` = requête autorisée (avec la version du Go, pour `/api/ping`) ;
|
|
/// sinon la réponse de refus.
|
|
pub async fn guard(&self, headers: &HeaderMap) -> Result<String, Box<Response<Body>>> {
|
|
let info = self.internal.auth().await;
|
|
if let Some(refus) = auth::authorize(headers, &info, &self.host_env) {
|
|
return Err(Box::new(refus));
|
|
}
|
|
Ok(info.map(|i| i.version).unwrap_or_default())
|
|
}
|
|
}
|
|
|
|
/// Cette route est-elle servie par Rust ?
|
|
pub fn handles(_method: &Method, path: &str) -> bool {
|
|
matches!(
|
|
path,
|
|
"/api/ping"
|
|
| "/api/config"
|
|
| "/api/engine/cmdline"
|
|
| "/api/ram"
|
|
| "/api/vram"
|
|
| "/api/status"
|
|
| "/api/service/log"
|
|
| "/api/paths"
|
|
| "/api/model/layers"
|
|
)
|
|
}
|
|
|
|
/// `from_go` : relayée par le Go (tunnel du relais), qui l'a déjà
|
|
/// authentifiée.
|
|
pub async fn serve(ctx: &Ctx, 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,
|
|
}
|
|
};
|
|
match parts.uri.path() {
|
|
// Vérifie la connectivité ET la clé (200 = bonne clé, 401 = mauvaise).
|
|
"/api/ping" => auth::json(
|
|
StatusCode::OK,
|
|
&json!({ "ok": true, "service": "loki", "version": version }),
|
|
),
|
|
// ReadConfig() du Go : json.Encoder trie les clés d'une map, d'où le
|
|
// BTreeMap (un HashMap sortirait dans un ordre aléatoire).
|
|
"/api/config" => {
|
|
let cfg: BTreeMap<String, String> = ctx.store.config().await.into_iter().collect();
|
|
auth::json(StatusCode::OK, &json!(cfg))
|
|
}
|
|
// Commande du dernier lancement du moteur (engine_cmdline.go).
|
|
"/api/engine/cmdline" => {
|
|
let cmdline = ctx.store.get_str(BK_STATE, "engine_cmdline").await;
|
|
auth::json(StatusCode::OK, &json!({ "ok": true, "cmdline": cmdline }))
|
|
}
|
|
// RAM système en Mo (total = 0 : l'UI masque le bloc).
|
|
"/api/ram" => {
|
|
let (used, total) = sysinfo::ram_usage_mb();
|
|
auth::json(StatusCode::OK, &json!({ "used": used, "total": total }))
|
|
}
|
|
// Jauges des cartes : un seul nvidia-smi partagé par tous les onglets.
|
|
"/api/vram" => {
|
|
let gpus: Vec<_> = sysinfo::gpu_stats_cached()
|
|
.await
|
|
.into_iter()
|
|
.map(|g| {
|
|
json!({ "name": g.name, "used": g.used, "total": g.total, "util": g.util, "temp": g.temp })
|
|
})
|
|
.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" }))
|
|
}
|
|
|
|
/// Lecture bornée du corps d'une requête (routes POST portées).
|
|
#[allow(dead_code)]
|
|
pub async fn read_body(body: Incoming, max: usize) -> Result<Bytes, String> {
|
|
use http_body_util::{BodyExt, Limited};
|
|
Limited::new(body, max)
|
|
.collect()
|
|
.await
|
|
.map(|c| c.to_bytes())
|
|
.map_err(|e| e.to_string())
|
|
}
|