Lot 10 : réglages partagés tenus par la façade (LOKI_SETTINGS=rust)

La façade sert /api/prefs, /api/sysprompt, /api/reasoning-effort et
/api/oai/public quand LOKI_SETTINGS=rust (expérimental ; le Go reste le
défaut), sur les mêmes clés de la base : préférences fusionnées et
nettoyées (prénom borné, avatars en liste fermée, valeurs vides
effacées), prompt système du preset actif avec repli sur le global,
intensité du raisonnement en liste blanche, adresse publique normalisée.

Côté Go, facade_settings.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais.

Parité : parity_settings_test.go tire du Go 19 adresses, 11 intensités et
29 appels de route avec le contenu de la base après chacun, rejoués par
tests/settings.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 16:25:51 +00:00
1 parent 46bfab3343
commit 28f90543de
17 files changed
+1689 -9

No files matched your search

+8
View File
@@ -32,11 +32,19 @@ pub struct Ctx {
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,
/// 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
}
/// 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 {
+1
View File
@@ -26,6 +26,7 @@ pub mod oai;
pub mod paths;
pub mod presets;
pub mod proxy;
pub mod settings;
pub mod store;
pub mod sysinfo;
pub mod tools;
+8 -1
View File
@@ -124,6 +124,9 @@ fn serve() -> ExitCode {
// LOKI_DICTATE=rust : la dictée vocale aussi (le Go lui demande alors
// d'arrêter son serveur quand il libère la VRAM).
let dictate_rust = held_by_facade("LOKI_DICTATE");
// LOKI_SETTINGS=rust : les réglages partagés (préférences, prompt
// système, intensité du raisonnement, adresse publique).
let settings_rust = held_by_facade("LOKI_SETTINGS");
let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())];
if chat_rust {
env.push(("LOKI_CHAT".into(), "rust".into()));
@@ -134,7 +137,10 @@ fn serve() -> ExitCode {
if dictate_rust {
env.push(("LOKI_DICTATE".into(), "rust".into()));
}
if chat_rust || models_rust || dictate_rust {
if settings_rust {
env.push(("LOKI_SETTINGS".into(), "rust".into()));
}
if chat_rust || models_rust || dictate_rust || settings_rust {
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
}
let sup = child::Supervisor::spawn(go_bin, go_port, env);
@@ -196,6 +202,7 @@ fn serve() -> ExitCode {
chat,
models,
dictate,
settings: settings_rust,
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
+9 -5
View File
@@ -69,6 +69,8 @@ pub struct Config {
/// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) : servie ici ;
/// `None` : relayée au Go.
pub dictate: Option<Arc<crate::dictate::Dictation>>,
/// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`).
pub settings: bool,
}
struct Proxy {
@@ -98,6 +100,7 @@ pub async fn serve(
chat: cfg.chat,
models: cfg.models,
dictate: cfg.dictate,
settings: cfg.settings,
internal_token: cfg.internal_token.clone(),
},
});
@@ -137,11 +140,9 @@ pub async fn serve(
impl Proxy {
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
// Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la
// façade le trafic du chat, des modèles et de la dictée qui lui arrive
// sans passer par elle.
let from_go =
(self.ctx.chat.is_some() || self.ctx.models.is_some() || self.ctx.dictate.is_some())
&& self.ctx.from_go(req.headers());
// façade le trafic des lots qu'elle tient et qui lui arrive sans passer
// par elle.
let from_go = self.ctx.holds_any() && self.ctx.from_go(req.headers());
// Jamais de confiance accordée sur la foi d'un en-tête du client :
// ni « je viens du tunnel », ni « je suis la façade ».
req.headers_mut().remove(VIA_TUNNEL);
@@ -181,6 +182,9 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if self.ctx.settings && crate::settings::handles(path) {
return crate::settings::serve(&self.ctx, req, from_go).await;
}
if let Some(d) = &self.ctx.dictate
&& crate::dictate::handles(path)
{
+490
View File
@@ -0,0 +1,490 @@
//! Réglages partagés entre appareils — port de `web_prefs.go`,
//! `web_sysprompt.go`, `handleReasoningEffort` et `handlePublicAddress`
//! (lot 10 de la migration), servis par la façade quand
//! `LOKI_SETTINGS=rust` (expérimental ; le Go par défaut) :
//!
//! - `GET|POST /api/prefs` : thème et affichage, prénom, avatars ;
//! - `GET|POST /api/sysprompt` : prompt système du preset actif (repli sur le
//! prompt global) ;
//! - `GET|POST /api/reasoning-effort` : intensité du raisonnement
//! (`REASONING_EFFORT` de la configuration vivante) ;
//! - `GET|POST /api/oai/public` : adresse publique de l'instance.
//!
//! Tout vit dans la base du Go (mêmes clés) : le Go relit ces valeurs là où il
//! s'en sert (tour de chat, accès OpenAI).
//!
//! Écarts assumés : message d'un corps JSON syntaxiquement illisible (serde) ;
//! préférences réécrites clé par clé au lieu d'une transaction (le Go
//! remplaçait le bucket d'un bloc) ; port d'adresse publique nommé
//! (`:https`) refusé — le Go le résolvait par /etc/services.
use std::collections::{BTreeMap, HashMap};
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::chat::wire::{reasoning_active, reasoning_effort_value};
use crate::models::body_json;
use crate::presets;
use crate::proxy::Body;
use crate::store::BK_STATE;
const BK_PREFS: &str = "prefs";
const BK_CONFIG: &str = "config";
const SYSPROMPT_GLOBAL: &str = "sysprompt";
const SYSPROMPT_PREFIX: &str = "sysprompt:";
const PUBLIC_URL_KEY: &str = "public_url";
/// Valeurs proposées pour `REASONING_EFFORT` (`ReasoningEfforts`).
pub const REASONING_EFFORTS: [&str; 5] = ["none", "low", "medium", "high", "xhigh"];
/// Avatars proposés (liste fermée).
pub const AVATARS: [&str; 16] = [
"🙂", "😎", "🧑", "👤", "🦊", "🐱", "🐼", "🦉", "🚀", "⚡", "🌙", "🔥", "🌿", "🎯", "🧠", "🤖",
];
/// Routes servies ici.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/prefs" | "/api/sysprompt" | "/api/reasoning-effort" | "/api/oai/public"
)
}
// ---------------------------------------------------------------------------
// Préférences
// ---------------------------------------------------------------------------
/// `webPrefsAllowed` : clés à valeurs fermées.
fn allowed(key: &str) -> Option<&'static [&'static str]> {
Some(match key {
"theme" => &["light", "dark"],
"hide_reasoning" | "hide_tools" | "fold_tools" | "hide_side" | "hide_stats" => &["0", "1"],
_ => return None,
})
}
/// `strings.TrimSpace` (espaces Unicode).
fn go_trim(s: &str) -> &str {
s.trim_matches(char::is_whitespace)
}
/// `webPrefsText` : nettoyage des préférences libres ; `None` : refusée.
fn clean_text(key: &str, v: &str) -> Option<Option<String>> {
match key {
"user_name" => {
let v: String = v
.chars()
.filter(|&c| c >= '\u{20}' && c != '\u{7f}')
.collect();
let mut v = go_trim(&v).to_string();
if v.chars().count() > 24 {
v = go_trim(&v.chars().take(24).collect::<String>()).to_string();
}
Some(Some(v))
}
"avatar_user" | "avatar_ai" => {
let v = go_trim(v);
if v.is_empty() || AVATARS.contains(&v) {
Some(Some(v.to_string()))
} else {
Some(None)
}
}
_ => None,
}
}
/// `saveWebPrefs` : fusionne les valeurs valides de `req` dans `prefs`.
pub fn merge_prefs(prefs: &mut BTreeMap<String, String>, req: &Value) {
let Some(obj) = req.as_object() else {
return;
};
for (k, v) in obj {
// map[string]string du Go : une valeur non textuelle est sautée.
let Some(v) = v.as_str() else { continue };
if let Some(res) = clean_text(k, v) {
if let Some(val) = res {
prefs.insert(k.clone(), val);
}
continue;
}
let Some(ok) = allowed(k) else { continue };
let v = go_trim(v).to_lowercase();
if ok.contains(&v.as_str()) {
prefs.insert(k.clone(), v);
}
}
}
// ---------------------------------------------------------------------------
// Adresse publique
// ---------------------------------------------------------------------------
/// Caractères refusés dans un nom d'hôte par `url.Parse` (`InvalidHostError`).
fn host_char_ok(c: char) -> bool {
!c.is_ascii() || c.is_ascii_alphanumeric() || "-_.~!$&'()*+,;=:[]<>\"%".contains(c)
}
/// `url.Parse` du Go, réduit à ce que l'adresse publique examine.
struct GoUrl {
scheme: String,
user: Option<String>,
host: String,
path: String,
query: String,
fragment: String,
}
fn go_url_parse(raw: &str) -> Option<GoUrl> {
let (rest, fragment) = match raw.split_once('#') {
Some((a, b)) => (a, b.to_string()),
None => (raw, String::new()),
};
let (scheme, rest) = rest.split_once("://")?;
let mut sc = scheme.chars();
if !sc.next().is_some_and(|c| c.is_ascii_alphabetic())
|| !sc.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
{
return None;
}
let (rest, query) = match rest.split_once('?') {
Some((a, b)) => (a, b.to_string()),
None => (rest, String::new()),
};
let (authority, path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
let (user, host) = match authority.rfind('@') {
Some(i) => {
let info = &authority[..i];
let name = info.split(':').next().unwrap_or("");
(Some(percent_decode(name)?), &authority[i + 1..])
}
None => (None, authority),
};
// Port : des chiffres seulement après le dernier « : » (hors crochets).
if let Some(stripped) = host.strip_prefix('[') {
let end = stripped.find(']')?;
let after = &stripped[end + 1..];
if !after.is_empty()
&& !(after.starts_with(':') && after[1..].bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
} else if let Some(i) = host.rfind(':')
&& !host[i + 1..].bytes().all(|b| b.is_ascii_digit())
{
return None;
}
if !host.chars().all(host_char_ok) {
return None;
}
Some(GoUrl {
scheme: scheme.to_string(),
user,
host: host.to_string(),
path: percent_decode(path)?,
query,
fragment,
})
}
fn percent_decode(s: &str) -> Option<String> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' {
let h = std::str::from_utf8(b.get(i + 1..i + 3)?).ok()?;
out.push(u8::from_str_radix(h, 16).ok()?);
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8(out).ok()
}
/// `net.SplitHostPort`, réduit : (hôte, port) ou rien.
fn split_host_port(host: &str) -> Option<(&str, &str)> {
if let Some(rest) = host.strip_prefix('[') {
let end = rest.find(']')?;
let after = &rest[end + 1..];
return after.strip_prefix(':').map(|p| (&rest[..end], p));
}
let i = host.rfind(':')?;
let h = &host[..i];
if h.contains(':') {
return None; // trop de « : »
}
Some((h, &host[i + 1..]))
}
/// `normalizePublicURL` : « schéma://hôte[:port] », sans slash final.
pub fn normalize_public_url(raw: &str) -> Result<String, String> {
let raw = go_trim(raw);
if raw.is_empty() {
return Ok(String::new());
}
let raw = if raw.contains("://") {
raw.to_string()
} else {
format!("https://{raw}")
};
let u = go_url_parse(&raw).ok_or_else(|| format!("adresse illisible : {raw}"))?;
let scheme = u.scheme.to_lowercase();
if scheme != "http" && scheme != "https" {
return Err(format!(
"adresse en {scheme}:// — seuls http:// et https:// sont acceptés"
));
}
if u.host.is_empty() {
return Err("adresse sans nom de machine".into());
}
if let Some(name) = u.user {
return Err(format!(
"l'adresse ne doit pas porter d'identifiants ({name}@…)"
));
}
if !u.query.is_empty() || !u.fragment.is_empty() {
return Err("l'adresse ne doit porter ni paramètre ni ancre".into());
}
match u.path.strip_suffix('/').unwrap_or(&u.path) {
"" | "/v1" => {}
_ => {
return Err(format!(
"l'adresse ne doit pas contenir de chemin ({}) : Loki sert /v1 à la racine de son port",
u.path
));
}
}
match split_host_port(&u.host) {
Some((h, port)) => {
if h.is_empty() {
return Err("adresse sans nom de machine".into());
}
if !port.parse::<u32>().is_ok_and(|n| n > 0 && n <= 65535) {
return Err(format!("port invalide : {port}"));
}
}
None if u.host.contains(':') && !u.host.starts_with('[') => {
return Err(format!(
"adresse IPv6 à écrire entre crochets : [{}]",
u.host
));
}
None => {}
}
Ok(format!("{scheme}://{}", u.host))
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
/// Sérialise les écritures de préférences (lecture, fusion, écriture).
static PREFS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn load_prefs(ctx: &Ctx) -> Result<BTreeMap<String, String>, String> {
Ok(ctx.store.all(BK_PREFS).await?.into_iter().collect())
}
/// `putStr` : une chaîne vide efface la clé.
async fn put_str(ctx: &Ctx, bucket: &str, key: &str, val: &str) -> Result<(), String> {
if val.is_empty() {
ctx.store.del(bucket, key).await
} else {
ctx.store.put(bucket, key, val.as_bytes().to_vec()).await
}
}
/// Erreur de type d'`encoding/json` pour un champ texte attendu.
fn go_type_error(field: &str, v: &Value) -> Option<String> {
let kind = match v {
Value::Null | Value::String(_) => return None,
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::Array(_) => "array",
Value::Object(_) => "object",
};
Some(format!(
"json: cannot unmarshal {kind} into Go struct field .{field} of type string"
))
}
async fn active_preset(ctx: &Ctx) -> String {
let cfg: HashMap<String, String> = ctx.store.config().await;
let order = presets::parse_preset_order(
ctx.store
.get(BK_STATE, presets::PRESET_ORDER_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
let home = ctx.engine.home.clone();
tokio::task::spawn_blocking(move || presets::active_preset_id(&home, &cfg, &order))
.await
.unwrap_or_default()
}
/// `readSysPrompt` : celui du preset actif, sinon le global.
async fn read_sysprompt(ctx: &Ctx) -> String {
let id = active_preset(ctx).await;
if !id.is_empty() {
let s = ctx
.store
.get_str(BK_STATE, &format!("{SYSPROMPT_PREFIX}{id}"))
.await;
if !go_trim(&s).is_empty() {
return go_trim(&s).to_string();
}
}
ctx.store.get_str(BK_STATE, SYSPROMPT_GLOBAL).await
}
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": e.into() }))
}
/// `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 post = parts.method == Method::POST;
match parts.uri.path() {
"/api/prefs" if post => {
let req = body_json(body).await.unwrap_or(Value::Null);
let _guard = PREFS_LOCK.lock().await;
let old = match load_prefs(ctx).await {
Ok(p) => p,
Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e),
};
let mut prefs = old.clone();
merge_prefs(&mut prefs, &req);
// replaceKV : les valeurs vides ne sont pas gardées.
for (k, v) in &prefs {
let res = if v.is_empty() {
if old.contains_key(k) {
ctx.store.del(BK_PREFS, k).await
} else {
Ok(())
}
} else if old.get(k) != Some(v) {
ctx.store.put(BK_PREFS, k, v.as_bytes().to_vec()).await
} else {
Ok(())
};
if let Err(e) = res {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
}
send_json(StatusCode::OK, &json!({ "ok": true, "prefs": prefs }))
}
"/api/prefs" => match load_prefs(ctx).await {
Ok(p) => send_json(
StatusCode::OK,
&json!({ "ok": true, "prefs": p, "avatars": AVATARS }),
),
Err(_) => send_json(
StatusCode::OK,
&json!({ "ok": true, "prefs": {}, "avatars": AVATARS }),
),
},
"/api/sysprompt" => match parts.method {
Method::GET => send_json(
StatusCode::OK,
&json!({ "ok": true, "text": read_sysprompt(ctx).await }),
),
Method::POST => {
let v = match body_json(body).await {
Ok(v) => v,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
let raw = v.get("text").cloned().unwrap_or(Value::Null);
if let Some(e) = go_type_error("text", &raw) {
return fail(StatusCode::BAD_REQUEST, e);
}
let text = go_trim(raw.as_str().unwrap_or("")).to_string();
let id = active_preset(ctx).await;
let key = if id.is_empty() {
SYSPROMPT_GLOBAL.to_string()
} else {
format!("{SYSPROMPT_PREFIX}{id}")
};
if let Err(e) = put_str(ctx, BK_STATE, &key, &text).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "text": read_sysprompt(ctx).await }),
)
}
_ => fail(StatusCode::METHOD_NOT_ALLOWED, "méthode non autorisée"),
},
"/api/reasoning-effort" => {
if post {
let v = body_json(body).await.unwrap_or(Value::Null);
let v =
go_trim(v.get("value").and_then(Value::as_str).unwrap_or("")).to_lowercase();
if !v.is_empty() && !REASONING_EFFORTS.contains(&v.as_str()) {
return fail(StatusCode::BAD_REQUEST, format!("valeur inconnue : {v}"));
}
if let Err(e) = put_str(ctx, BK_CONFIG, "REASONING_EFFORT", &v).await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
}
let cfg = ctx.store.config().await;
let get = |k: &str| cfg.get(k).map(String::as_str).unwrap_or("");
send_json(
StatusCode::OK,
&json!({
"ok": true,
"value": reasoning_effort_value(get("REASONING_EFFORT")),
"on": reasoning_active(get("REASONING")),
"choices": REASONING_EFFORTS,
}),
)
}
"/api/oai/public" if post => {
let v = match body_json(body).await {
Ok(Value::Object(o)) => o,
_ => return fail(StatusCode::BAD_REQUEST, "requête illisible"),
};
let raw = v.get("url").cloned().unwrap_or(Value::Null);
if go_type_error("url", &raw).is_some() {
return fail(StatusCode::BAD_REQUEST, "requête illisible");
}
let clean = match normalize_public_url(raw.as_str().unwrap_or("")) {
Ok(c) => c,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
if let Err(e) = put_str(ctx, BK_STATE, PUBLIC_URL_KEY, &clean).await {
return fail(StatusCode::BAD_REQUEST, e);
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "url": clean, "set": !clean.is_empty() }),
)
}
"/api/oai/public" => {
let u = ctx.store.get_str(BK_STATE, PUBLIC_URL_KEY).await;
send_json(
StatusCode::OK,
&json!({ "ok": true, "url": u, "set": !u.is_empty() }),
)
}
_ => send_json(
StatusCode::NOT_FOUND,
&json!({ "error": "404 page not found" }),
),
}
}
+2
View File
@@ -465,6 +465,7 @@ pub struct Opts {
pub engine_port: Option<u16>,
pub models_hf: Option<String>,
pub dictate: Option<Arc<loki_rs::dictate::Dictation>>,
pub settings: bool,
}
/// Comme [`start`], avec la conversation tenue par la façade
@@ -556,6 +557,7 @@ pub async fn start_with(
})
}),
dictate: opts.dictate,
settings: opts.settings,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+1
View File
@@ -96,6 +96,7 @@ async fn banc(cle: Option<&str>) -> Banc {
chat: None,
models: None,
dictate: None,
settings: false,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
@@ -0,0 +1,731 @@
{
"public_url": [
{
"in": ""
},
{
"in": " "
},
{
"in": "exemple.fr",
"out": "https://exemple.fr"
},
{
"in": "https://ia.exemple.fr/",
"out": "https://ia.exemple.fr"
},
{
"in": "http://10.0.0.5:8090/v1/",
"out": "http://10.0.0.5:8090"
},
{
"in": "HTTPS://Exemple.FR:443",
"out": "https://Exemple.FR:443"
},
{
"in": "ftp://x.fr",
"err": "adresse en ftp:// — seuls http:// et https:// sont acceptés"
},
{
"in": "https://",
"err": "adresse sans nom de machine"
},
{
"in": "https://moi:secret@x.fr",
"err": "l'adresse ne doit pas porter d'identifiants (moi@…)"
},
{
"in": "https://x.fr/?a=1",
"err": "l'adresse ne doit porter ni paramètre ni ancre"
},
{
"in": "https://x.fr/#ancre",
"err": "l'adresse ne doit porter ni paramètre ni ancre"
},
{
"in": "https://x.fr/api",
"err": "l'adresse ne doit pas contenir de chemin (/api) : Loki sert /v1 à la racine de son port"
},
{
"in": "https://x.fr:0",
"err": "port invalide : 0"
},
{
"in": "https://x.fr:99999",
"err": "port invalide : 99999"
},
{
"in": "https://x.fr:abc",
"err": "adresse illisible : https://x.fr:abc"
},
{
"in": "https://[::1]:8090",
"out": "https://[::1]:8090"
},
{
"in": "https://::1",
"err": "adresse IPv6 à écrire entre crochets : [::1]"
},
{
"in": "https://:8080",
"err": "adresse sans nom de machine"
},
{
"in": "exemple.fr:8090/v1",
"out": "https://exemple.fr:8090"
}
],
"effort_value": {
"": "",
" High ": "high",
"0": "none",
"Default": "",
"NONE": "none",
"auto": "",
"bizarre": "bizarre",
"low": "low",
"medium": "medium",
"off": "none",
"xhigh": "xhigh"
},
"steps": [
{
"method": "GET",
"path": "/api/prefs",
"status": 200,
"out": {
"avatars": [
"🙂",
"😎",
"🧑",
"👤",
"🦊",
"🐱",
"🐼",
"🦉",
"🚀",
"⚡",
"🌙",
"🔥",
"🌿",
"🎯",
"🧠",
"🤖"
],
"ok": true,
"prefs": {}
}
},
{
"method": "POST",
"path": "/api/prefs",
"body": "{\"theme\":\" DARK \",\"hide_tools\":\"1\",\"hide_stats\":\"2\",\"inconnue\":\"x\",\"user_name\":\" Ma\\u0007rie \",\"avatar_user\":\"🦊\",\"avatar_ai\":\"💩\"}",
"status": 200,
"out": {
"ok": true,
"prefs": {
"avatar_user": "🦊",
"hide_tools": "1",
"theme": "dark",
"user_name": "Marie"
}
},
"prefs": {
"avatar_user": "🦊",
"hide_tools": "1",
"theme": "dark",
"user_name": "Marie"
}
},
{
"method": "POST",
"path": "/api/prefs",
"body": "{\"user_name\":\"Une très très longue signature éèà\",\"fold_tools\":1,\"hide_side\":\"0\"}",
"status": 200,
"out": {
"ok": true,
"prefs": {
"avatar_user": "🦊",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": "Une très très longue sig"
}
},
"prefs": {
"avatar_user": "🦊",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": "Une très très longue sig"
}
},
{
"method": "POST",
"path": "/api/prefs",
"body": "pas du json",
"status": 200,
"out": {
"ok": true,
"prefs": {
"avatar_user": "🦊",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": "Une très très longue sig"
}
},
"prefs": {
"avatar_user": "🦊",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": "Une très très longue sig"
}
},
{
"method": "POST",
"path": "/api/prefs",
"body": "{\"user_name\":\" \",\"avatar_user\":\"\"}",
"status": 200,
"out": {
"ok": true,
"prefs": {
"avatar_user": "",
"hide_side": "0",
"hide_tools": "1",
"theme": "dark",
"user_name": ""
}
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
}
},
{
"method": "GET",
"path": "/api/prefs",
"status": 200,
"out": {
"avatars": [
"🙂",
"😎",
"🧑",
"👤",
"🦊",
"🐱",
"🐼",
"🦉",
"🚀",
"⚡",
"🌙",
"🔥",
"🌿",
"🎯",
"🧠",
"🤖"
],
"ok": true,
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
}
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
}
},
{
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
}
},
{
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":\" Réponds en français. \"}",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":5}",
"status": 400,
"out": {
"error": "json: cannot unmarshal number into Go struct field .text of type string",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "PUT",
"path": "/api/sysprompt",
"status": 405,
"out": {
"error": "méthode non autorisée",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"config": {
"CTX": "8192",
"MODEL": "a.gguf"
},
"preset": {
"alpha": "# Alpha\nMODEL=a.gguf\nCTX=8192\n",
"beta": "# Beta\nMODEL=b.gguf\n"
},
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":\"Prompt d'Alpha\"}",
"status": 200,
"out": {
"ok": true,
"text": "Prompt d'Alpha"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français.",
"sysprompt:alpha": "Prompt d'Alpha"
}
},
{
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": "Prompt d'Alpha"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français.",
"sysprompt:alpha": "Prompt d'Alpha"
}
},
{
"config": {
"MODEL": "b.gguf"
},
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français.",
"sysprompt:alpha": "Prompt d'Alpha"
}
},
{
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":\"\"}",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français.",
"sysprompt:alpha": "Prompt d'Alpha"
}
},
{
"config": {
"CTX": "8192",
"MODEL": "a.gguf"
},
"method": "POST",
"path": "/api/sysprompt",
"body": "{\"text\":\" \"}",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "GET",
"path": "/api/sysprompt",
"status": 200,
"out": {
"ok": true,
"text": "Réponds en français."
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"config": {
"MODEL": "a.gguf"
},
"method": "GET",
"path": "/api/reasoning-effort",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": false,
"value": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/reasoning-effort",
"body": "{\"value\":\" HIGH \"}",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": false,
"value": "high"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "high",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/reasoning-effort",
"body": "{\"value\":\"auto\"}",
"status": 400,
"out": {
"error": "valeur inconnue : auto",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "high",
"sysprompt": "Réponds en français."
}
},
{
"config": {
"MODEL": "a.gguf",
"REASONING": "on",
"REASONING_EFFORT": "off"
},
"method": "GET",
"path": "/api/reasoning-effort",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": true,
"value": "none"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "off",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/reasoning-effort",
"body": "{\"value\":\"\"}",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": true,
"value": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"sysprompt": "Réponds en français."
}
},
{
"config": {
"REASONING": "off",
"REASONING_EFFORT": "bizarre"
},
"method": "GET",
"path": "/api/reasoning-effort",
"status": 200,
"out": {
"choices": [
"none",
"low",
"medium",
"high",
"xhigh"
],
"ok": true,
"on": false,
"value": "bizarre"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
},
{
"method": "GET",
"path": "/api/oai/public",
"status": 200,
"out": {
"ok": true,
"set": false,
"url": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/oai/public",
"body": "{\"url\":\"ftp://x\"}",
"status": 400,
"out": {
"error": "adresse en ftp:// — seuls http:// et https:// sont acceptés",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/oai/public",
"body": "pas du json",
"status": 400,
"out": {
"error": "requête illisible",
"ok": false
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/oai/public",
"body": "{\"url\":\" ia.exemple.fr/v1 \"}",
"status": 200,
"out": {
"ok": true,
"set": true,
"url": "https://ia.exemple.fr"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"public_url": "https://ia.exemple.fr",
"sysprompt": "Réponds en français."
}
},
{
"method": "GET",
"path": "/api/oai/public",
"status": 200,
"out": {
"ok": true,
"set": true,
"url": "https://ia.exemple.fr"
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"public_url": "https://ia.exemple.fr",
"sysprompt": "Réponds en français."
}
},
{
"method": "POST",
"path": "/api/oai/public",
"body": "{\"url\":\"\"}",
"status": 200,
"out": {
"ok": true,
"set": false,
"url": ""
},
"prefs": {
"hide_side": "0",
"hide_tools": "1",
"theme": "dark"
},
"state": {
"config:REASONING_EFFORT": "bizarre",
"sysprompt": "Réponds en français."
}
}
]
}
+1
View File
@@ -102,6 +102,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
chat: None,
models: None,
dictate: None,
settings: false,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
+146
View File
@@ -0,0 +1,146 @@
//! Lot 10 : réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_settings_test.go` →
//! `tests/parity_settings/settings.json`) est rejoué : normalisation de
//! l'adresse publique, intensité du raisonnement, puis 29 appels de route
//! par la façade, avec le contenu de la base après chacun.
mod common;
use std::collections::BTreeMap;
use common::fakego;
use loki_rs::settings;
use serde_json::Value;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_settings/settings.json")).expect("journal")
}
#[test]
fn parite_des_fonctions_pures() {
let fx = fixture();
for c in fx["public_url"].as_array().unwrap() {
let input = c["in"].as_str().unwrap();
match settings::normalize_public_url(input) {
Ok(o) => {
assert!(
c.get("err").is_none(),
"{input:?} : erreur attendue {}",
c["err"]
);
assert_eq!(o, c["out"].as_str().unwrap_or(""), "{input:?}");
}
Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or("<ok attendu>"), "{input:?}"),
}
}
for (v, want) in fx["effort_value"].as_object().unwrap() {
assert_eq!(
loki_rs::chat::wire::reasoning_effort_value(v),
want.as_str().unwrap(),
"{v:?}"
);
}
}
#[tokio::test]
async fn parite_des_routes() {
let fx = fixture();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let opts = fakego::Opts {
settings: true,
..Default::default()
};
let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await;
let keys = [
"sysprompt",
"sysprompt:alpha",
"sysprompt:beta",
"public_url",
];
for (i, st) in fx["steps"].as_array().unwrap().iter().enumerate() {
if let Some(cfg) = st.get("config").and_then(Value::as_object) {
let mut s = go.store.lock().unwrap();
let b = s.entry("config".into()).or_default();
b.clear();
for (k, v) in cfg {
b.insert(k.clone(), v.as_str().unwrap().as_bytes().to_vec());
}
}
if let Some(p) = st.get("preset").and_then(Value::as_object) {
std::fs::create_dir_all(home.join("presets")).unwrap();
for (id, content) in p {
std::fs::write(
home.join("presets").join(format!("{id}.env")),
content.as_str().unwrap(),
)
.unwrap();
}
}
let (m, p) = (st["method"].as_str().unwrap(), st["path"].as_str().unwrap());
let body = st.get("body").and_then(Value::as_str).unwrap_or("");
let (code, b) = fakego::send(facade, m, p, &[], body).await;
let ctx = format!("étape {i} : {m} {p} {body}");
assert_eq!(
code.as_u16() as u64,
st["status"].as_u64().unwrap(),
"{ctx}"
);
let got: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(got, st["out"], "{ctx}");
let prefs: BTreeMap<String, String> = go
.store
.lock()
.unwrap()
.get("prefs")
.map(|b| {
b.iter()
.map(|(k, v)| (k.clone(), String::from_utf8_lossy(v).into_owned()))
.collect()
})
.unwrap_or_default();
let want_prefs: BTreeMap<String, String> = serde_json::from_value(
st.get("prefs")
.cloned()
.unwrap_or(Value::Object(Default::default())),
)
.unwrap();
assert_eq!(prefs, want_prefs, "{ctx} (préférences en base)");
let mut state = BTreeMap::new();
for k in keys {
if let Some(v) = go.get("state", k).filter(|v| !v.is_empty()) {
state.insert(k.to_string(), v);
}
}
if let Some(v) = go
.get("config", "REASONING_EFFORT")
.filter(|v| !v.is_empty())
{
state.insert("config:REASONING_EFFORT".into(), v);
}
let want_state: BTreeMap<String, String> = serde_json::from_value(
st.get("state")
.cloned()
.unwrap_or(Value::Object(Default::default())),
)
.unwrap();
assert_eq!(state, want_state, "{ctx} (base)");
}
}
#[tokio::test]
async fn sans_la_variable_les_reglages_restent_au_go() {
let tmp = tempfile::tempdir().unwrap();
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
for p in [
"/api/prefs",
"/api/sysprompt",
"/api/reasoning-effort",
"/api/oai/public",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert_eq!(&b[..], format!("go:{p}").as_bytes());
}
}