Files
Loki/rust/loki-rs/src/api.rs
T
Claude 846b561efb Lot 6 : routes du chat servies par la façade (LOKI_CHAT=rust)
Avec LOKI_CHAT=rust, la façade charge la discussion active au démarrage
et sert elle-même /api/chat (flux SSE : rejeu puis direct, battement de
cœur toutes les 4 s, trames regroupées), /api/chat/send, stop, reset,
state, compact, tool-result et /api/conversations{,/new,/switch,/rename,
/delete}, avec les réponses des handlers Go. /api/chat/mode et
/api/chat/criteria restent au Go ; la façade relaie puis journalise
l'état rendu, pour que tous les appareils suivent. Pièces jointes,
fichiers, images et export restent au Go, qui suit la discussion active.

Le Go reçoit LOKI_CHAT=rust et l'adresse de la façade (étape suivante :
lui remettre le trafic de chat du tunnel). Sans la variable, rien ne
change : tout part au Go comme avant.

ChatService gagne reset (fichiers effacés par le Go), la compaction
manuelle, le résultat complet d'un outil et le relais d'un événement ;
les critères changés par l'outil criteria sont journalisés pendant le
tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:11:22 +00:00

297 lines
10 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.
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>>,
}
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"
)
}
pub async fn serve(ctx: &Ctx, req: Request<Incoming>) -> Response<Body> {
let (parts, _body) = req.into_parts();
let version = 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())
}