Files
Loki/rust/loki-rs/src/presets_api.rs
T
Claude ed9b2e8c1c Lot 25 : Strata tenu par la façade (LOKI_STRATA=rust)
Port de backend_strata.go (rust/loki-rs/src/strata) : la machine (cartes
par nvidia-smi, Python 3.10+ et venv, RAM totale et disponible, RAM du
moteur Strata en marche, place libre), ce qui y tient et la
recommandation, la configuration de lancement en clair des modèles
installés (placement des experts drop/ram/arena/disk, carte d'aide,
réglages du preset), les réglages d'un modèle installé et l'installation
(job des moteurs : paquet figé vérifié par SHA-256, installeur lancé et
journalisé comme le sinkWriter du Go, preset écrit puis activé par le Go).

Tenu seulement avec LOKI_BACKENDS=rust ; le lancement (loki serve) reste
au Go, qui relaie les routes du tunnel.

Correction du Go : /api/strata/settings décidait « en service » après
avoir écrit les nouveaux réglages, donc jamais pour un réglage changé ;
décidé désormais avant l'écriture, comme SavePresetApplying. Le port suit.

Parité : parity_strata_test.go (crochets de test : release, meminfo,
place libre, action de service) tire du Go un journal rejoué par
tests/strata.rs ; les tests du Go portés dans strata/tests.rs ;
TestFacadeStrata côté Go. Mutations : 71 tuées sur 71.

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

1459 lines
52 KiB
Rust

//! Presets tenus par la façade — port de `backend_presets.go` (écriture),
//! des handlers de presets de `web_api.go`, de `web_external.go`, et des
//! aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`, `moePreview`,
//! `autoPlacementPreset`) — lot 15 de la migration, servi quand
//! `LOKI_PRESETS=rust` (expérimental ; le Go par défaut) :
//!
//! - `GET /api/presets` (pastilles et mesures), `POST /api/presets/order` ;
//! - `GET /api/preset?id=`, `POST /api/preset/save`, `/api/preset/delete` ;
//! - `POST /api/preset/cacheram`, `/api/preset/moe`, `/api/preset/autoplace` ;
//! - `GET /api/preset/external?id=`, `POST /api/preset/external/save`,
//! `/api/preset/external/test` ;
//! - `POST /api/switch`.
//!
//! Les presets restent des fichiers `presets/*.env`. Ce qui touche au moteur
//! reste au Go jusqu'à son lot : le verrou de l'optimiseur
//! (`/internal/v1/tune/guard`) et l'application d'un preset à la
//! configuration vivante, état du slot gardé et moteur relancé
//! (`/internal/v1/preset/apply`).
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use http_body_util::{BodyExt, Limited};
use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::gojson::{self, Kind};
use crate::presets::{
PRESET_ORDER_KEY, config_fingerprint, list_presets, parse_env, parse_preset_order, preset_ctx,
preset_display_name, preset_fingerprint, presets_dir, unquote_value,
};
use crate::proxy::Body;
use crate::serve::{self, Cfg, SysInfo};
use crate::store::{BK_STATE, Store};
const SYSPROMPT_PREFIX: &str = "sysprompt:";
const BENCH_KEY: &str = "bench_presets";
const BENCH_PROTOCOL: &str = "2";
/// `previewServeHelp` : l'aide supposée d'un moteur récent.
pub const PREVIEW_SERVE_HELP: &str = "--load-mode --fit [on|off] --fit-target";
/// Routes servies ici.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/presets"
| "/api/presets/order"
| "/api/preset"
| "/api/preset/save"
| "/api/preset/cacheram"
| "/api/preset/moe"
| "/api/preset/autoplace"
| "/api/preset/delete"
| "/api/preset/external"
| "/api/preset/external/save"
| "/api/preset/external/test"
| "/api/switch"
)
}
fn trim(s: &str) -> &str {
s.trim_matches(char::is_whitespace)
}
fn get<'a>(cfg: &'a Cfg, k: &str) -> &'a str {
cfg.get(k).map_or("", String::as_str)
}
// ---------------------------------------------------------------------------
// Fonctions pures
// ---------------------------------------------------------------------------
/// `withDisplayName` : la ligne `# NAME=` remplacée, ou ajoutée en tête.
pub fn with_display_name(content: &str, name: &str) -> String {
let re = regex::Regex::new(r"(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$").expect("motif");
let line = format!("# NAME={name}");
if re.is_match(content) {
// ReplaceAllString du Go : « $x » est développé, comme ici.
return re.replace_all(content, line.as_str()).into_owned();
}
format!("{line}\n{content}")
}
/// `quoteValue`.
pub fn quote_value(v: &str) -> String {
if v.is_empty() || v.contains('"') {
return v.to_string();
}
if v.contains([' ', '\t']) || unquote_value(v) != v {
return format!("\"{v}\"");
}
v.to_string()
}
/// `formatEnv` : clés triées.
pub fn format_env(m: &BTreeMap<String, String>) -> String {
m.iter()
.map(|(k, v)| format!("{k}={}\n", quote_value(v)))
.collect()
}
/// `validPresetName`.
pub fn valid_preset_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("nom vide".into());
}
if name == "." || name == ".." {
return Err("nom réservé".into());
}
if name.contains(['/', '\\', '\0']) {
return Err("le nom ne peut pas contenir / ni \\".into());
}
if name.chars().any(|c| (c as u32) < 0x20) {
return Err("le nom contient un caractère de contrôle invalide".into());
}
Ok(())
}
/// `safePresetPath`.
pub fn safe_preset_path(home: &Path, name: &str) -> Result<PathBuf, String> {
let name = trim(name);
valid_preset_name(name)?;
Ok(presets_dir(home).join(format!("{name}.env")))
}
/// `uniquePresetID`.
pub fn unique_preset_id(home: &Path, name: &str) -> Result<String, String> {
let replaced = name.replace(['/', '\\'], "-");
let mut base = trim(&replaced).to_string();
if base.is_empty() {
base = "preset".into();
}
let mut cand = base.clone();
for n in 2..10000 {
let p = safe_preset_path(home, &cand)?;
if matches!(std::fs::metadata(&p), Err(e) if e.kind() == std::io::ErrorKind::NotFound) {
return Ok(cand);
}
cand = format!("{base} ({n})");
}
Err("impossible de générer un nom de fichier unique".into())
}
/// `ReadPreset`.
pub fn read_preset(home: &Path, id: &str) -> Result<String, String> {
let p = safe_preset_path(home, id)?;
std::fs::read(&p)
.map(|b| String::from_utf8_lossy(&b).into_owned())
.map_err(|e| crate::paths::os_err("open", &p.to_string_lossy(), &e))
}
fn write_preset(p: &Path, content: &str) -> Result<(), String> {
if let Some(d) = p.parent() {
let _ = std::fs::create_dir_all(d);
}
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.mode(0o644);
}
use std::io::Write as _;
o.open(p)
.and_then(|mut f| f.write_all(content.as_bytes()))
.map_err(|e| crate::paths::os_err("open", &p.to_string_lossy(), &e))
}
/// `SavePreset` : création (id vide, nom de fichier unique) ou mise à jour.
pub fn save_preset(home: &Path, id: &str, name: &str, content: &str) -> Result<String, String> {
let name = trim(name);
if name.is_empty() {
return Err("nom requis".into());
}
let content = with_display_name(content, name);
let id = if id.is_empty() {
unique_preset_id(home, name)?
} else {
id.to_string()
};
let p = safe_preset_path(home, &id)?;
write_preset(&p, &content)?;
Ok(id)
}
/// `presetReasoning`.
pub fn preset_reasoning(content: &str) -> String {
for line in content.split('\n') {
let s = trim(line);
if s.is_empty() || s.starts_with('#') {
continue;
}
if let Some((k, v)) = s.split_once('=')
&& trim(k).eq_ignore_ascii_case("REASONING")
{
return unquote_value(trim(v)).to_string();
}
}
String::new()
}
/// `modelFromPresetContent`.
pub fn model_from_preset_content(content: &str) -> String {
for line in content.split('\n') {
let s = trim(line);
if s.is_empty() || s.starts_with('#') {
continue;
}
if let Some((k, v)) = s.split_once('=')
&& trim(k) == "MODEL"
{
return unquote_value(trim(v)).to_string();
}
}
String::new()
}
/// `detectQuant` : une ligne QUANT= (avec ou sans `#`), sinon le nom du
/// modèle.
pub fn detect_quant(content: &str) -> String {
for line in content.split('\n') {
let t = trim(line);
let s = trim(t.strip_prefix('#').unwrap_or(t));
if let Some((k, v)) = s.split_once('=')
&& trim(k).eq_ignore_ascii_case("QUANT")
{
let v = unquote_value(trim(v));
if !v.is_empty() {
return v.to_uppercase();
}
}
}
crate::models::quant_from_name(&crate::paths::base_name(&model_from_preset_content(
content,
)))
}
/// `completionsURL`.
pub fn completions_url(raw: &str) -> String {
let u = trim(raw).trim_end_matches('/');
if u.is_empty() {
return String::new();
}
if u.ends_with("/chat/completions") {
return u.to_string();
}
if u.ends_with("/v1") {
return format!("{u}/chat/completions");
}
format!("{u}/v1/chat/completions")
}
/// `externalPresetContent`.
pub fn external_preset_content(
url: &str,
model: &str,
key: &str,
ctx: &str,
vision: bool,
) -> String {
let mut m = BTreeMap::new();
m.insert("EXTERNAL".to_string(), "1".to_string());
m.insert("EXTERNAL_URL".into(), trim(url).into());
m.insert("EXTERNAL_MODEL".into(), trim(model).into());
if !trim(key).is_empty() {
m.insert("EXTERNAL_KEY".into(), trim(key).into());
}
if vision {
m.insert("EXTERNAL_VISION".into(), "1".into());
}
if !trim(ctx).is_empty() {
m.insert("CTX".into(), trim(ctx).into());
}
format_env(&m)
}
/// `extractAPIError` : `error.message` d'une réponse au format OpenAI.
pub fn extract_api_error(b: &[u8]) -> String {
match serde_json::from_slice::<Value>(b) {
Ok(v) => v
.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.map(|s| trim(s).to_string())
.unwrap_or_default(),
Err(_) => String::new(),
}
}
/// `presetImposesEngine` : seul un backend personnalisé est imposé par un
/// preset ; le moteur de l'image, un téléchargé ou un précompilé suivent la
/// machine.
pub fn preset_imposes_engine(home: &Path, p: &str) -> bool {
let p = trim(p);
let ep = serve::run::EnginePaths {
home: home.to_path_buf(),
};
if p.is_empty() || ep.engine_owns(p) || ep.prebuilt_owns(p) {
return false;
}
let img = std::env::var("LOKI_ENGINE_BIN").unwrap_or_default();
let img = trim(&img);
if !img.is_empty() && serve::run::same_path(p, img) {
return false;
}
true
}
/// `newPresetSeed` : BIN (backend personnalisé seulement), HOST, PORT.
pub fn new_preset_seed(home: &Path, cur: &Cfg) -> BTreeMap<String, String> {
let mut seed = BTreeMap::new();
for k in ["BIN", "HOST", "PORT"] {
let v = get(cur, k);
if v.is_empty() || (k == "BIN" && !preset_imposes_engine(home, v)) {
continue;
}
seed.insert(k.to_string(), v.to_string());
}
seed
}
/// `benchFingerprint`.
pub fn bench_fingerprint(cfg: &Cfg, cur: &Cfg) -> String {
let mut gpus = trim(get(cfg, "CUDA_VISIBLE_DEVICES")).to_string();
if gpus.is_empty() {
gpus = trim(get(cur, "CUDA_VISIBLE_DEVICES")).to_string();
}
let mut m = HashMap::new();
m.insert("config".to_string(), config_fingerprint(cfg));
m.insert("gpus".into(), gpus);
m.insert("protocol".into(), BENCH_PROTOCOL.into());
config_fingerprint(&m)
}
/// `benchMatchesPreset`.
pub fn bench_matches_preset(sb: &Value, cfg: &Cfg, cur: &Cfg) -> bool {
let fp = sb.get("fingerprint").and_then(Value::as_str).unwrap_or("");
if !fp.is_empty() {
return fp == bench_fingerprint(cfg, cur);
}
let m = trim(get(cfg, "MODEL"));
!m.is_empty() && sb.get("model").and_then(Value::as_str) == Some(&crate::paths::base_name(m))
}
/// Nombre JSON au rendu d'`encoding/json` (un flottant entier s'écrit sans
/// « .0 »).
fn go_num(f: f64) -> Value {
if f.fract() == 0.0 && f.abs() < 1e15 {
json!(f as i64)
} else {
json!(f)
}
}
fn num(v: &Value, k: &str) -> f64 {
v.get(k).and_then(Value::as_f64).unwrap_or(0.0)
}
/// Pastille de mesure d'un preset (`bench` de `handlePresets`).
fn bench_badge(sb: &Value) -> Value {
let r = sb.get("result").cloned().unwrap_or(Value::Null);
let mut b = serde_json::Map::new();
b.insert("prefill".into(), go_num(num(&r, "prompt_per_second")));
b.insert("decode".into(), go_num(num(&r, "predicted_per_second")));
b.insert(
"at".into(),
json!(sb.get("at").and_then(Value::as_i64).unwrap_or(0)),
);
let kv = r.get("kv").and_then(Value::as_str).unwrap_or("");
if !kv.is_empty() {
b.insert("kv".into(), json!(kv));
b.insert(
"kv_quantized".into(),
json!(
r.get("kv_quantized")
.and_then(Value::as_bool)
.unwrap_or(false)
),
);
}
if let Some(d) = r.get("depth").filter(|d| d.is_object()) {
let dd = num(d, "decode_per_second");
if let Some(c) = d.get("cold").filter(|c| c.is_object())
&& dd > 0.0
{
b.insert(
"depth".into(),
json!(c.get("new").and_then(Value::as_i64).unwrap_or(0)),
);
b.insert("depth_decode".into(), go_num(dd));
}
}
Value::Object(b)
}
/// `joinArgs` : l'inverse de `splitArgs`.
pub fn join_args(args: &[String]) -> String {
args.iter()
.map(|a| {
if a.is_empty() {
"\"\"".to_string()
} else if a.contains([' ', '\t']) {
if a.contains('\'') {
format!("\"{a}\"")
} else {
format!("'{a}'")
}
} else {
a.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
/// `presetSetKey` : lignes KEY= remplacées (toutes), ajoutée à la fin si
/// absente, retirées si `val` est vide.
pub fn preset_set_key(content: &str, key: &str, val: &str) -> String {
let k = regex::escape(key);
if val.is_empty() {
let re = regex::Regex::new(&format!(
r"(?m)^[ \t]*(?:export[ \t]+)?{k}[ \t]*=.*(?:\n|$)"
))
.expect("motif");
return re.replace_all(content, regex::NoExpand("")).into_owned();
}
let re =
regex::Regex::new(&format!(r"(?m)^[ \t]*(?:export[ \t]+)?{k}[ \t]*=.*$")).expect("motif");
let line = format!("{key}={}", quote_value(val));
if re.is_match(content) {
return re.replace_all(content, regex::NoExpand(&line)).into_owned();
}
let mut c = content.to_string();
if !c.is_empty() && !c.ends_with('\n') {
c.push('\n');
}
c + &line + "\n"
}
/// `autoPlacementPreset` : la COPIE d'un preset MoE placé à la main, en
/// placement automatique, et ce qui change.
pub fn auto_placement_preset(content: &str) -> Result<(String, Vec<String>), String> {
let cfg = parse_env(content);
if serve::is_external(&cfg) {
return Err("preset externe : le modèle tourne ailleurs".into());
}
let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS"));
let mut kept: Vec<String> = Vec::new();
let mut changes: Vec<String> = Vec::new();
let mut migrated = false;
let mut i = 0;
while i < extra.len() {
let a = &extra[i];
let (name, val, has_eq) = match a.split_once('=') {
Some((n, v)) => (n.to_string(), v.to_string(), true),
None => (a.clone(), String::new(), false),
};
let mut value = || -> String {
if has_eq {
return val.clone();
}
if i + 1 < extra.len() {
i += 1;
return extra[i].clone();
}
String::new()
};
match name.as_str() {
"-ot" | "--override-tensor" => {
let v = value();
let mut rest: Vec<String> = Vec::new();
for el in v.split(',') {
let el = trim(el);
let pat = el.split_once('=').map_or(el, |(p, _)| p);
if el.is_empty() {
} else if serve::ot_targets_experts(pat) {
changes.push(format!("retiré : {name} {el} (experts : --fit les place)"));
migrated = true;
} else if pat.to_lowercase().contains("per_layer_token_embd")
&& serve::ot_on_cpu(el)
{
changes.push(format!(
"retiré : {name} {el} (couche d'entrée, toujours au CPU)"
));
} else {
rest.push(el.to_string());
}
}
if !rest.is_empty() {
kept.push(name.clone());
kept.push(rest.join(","));
}
}
"-ncmoe" | "--n-cpu-moe" => {
let v = value();
changes.push(format!("retiré : {name} {v}"));
if trim(&v) != "0" {
migrated = true;
}
}
"-cmoe" | "--cpu-moe" => {
changes.push(format!("retiré : {name}"));
migrated = true;
}
"-ngl" | "--n-gpu-layers" | "--gpu-layers" | "-ts" | "--tensor-split" | "-b"
| "--batch-size" | "-ub" | "--ubatch-size" | "--load-mode" | "-lm" => {
let v = value();
changes.push(format!("retiré : {name} {v}"));
}
"--mlock" | "--no-mmap" | "--mmap" => changes.push(format!("retiré : {name}")),
"-fit" | "--fit" => {
let v = value();
if matches!(
trim(&v).to_lowercase().as_str(),
"off" | "disabled" | "false" | "0"
) {
changes.push(format!("retiré : {name} {v}"));
} else {
kept.push(name.clone());
kept.push(v);
}
}
_ => kept.push(a.clone()),
}
i += 1;
}
if !migrated {
return Err("rien à migrer : ce preset ne place pas d'experts à la main (-ot …exps…, --n-cpu-moe, --cpu-moe)".into());
}
kept.push("--load-mode".into());
kept.push("mmap".into());
let mut nc = cfg.clone();
nc.insert("EXTRA_ARGS".into(), join_args(&kept));
nc.insert("UBATCH".into(), "2048".into());
nc.insert("BATCH".into(), "4096".into());
nc.remove("NGL");
let (ctx, ok) = serve::ctx_fixed(&nc, &kept);
if !ok {
return Err(format!(
"contexte « {ctx} » non chiffré : --fit pourrait le réduire pour tenir — donne un CTX chiffré d'abord"
));
}
if serve::split_mode_key(&nc) == "tensor" {
return Err("SPLIT_MODE=tensor coupe --fit : retire-le d'abord".into());
}
let si = SysInfo {
help: PREVIEW_SERVE_HELP.into(),
..SysInfo::default()
};
let why = serve::fit_blocker(&nc, &kept, &si);
if !why.is_empty() {
return Err(format!(
"--fit resterait inactif ({why}) : retire-le à la main d'abord"
));
}
let ngl = trim(get(&cfg, "NGL"));
if !ngl.is_empty() {
changes.push(format!(
"NGL={ngl} retiré : le moteur place les couches (-ngl auto)"
));
}
changes.push(format!(
"UBATCH=2048, BATCH=4096 (avant : {} / {})",
serve_first(&[get(&cfg, "UBATCH"), "512"]),
serve_first(&[get(&cfg, "BATCH"), "2048"])
));
changes.push("--load-mode mmap".into());
changes.push(format!("CTX conservé : {ctx}"));
let (k, v) = serve::serve_kv_types(&nc);
if !k.is_empty()
|| !v.is_empty()
|| !serve::flag_value(&kept, &["-ctk", "--cache-type-k"]).is_empty()
|| !serve::flag_value(&kept, &["-ctv", "--cache-type-v"]).is_empty()
{
changes.push(
"cache KV conservé tel quel — garde-le identique pour comparer les deux presets".into(),
);
}
let mut out = content.to_string();
out = preset_set_key(&out, "EXTRA_ARGS", get(&nc, "EXTRA_ARGS"));
out = preset_set_key(&out, "UBATCH", "2048");
out = preset_set_key(&out, "BATCH", "4096");
out = preset_set_key(&out, "NGL", "");
Ok((out, changes))
}
fn serve_first<'a>(vals: &[&'a str]) -> &'a str {
vals.iter()
.copied()
.find(|v| !trim(v).is_empty())
.unwrap_or("")
}
// ---------------------------------------------------------------------------
// Aperçus de l'éditeur (lectures du monde : GGUF, RAM, nvidia-smi)
// ---------------------------------------------------------------------------
/// Résolution d'un MODEL= pour les aperçus.
pub struct Resolver {
pub home: PathBuf,
pub stored: Vec<String>,
pub env: Option<String>,
}
impl Resolver {
fn resolve(&self, m: &str) -> String {
crate::gguf::resolve_serve_model_path(m, &self.home, &self.stored, self.env.as_deref())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}
}
fn lookup_env(si: &mut SysInfo, keys: &[&[&str]], trimmed: bool) {
for group in keys {
for k in *group {
if let Ok(v) = std::env::var(k) {
let keep = if trimmed {
!trim(&v).is_empty()
} else {
!v.is_empty()
};
if keep {
si.arg_env.insert((*k).into(), v);
}
}
}
}
}
/// `cacheRAMPreview` : (Mio, pourquoi) ; 0 = défaut du moteur.
pub fn cache_ram_preview(r: &Resolver, content: &str) -> (i64, String) {
let cfg = parse_env(content);
if serve::is_external(&cfg) {
return (0, "preset externe".into());
}
let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS"));
let v = serve::flag_value(&extra, &["-cram", "--cache-ram"]);
if !v.is_empty() {
return (v.parse().unwrap_or(0), "fixé par EXTRA_ARGS".into());
}
let e = std::env::var("LLAMA_ARG_CACHE_RAM").unwrap_or_default();
if !trim(&e).is_empty() {
return (
trim(&e).parse().unwrap_or(0),
"fixé par LLAMA_ARG_CACHE_RAM".into(),
);
}
let (manual, auto, ok) = serve::cache_ram_setting(&cfg);
if !ok {
return (0, "CACHE_RAM illisible".into());
}
if !auto {
return (manual.parse().unwrap_or(0), "fixé par CACHE_RAM".into());
}
let mut si = SysInfo::default();
lookup_env(
&mut si,
&[
serve::SERVE_ARG_ENV,
serve::FIDELITY_ARG_ENV,
serve::CACHE_ARG_ENV,
],
false,
);
let m = trim(get(&cfg, "MODEL"));
if !m.is_empty() {
si.model = r.resolve(m);
}
if si.model.is_empty() {
return (0, "modèle introuvable".into());
}
serve::run::probe_cache_ram(&serve::run::Env::default(), &cfg, &extra, &mut si);
serve::cache_ram_auto(&cfg, &extra, &si)
}
/// `sideSlotPreview` : (accepté, Mio, pourquoi).
pub fn side_slot_preview(r: &Resolver, content: &str) -> (bool, i64, String) {
let cfg = parse_env(content);
if !serve::side_slot_on(&cfg) {
return (false, 0, String::new());
}
if serve::is_external(&cfg) {
return (false, 0, "preset externe".into());
}
let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS"));
let mut si = SysInfo {
help: "--no-kv-unified".into(),
..SysInfo::default()
};
lookup_env(
&mut si,
&[
serve::SERVE_ARG_ENV,
serve::FIDELITY_ARG_ENV,
serve::CACHE_ARG_ENV,
serve::SIDE_SLOT_ARG_ENV,
],
true,
);
let m = trim(get(&cfg, "MODEL"));
if !m.is_empty() {
si.model = r.resolve(m);
}
if si.model.is_empty() {
return (false, 0, "modèle introuvable".into());
}
serve::run::probe_cache_ram(&serve::run::Env::default(), &cfg, &extra, &mut si);
let (ctx, mib, why) = serve::side_slot_plan(&cfg, &extra, &si);
(ctx > 0, mib, why)
}
/// `moePreview` : (notes, avis de chargement).
pub fn moe_preview(r: &Resolver, live_bin: &str, content: &str) -> (Vec<String>, String) {
let cfg = parse_env(content);
if serve::is_external(&cfg) {
return (Vec::new(), String::new());
}
let mut si = SysInfo {
help: PREVIEW_SERVE_HELP.into(),
..SysInfo::default()
};
lookup_env(
&mut si,
&[
serve::SERVE_ARG_ENV,
serve::FIT_ARG_ENV,
serve::LOAD_ARG_ENV,
],
true,
);
let m = trim(get(&cfg, "MODEL"));
if !m.is_empty() {
si.model = r.resolve(m);
}
if si.model.is_empty() {
return (Vec::new(), String::new());
}
let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS"));
serve::run::probe_cache_ram(&serve::run::Env::default(), &cfg, &extra, &mut si);
let (norm, _) = serve::normalize_load_flags(&extra, true);
if serve::load_guard_needs_devices(&cfg, &norm, &si) {
let ep = serve::run::EnginePaths {
home: r.home.clone(),
};
let bin = ep.prebuilt_resolve_bin(serve_first(&[get(&cfg, "BIN"), live_bin]));
let key = format!("{bin}\0{}", get(&cfg, "CUDA_VISIBLE_DEVICES"));
if let Some(devs) = dev_persist_get(&r.home, &key) {
si.engine_vram_mib = serve::engine_devs_vram(&devs);
}
}
let (mut warn, refuse) = serve::load_mode_risk(&cfg, &norm, &si);
if !refuse.is_empty() {
warn = format!("refusé au lancement — {refuse}");
}
(serve::moe_notes(&cfg, &extra, &si), warn)
}
/// `devPersistGet` : la dernière liste de cartes donnée à l'éditeur
/// (`devices.json`).
fn dev_persist_get(home: &Path, key: &str) -> Option<Vec<serve::SplitDev>> {
let b = std::fs::read(home.join("devices.json")).ok()?;
let m: HashMap<String, Vec<Value>> = serde_json::from_slice(&b).ok()?;
let list = m.get(key).filter(|l| !l.is_empty())?;
Some(
list.iter()
.map(|d| serve::SplitDev {
id: d.get("id").and_then(Value::as_str).unwrap_or("").into(),
total_mib: d.get("total_mib").and_then(Value::as_f64).unwrap_or(0.0) as i64,
})
.collect(),
)
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": e.into() }))
}
async fn read_body(body: Incoming, limit: usize) -> Result<Vec<u8>, String> {
Limited::new(body, limit)
.collect()
.await
.map(|b| b.to_bytes().to_vec())
.map_err(|_| "http: request body too large".to_string())
}
const PRESET_SAVE_REQ: &[(&str, Kind)] = &[
("id", Kind::Str),
("name", Kind::Str),
("content", Kind::Str),
("deleteModel", Kind::Bool),
("sysprompt", Kind::OptStr),
];
const EXTERNAL_SAVE_REQ: &[(&str, Kind)] = &[
("id", Kind::Str),
("name", Kind::Str),
("url", Kind::Str),
("model", Kind::Str),
("key", Kind::Str),
("ctx", Kind::Str),
("keyTouched", Kind::Bool),
("vision", Kind::Bool),
];
const CONTENT_REQ_TYPE: &str = r#"struct { Content string "json:\"content\"" }"#;
const ORDER_REQ_TYPE: &str = r#"struct { IDs []string "json:\"ids\"" }"#;
const AUTOPLACE_REQ_TYPE: &str = r#"struct { ID string "json:\"id\""; Name string "json:\"name\""; Content string "json:\"content\"" }"#;
const SWITCH_REQ_TYPE: &str = r#"struct { N int "json:\"n\""; ID string "json:\"id\"" }"#;
/// 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
}
/// Applique un preset à la configuration vivante (au Go, voir
/// `facade_presets.go`).
pub(crate) async fn preset_apply(store: &Store, path: &Path, mode: &str) -> Result<(), String> {
let (code, v) = store
.internal_post(
"/internal/v1/preset/apply",
&json!({ "path": path.to_string_lossy(), "mode": mode }),
)
.await?;
if code.is_success() {
return Ok(());
}
Err(v
.get("error")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("application du preset : {code}")))
}
struct Live {
cfg: Cfg,
order: Vec<String>,
}
async fn live(store: &Store) -> Live {
Live {
cfg: store.config().await,
order: parse_preset_order(
store
.get(BK_STATE, PRESET_ORDER_KEY)
.await
.ok()
.flatten()
.as_deref(),
),
}
}
/// `SavePresetApplying` : enregistre ; si c'était le preset EN SERVICE (avant
/// l'écriture), l'applique (`mode` : save ou apply).
pub(crate) async fn save_applying(
ctx: &Ctx,
id: &str,
name: &str,
content: &str,
mode: &str,
) -> Result<(String, bool), String> {
let home = &ctx.engine.home;
let mut was_active = false;
if !id.is_empty()
&& let Ok(old) = read_preset(home, id)
{
was_active = preset_fingerprint(&old) == config_fingerprint(&ctx.store.config().await);
}
let new_id = save_preset(home, id, name, content)?;
if !was_active {
return Ok((new_id, false));
}
let p = safe_preset_path(home, &new_id)?;
preset_apply(&ctx.store, &p, mode).await?;
Ok((new_id, true))
}
async fn resolver(ctx: &Ctx) -> Resolver {
let d = crate::models::model_dirs(ctx).await;
Resolver {
home: d.home,
stored: d.stored,
env: d.env,
}
}
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(ctx: &Ctx, 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;
}
let store = &ctx.store;
let home = ctx.engine.home.clone();
let mut query: HashMap<String, String> = HashMap::new();
for (k, v) in url::form_urlencoded::parse(parts.uri.query().unwrap_or("").as_bytes()) {
query
.entry(k.into_owned())
.or_insert_with(|| v.into_owned());
}
let q = |k: &str| trim(query.get(k).map_or("", String::as_str)).to_string();
match parts.uri.path() {
"/api/presets" => {
let lv = live(store).await;
let list = match list_presets(&home, &lv.cfg, &lv.order) {
Ok(l) => l,
Err(e) => {
return send_json(StatusCode::INTERNAL_SERVER_ERROR, &json!({ "error": e }));
}
};
let benches: HashMap<String, Value> = store
.get(BK_STATE, BENCH_KEY)
.await
.ok()
.flatten()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let mut out = Vec::new();
for p in list {
let mut item = serde_json::Map::new();
item.insert("id".into(), json!(p.id));
item.insert("name".into(), json!(p.name));
item.insert("active".into(), json!(p.active));
let content = read_preset(&home, &p.id).unwrap_or_default();
let cfg = parse_env(&content);
if !content.is_empty() {
if serve::is_external(&cfg) {
item.insert("external".into(), json!(true));
let m = trim(get(&cfg, "EXTERNAL_MODEL"));
if !m.is_empty() {
item.insert("model".into(), json!(m));
}
if trim(get(&cfg, "EXTERNAL_VISION")) == "1" {
item.insert("vision".into(), json!(true));
}
out.push(Value::Object(item));
continue;
}
let c = preset_ctx(&content);
if !c.is_empty() {
item.insert("ctx".into(), json!(c));
}
if !trim(get(&cfg, "MMPROJ")).is_empty() {
item.insert("vision".into(), json!(true));
}
if serve::is_strata(&cfg) {
item.insert("strata".into(), json!(true));
if get(&cfg, "STRATA_VISION") != "0" {
item.insert("vision".into(), json!(true));
}
}
let qn = detect_quant(&content);
if !qn.is_empty() {
item.insert("quant".into(), json!(qn));
}
let r = preset_reasoning(&content);
if crate::chat::wire::reasoning_active(&r) {
item.insert("reasoning".into(), json!(r.to_lowercase()));
}
}
if let Some(sb) = benches.get(&p.id)
&& bench_matches_preset(sb, &cfg, &lv.cfg)
{
item.insert("bench".into(), bench_badge(sb));
}
out.push(Value::Object(item));
}
send_json(StatusCode::OK, &Value::Array(out))
}
"/api/presets/order" => {
if parts.method != Method::POST {
return fail(StatusCode::METHOD_NOT_ALLOWED, "POST attendu");
}
let b = read_body(body, 8 << 20).await.unwrap_or_default();
let d = gojson::decode_struct(&b, ORDER_REQ_TYPE, &[("ids", Kind::StrList)]);
if let Some(e) = d.err {
return fail(StatusCode::BAD_REQUEST, e);
}
let ids = d.str_list("ids");
if let Err(e) = store
.put(
BK_STATE,
PRESET_ORDER_KEY,
gojson::marshal(&ids).into_bytes(),
)
.await
{
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
send_json(StatusCode::OK, &json!({ "ok": true }))
}
"/api/preset" => {
let id = q("id");
if id.is_empty() {
let cur = store.config().await;
return send_json(
StatusCode::OK,
&json!({ "id": "", "name": "", "content": format_env(&new_preset_seed(&home, &cur)) }),
);
}
match read_preset(&home, &id) {
Ok(content) => {
let sp = store
.get_str(BK_STATE, &format!("{SYSPROMPT_PREFIX}{id}"))
.await;
send_json(
StatusCode::OK,
&json!({ "id": id, "name": preset_display_name(&content, &id), "content": content, "sysprompt": sp }),
)
}
Err(_) => send_json(StatusCode::NOT_FOUND, &json!({ "error": "not found" })),
}
}
"/api/preset/save" => {
if let Some(r) = tune_deny(store).await {
return r;
}
let b = read_body(body, 8 << 20).await.unwrap_or_default();
let d = gojson::decode_struct(&b, "presetSaveReq", PRESET_SAVE_REQ);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let (new_id, applied) =
match save_applying(ctx, &d.str("id"), &d.str("name"), &d.str("content"), "save")
.await
{
Ok(x) => x,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
if let Some(sp) = d.opt_str("sysprompt") {
let key = format!("{SYSPROMPT_PREFIX}{new_id}");
let res = if trim(&sp).is_empty() {
store.del(BK_STATE, &key).await
} else {
store
.put(BK_STATE, &key, trim(&sp).as_bytes().to_vec())
.await
};
if let Err(e) = res {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "id": new_id, "name": d.str("name"), "applied": applied }),
)
}
"/api/preset/cacheram" => {
let b = match read_body(body, 1 << 20).await {
Ok(b) => b,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
let d = gojson::decode_struct(&b, CONTENT_REQ_TYPE, &[("content", Kind::Str)]);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let r = resolver(ctx).await;
let content = d.str("content");
let res = tokio::task::spawn_blocking(move || {
(
cache_ram_preview(&r, &content),
side_slot_preview(&r, &content),
)
})
.await;
let Ok(((mib, why), (on, extra, swhy))) = res else {
return fail(StatusCode::INTERNAL_SERVER_ERROR, "aperçu interrompu");
};
let mut out = serde_json::Map::new();
out.insert("ok".into(), json!(true));
out.insert("mib".into(), json!(mib));
out.insert("default".into(), json!(serve::ENGINE_CACHE_RAM_DEFAULT));
out.insert("why".into(), json!(why));
if on || !swhy.is_empty() {
out.insert(
"side".into(),
json!({ "on": on, "vramMiB": extra, "why": swhy }),
);
}
send_json(StatusCode::OK, &Value::Object(out))
}
"/api/preset/moe" => {
let b = match read_body(body, 1 << 20).await {
Ok(b) => b,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
let d = gojson::decode_struct(&b, CONTENT_REQ_TYPE, &[("content", Kind::Str)]);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let r = resolver(ctx).await;
let live_bin = get(&store.config().await, "BIN").to_string();
let content = d.str("content");
let c2 = content.clone();
let Ok((notes, load)) =
tokio::task::spawn_blocking(move || moe_preview(&r, &live_bin, &c2)).await
else {
return fail(StatusCode::INTERNAL_SERVER_ERROR, "aperçu interrompu");
};
let mut out = serde_json::Map::new();
out.insert("ok".into(), json!(true));
out.insert(
"notes".into(),
if notes.is_empty() {
Value::Null
} else {
json!(notes)
},
);
out.insert("load".into(), json!(load));
let cfg = parse_env(&content);
if !serve::is_external(&cfg)
&& !serve::cpu_experts(
&crate::models::split_args(get(&cfg, "EXTRA_ARGS")),
&HashMap::new(),
)
.is_empty()
{
out.insert(
"autoplace".into(),
match auto_placement_preset(&content) {
Ok((_, changes)) => json!({ "ok": true, "changes": changes }),
Err(e) => json!({ "ok": false, "why": e }),
},
);
}
send_json(StatusCode::OK, &Value::Object(out))
}
"/api/preset/autoplace" => {
if parts.method != Method::POST {
let mut r = fail(
StatusCode::METHOD_NOT_ALLOWED,
format!("méthode {} refusée : POST attendu", parts.method),
);
r.headers_mut().insert(
hyper::header::ALLOW,
hyper::header::HeaderValue::from_static("POST"),
);
return r;
}
let b = match read_body(body, 1 << 20).await {
Ok(b) => b,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
let d = gojson::decode_struct(
&b,
AUTOPLACE_REQ_TYPE,
&[
("id", Kind::Str),
("name", Kind::Str),
("content", Kind::Str),
],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let content = d.str("content");
let (out, changes) = match auto_placement_preset(&content) {
Ok(x) => x,
Err(e) => return send_json(StatusCode::OK, &json!({ "ok": false, "error": e })),
};
let shown = preset_display_name(&content, &d.str("id"));
let name = format!(
"{} (placement auto)",
serve_first(&[trim(&d.str("name")), &shown, "preset"])
);
match save_preset(&home, "", &name, &out) {
Ok(id) => send_json(
StatusCode::OK,
&json!({ "ok": true, "id": id, "name": name, "changes": changes }),
),
Err(e) => send_json(StatusCode::OK, &json!({ "ok": false, "error": e })),
}
}
"/api/preset/delete" => {
let b = read_body(body, 8 << 20).await.unwrap_or_default();
let d = gojson::decode_struct(&b, "presetSaveReq", PRESET_SAVE_REQ);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let id = d.str("id");
let mut model = String::new();
if d.bool("deleteModel")
&& let Ok(c) = read_preset(&home, &id)
{
model = model_from_preset_content(&c);
}
if let Err(e) = delete_preset(ctx, &id).await {
return fail(StatusCode::BAD_REQUEST, e);
}
let (mut model_deleted, mut model_err, mut freed) =
(String::new(), String::new(), 0i64);
if d.bool("deleteModel") && !model.is_empty() {
let dirs = crate::models::model_dirs(ctx).await;
let lv = live(store).await;
let m2 = model.clone();
let res = tokio::task::spawn_blocking(move || {
let refs = crate::models::build_refs(&dirs, &lv.cfg, &lv.order);
let (others, _) = refs.users(&dirs, &m2);
(others, crate::models::delete_model_file(&dirs, &refs, &m2))
})
.await;
if let Ok((others, r)) = res {
match r {
Err(e) => model_err = e,
Ok(n) => {
model_deleted = model;
freed = n;
if !others.is_empty() {
model_err = format!(
"supprimé, mais il était aussi référencé par : {}",
others.join(", ")
);
}
}
}
}
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "modelDeleted": model_deleted, "modelError": model_err, "freed": freed }),
)
}
"/api/preset/external" => {
let id = q("id");
if id.is_empty() {
return send_json(
StatusCode::OK,
&json!({ "id": "", "name": "", "url": "", "model": "", "hasKey": false }),
);
}
let Ok(content) = read_preset(&home, &id) else {
return send_json(StatusCode::NOT_FOUND, &json!({ "error": "not found" }));
};
let cfg = parse_env(&content);
if !serve::is_external(&cfg) {
return send_json(
StatusCode::BAD_REQUEST,
&json!({ "error": "pas un preset externe" }),
);
}
send_json(
StatusCode::OK,
&json!({
"id": id,
"name": preset_display_name(&content, &id),
"url": trim(get(&cfg, "EXTERNAL_URL")),
"model": trim(get(&cfg, "EXTERNAL_MODEL")),
"ctx": trim(get(&cfg, "CTX")),
"hasKey": !trim(get(&cfg, "EXTERNAL_KEY")).is_empty(),
"vision": trim(get(&cfg, "EXTERNAL_VISION")) == "1",
}),
)
}
"/api/preset/external/save" => {
let b = read_body(body, 8 << 20).await.unwrap_or_default();
let d = gojson::decode_struct(&b, "externalSaveReq", EXTERNAL_SAVE_REQ);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let name = trim(&d.str("name")).to_string();
let url = trim(&d.str("url")).to_string();
let model = trim(&d.str("model")).to_string();
if name.is_empty() {
return fail(StatusCode::BAD_REQUEST, "nom requis");
}
if url.is_empty() {
return fail(StatusCode::BAD_REQUEST, "URL requise");
}
if model.is_empty() {
return fail(StatusCode::BAD_REQUEST, "modèle requis");
}
let id = d.str("id");
let mut key = d.str("key");
if !id.is_empty()
&& !d.bool("keyTouched")
&& let Ok(c) = read_preset(&home, &id)
{
key = trim(get(&parse_env(&c), "EXTERNAL_KEY")).to_string();
}
let content =
external_preset_content(&url, &model, &key, &d.str("ctx"), d.bool("vision"));
match save_applying(ctx, &id, &name, &content, "apply").await {
Ok((new_id, applied)) => send_json(
StatusCode::OK,
&json!({ "ok": true, "id": new_id, "name": name, "applied": applied }),
),
Err(e) => fail(StatusCode::BAD_REQUEST, e),
}
}
"/api/preset/external/test" => {
let b = read_body(body, 8 << 20).await.unwrap_or_default();
let d = gojson::decode_struct(&b, "externalSaveReq", EXTERNAL_SAVE_REQ);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let url = completions_url(&d.str("url"));
let model = trim(&d.str("model")).to_string();
if url.is_empty() || model.is_empty() {
return send_json(
StatusCode::OK,
&json!({ "ok": false, "error": "URL et modèle requis" }),
);
}
let mut key = trim(&d.str("key")).to_string();
let id = d.str("id");
if key.is_empty()
&& !id.is_empty()
&& !d.bool("keyTouched")
&& let Ok(c) = read_preset(&home, &id)
{
key = trim(get(&parse_env(&c), "EXTERNAL_KEY")).to_string();
}
send_json(StatusCode::OK, &external_test(&url, &model, &key).await)
}
"/api/switch" => {
if let Some(r) = tune_deny(store).await {
return r;
}
let b = read_body(body, 8 << 20).await.unwrap_or_default();
let d =
gojson::decode_struct(&b, SWITCH_REQ_TYPE, &[("n", Kind::Int), ("id", Kind::Str)]);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let lv = live(store).await;
let list = match list_presets(&home, &lv.cfg, &lv.order) {
Ok(l) => l,
Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e),
};
let id = trim(&d.str("id")).to_string();
let target = if !id.is_empty() {
match list.into_iter().find(|p| p.id == id) {
Some(p) => p,
None => {
return fail(
StatusCode::NOT_FOUND,
"preset introuvable (supprimé ou renommé ?)",
);
}
}
} else {
let n = d.int("n");
if n < 1 || n as usize > list.len() {
return fail(StatusCode::BAD_REQUEST, "index hors limites");
}
list.into_iter().nth(n as usize - 1).expect("borné")
};
if let Err(e) = preset_apply(store, &target.path, "switch").await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "preset": target.name }),
)
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
/// `DeletePreset` : refusé pour le preset actif ; emporte son prompt système
/// et sa mesure.
async fn delete_preset(ctx: &Ctx, id: &str) -> Result<(), String> {
let home = &ctx.engine.home;
let p = safe_preset_path(home, id)?;
let target = std::fs::read(&p).map_err(|_| "introuvable".to_string())?;
if preset_fingerprint(&String::from_utf8_lossy(&target))
== config_fingerprint(&ctx.store.config().await)
{
return Err("preset actif, switche d'abord".into());
}
std::fs::remove_file(&p)
.map_err(|e| crate::paths::os_err("remove", &p.to_string_lossy(), &e))?;
let _ = ctx
.store
.del(BK_STATE, &format!("{SYSPROMPT_PREFIX}{id}"))
.await;
if let Ok(Some(b)) = ctx.store.get(BK_STATE, BENCH_KEY).await
&& let Ok(mut m) = serde_json::from_slice::<BTreeMap<String, Value>>(&b)
&& m.remove(id).is_some()
{
let _ = ctx
.store
.put(BK_STATE, BENCH_KEY, gojson::marshal(&m).into_bytes())
.await;
}
Ok(())
}
/// `handlePresetExternalTest` : un appel de complétion minimal.
pub async fn external_test(url: &str, model: &str, key: &str) -> Value {
crate::chat::engine::install_crypto();
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()
.unwrap_or_default();
let payload = json!({
"max_tokens": 1,
"messages": [{ "content": "ping", "role": "user" }],
"model": model,
"stream": false,
});
let mut rq = client
.post(url)
.header("Content-Type", "application/json")
.body(serde_json::to_vec(&payload).unwrap_or_default());
if !key.is_empty() {
rq = rq.header("Authorization", format!("Bearer {key}"));
}
let resp = match rq.send().await {
Ok(r) => r,
Err(e) => return json!({ "ok": false, "error": format!("injoignable : {e}") }),
};
let status = resp.status();
let mut body = Vec::new();
let mut stream = resp;
while body.len() < 2000 {
match stream.chunk().await {
Ok(Some(c)) => body.extend_from_slice(&c),
_ => break,
}
}
body.truncate(2000);
if status != reqwest::StatusCode::OK {
let mut msg = trim(&String::from_utf8_lossy(&body)).to_string();
let m = extract_api_error(&body);
if !m.is_empty() {
msg = m;
}
if msg.is_empty() {
msg = format!(
"{} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("")
);
}
return json!({ "ok": false, "status": status.as_u16(), "error": msg });
}
json!({ "ok": true })
}