diff --git a/README.md b/README.md index 3954cc0..a47e4c2 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | +| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot10-reglages-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot10-reglages-design.md new file mode 100644 index 0000000..52a4fb2 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot10-reglages-design.md @@ -0,0 +1,44 @@ +# Lot 10 — réglages partagés tenus par la façade + +## Contexte + +Quatre routes ne font que lire et écrire des réglages en base, partagés entre +tous les appareils : + +| Route | Port de | Ce qu'elle règle | +|---|---|---| +| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) | +| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:`), repli sur le global (`state/sysprompt`) | +| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) | +| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » | + +Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`, +expérimental ; sans la variable rien ne change). + +Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là +où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se +périme sur l'horodatage du fichier de base, qu'une écriture de la façade +modifie. `facade_settings.go` remet les routes du tunnel à la façade. + +## Écarts assumés + +- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une + valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`. +- Préférences réécrites clé par clé au lieu d'une transaction. +- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait + par `/etc/services`. + +## Parité + +`internal/loki/parity_settings_test.go` tire du Go : + +- 19 adresses publiques normalisées ou refusées ; +- 11 valeurs d'intensité ; +- 29 appels de route, avec le contenu de la base après chacun : + - fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ; + - prompt par preset avec repli, effacement, méthode refusée ; + - intensité acceptée ou refusée ; + - adresse publique. + +`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom +tronqué à 25 caractères) fait échouer le rejeu. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index a264a15..4a892c6 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -141,8 +141,8 @@ var facadeChatRoutes = map[string]bool{ } // withFacadeChat remet la requête à la façade quand elle tient la -// conversation — ou les routes des modèles (facade_models.go), ou la dictée -// (facade_dictate.go). +// conversation — ou les routes des modèles (facade_models.go), la dictée +// (facade_dictate.go), les réglages partagés (facade_settings.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -153,6 +153,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeModels, "modèles indisponibles : " case facadeDictateRoutes[path]: held, unavailable = facadeDictate, "dictée indisponible : " + case facadeSettingsRoutes[path]: + held, unavailable = facadeSettings, "réglages indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index ef1e3d6..c55d9de 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -412,3 +412,29 @@ func TestFacadeDictee(t *testing.T) { t.Fatalf("arrêt demandé à la façade sans la variable (%d appels)", n) } } + +// LOKI_SETTINGS=rust : les réglages partagés partent à la façade ; sans la +// variable, ils restent ici. +func TestFacadeReglages(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + t.Setenv(facadeSettingsEnv, "rust") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadeSettingsRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if n := len(f.recu()); n != len(facadeSettingsRoutes) { + t.Fatalf("%d appels reçus par la façade", n) + } + t.Setenv(facadeSettingsEnv, "") + w := httptest.NewRecorder() + withFacadeChat("/api/prefs", local)(w, httptest.NewRequest("GET", "/api/prefs", nil)) + if w.Body.String() != "go" { + t.Fatalf("relayé sans la variable : %q", w.Body.String()) + } +} diff --git a/internal/loki/facade_settings.go b/internal/loki/facade_settings.go new file mode 100644 index 0000000..c31af2e --- /dev/null +++ b/internal/loki/facade_settings.go @@ -0,0 +1,32 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_settings.go — réglages partagés tenus par la façade Rust +// (LOKI_SETTINGS=rust, lot 10 de la migration ; expérimental, le Go reste le +// défaut) : préférences d'affichage, prompt système, intensité du +// raisonnement, adresse publique. +// +// Les valeurs restent dans la base, aux mêmes clés : ce process les relit là +// où il s'en sert (tour de chat, accès OpenAI). Seules les routes changent de +// main ; celles qui arrivent ici par le tunnel du relais sont remises à la +// façade (withFacadeChat). + +const facadeSettingsEnv = "LOKI_SETTINGS" + +// facadeSettings : la façade tient-elle les réglages partagés ? +func facadeSettings() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeSettingsEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeSettingsRoutes : les routes que la façade sert alors (settings.rs). +var facadeSettingsRoutes = map[string]bool{ + "/api/prefs": true, + "/api/sysprompt": true, + "/api/reasoning-effort": true, + "/api/oai/public": true, +} diff --git a/internal/loki/parity_settings_test.go b/internal/loki/parity_settings_test.go new file mode 100644 index 0000000..741f8a4 --- /dev/null +++ b/internal/loki/parity_settings_test.go @@ -0,0 +1,183 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_settings_test.go — journal de référence des réglages partagés entre +// appareils (lot 10 de la migration Rust) : préférences d'affichage, prompt +// système (par preset, repli global), intensité du raisonnement, adresse +// publique. Rangé dans rust/loki-rs/tests/parity_settings/settings.json ; +// tests/settings.rs le rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteReglages + +const paritySettingsFile = "../../rust/loki-rs/tests/parity_settings/settings.json" + +type paritySettingsStep struct { + // Avant l'appel : configuration remplacée, preset écrit. + Config map[string]string `json:"config,omitempty"` + Preset map[string]string `json:"preset,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Host string `json:"host,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + // Après l'appel : le contenu des clés touchées en base. + Prefs map[string]string `json:"prefs,omitempty"` + State map[string]string `json:"state,omitempty"` +} + +type paritySettingsFixture struct { + PublicURL []struct { + In string `json:"in"` + Out string `json:"out,omitempty"` + Err string `json:"err,omitempty"` + } `json:"public_url"` + EffortValue map[string]string `json:"effort_value"` + Steps []paritySettingsStep `json:"steps"` +} + +func TestPariteReglages(t *testing.T) { + testHome(t) + var fx paritySettingsFixture + for _, in := range []string{"", " ", "exemple.fr", "https://ia.exemple.fr/", "http://10.0.0.5:8090/v1/", + "HTTPS://Exemple.FR:443", "ftp://x.fr", "https://", "https://moi:secret@x.fr", "https://x.fr/?a=1", + "https://x.fr/#ancre", "https://x.fr/api", "https://x.fr:0", "https://x.fr:99999", "https://x.fr:abc", + "https://[::1]:8090", "https://::1", "https://:8080", "exemple.fr:8090/v1"} { + c := struct { + In string `json:"in"` + Out string `json:"out,omitempty"` + Err string `json:"err,omitempty"` + }{In: in} + out, err := normalizePublicURL(in) + if err != nil { + c.Err = err.Error() + } else { + c.Out = out + } + fx.PublicURL = append(fx.PublicURL, c) + } + fx.EffortValue = map[string]string{} + for _, v := range []string{"", "auto", "Default", "off", "NONE", "0", "low", " High ", "medium", "xhigh", "bizarre"} { + fx.EffortValue[v] = reasoningEffortValue(v) + } + + keys := []string{"sysprompt", "sysprompt:alpha", "sysprompt:beta", "public_url"} + run := func(st paritySettingsStep) paritySettingsStep { + if st.Config != nil { + if err := WriteConfig(st.Config); err != nil { + t.Fatal(err) + } + } + for id, content := range st.Preset { + if err := os.MkdirAll(presetsDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + w := httptest.NewRecorder() + r := httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body)) + if st.Host != "" { + r.Host = st.Host + } + switch st.Path { + case "/api/prefs": + handleWebPrefs(w, r) + case "/api/sysprompt": + handleSysPrompt(w, r) + case "/api/reasoning-effort": + handleReasoningEffort(w, r) + case "/api/oai/public": + handlePublicAddress(w, r) + default: + t.Fatalf("route inattendue %s", st.Path) + } + st.Status = w.Code + if err := json.Unmarshal(w.Body.Bytes(), &st.Out); err != nil { + t.Fatalf("%s %s : %v (%s)", st.Method, st.Path, err, w.Body.String()) + } + st.Prefs = loadWebPrefs() + st.State = map[string]string{} + for _, k := range keys { + if v := getStr(bkState, k); v != "" { + st.State[k] = v + } + } + if v := ReadConfig()["REASONING_EFFORT"]; v != "" { + st.State["config:REASONING_EFFORT"] = v + } + return st + } + for _, st := range []paritySettingsStep{ + {Method: "GET", Path: "/api/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":"💩"}`}, + {Method: "POST", Path: "/api/prefs", Body: `{"user_name":"Une très très longue signature éèà","fold_tools":1,"hide_side":"0"}`}, + {Method: "POST", Path: "/api/prefs", Body: `pas du json`}, + {Method: "POST", Path: "/api/prefs", Body: `{"user_name":" ","avatar_user":""}`}, + {Method: "GET", Path: "/api/prefs"}, + // Prompt système : sans preset actif, le global. + {Method: "GET", Path: "/api/sysprompt"}, + {Method: "POST", Path: "/api/sysprompt", Body: `{"text":" Réponds en français. "}`}, + {Method: "POST", Path: "/api/sysprompt", Body: `{"text":5}`}, + {Method: "PUT", Path: "/api/sysprompt"}, + // Preset alpha actif : son propre prompt, repli sur le global tant qu'il + // n'en a pas. + {Config: map[string]string{"MODEL": "a.gguf", "CTX": "8192"}, + Preset: map[string]string{"alpha": "# Alpha\nMODEL=a.gguf\nCTX=8192\n", "beta": "# Beta\nMODEL=b.gguf\n"}, + Method: "GET", Path: "/api/sysprompt"}, + {Method: "POST", Path: "/api/sysprompt", Body: `{"text":"Prompt d'Alpha"}`}, + {Method: "GET", Path: "/api/sysprompt"}, + {Config: map[string]string{"MODEL": "b.gguf"}, Method: "GET", Path: "/api/sysprompt"}, + {Method: "POST", Path: "/api/sysprompt", Body: `{"text":""}`}, + {Config: map[string]string{"MODEL": "a.gguf", "CTX": "8192"}, Method: "POST", Path: "/api/sysprompt", Body: `{"text":" "}`}, + {Method: "GET", Path: "/api/sysprompt"}, + // Intensité du raisonnement. + {Config: map[string]string{"MODEL": "a.gguf"}, Method: "GET", Path: "/api/reasoning-effort"}, + {Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":" HIGH "}`}, + {Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":"auto"}`}, + {Config: map[string]string{"MODEL": "a.gguf", "REASONING": "on", "REASONING_EFFORT": "off"}, Method: "GET", Path: "/api/reasoning-effort"}, + {Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":""}`}, + {Config: map[string]string{"REASONING": "off", "REASONING_EFFORT": "bizarre"}, Method: "GET", Path: "/api/reasoning-effort"}, + // Adresse publique. + {Method: "GET", Path: "/api/oai/public"}, + {Method: "POST", Path: "/api/oai/public", Body: `{"url":"ftp://x"}`}, + {Method: "POST", Path: "/api/oai/public", Body: `pas du json`}, + {Method: "POST", Path: "/api/oai/public", Body: `{"url":" ia.exemple.fr/v1 "}`}, + {Method: "GET", Path: "/api/oai/public"}, + {Method: "POST", Path: "/api/oai/public", Body: `{"url":""}`}, + } { + fx.Steps = append(fx.Steps, run(st)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(paritySettingsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paritySettingsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(paritySettingsFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if string(got) != string(want) { + t.Errorf("les réglages ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteReglages,\n"+ + "puis cargo test --test settings dit si le port Rust suit.\n--- obtenu ---\n%s", paritySettingsFile, got) + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 9b35c1b..70623b7 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -221,7 +221,8 @@ func newWebMux() *http.ServeMux { // withFacadeChat : routes du chat remises à la façade quand elle tient la // conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand // elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée - // (LOKI_DICTATE=rust, facade_dictate.go) ; sans effet sinon. + // (LOKI_DICTATE=rust, facade_dictate.go) et des réglages partagés + // (LOKI_SETTINGS=rust, facade_settings.go) ; sans effet sinon. api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 6f5cde0..e094f36 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -32,11 +32,19 @@ pub struct Ctx { pub models: Option>, /// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) ; `None` : au Go. pub dictate: Option>, + /// 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 { diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 3da8474..7b1913c 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -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; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index fcd56b3..06cd973 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -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"); diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index ebc6839..c9bdac1 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -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>, + /// 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) -> Response { // 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) { diff --git a/rust/loki-rs/src/settings.rs b/rust/loki-rs/src/settings.rs new file mode 100644 index 0000000..dbf3599 --- /dev/null +++ b/rust/loki-rs/src/settings.rs @@ -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> { + 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::()).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, 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, + host: String, + path: String, + query: String, + fragment: String, +} + +fn go_url_parse(raw: &str) -> Option { + 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 { + 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 { + 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::().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, 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 { + 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 = 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) -> Response { + 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, from_go: bool) -> Response { + 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" }), + ), + } +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index bbddc8f..b45e3ca 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -465,6 +465,7 @@ pub struct Opts { pub engine_port: Option, pub models_hf: Option, pub dictate: Option>, + 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; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 2c17ae7..c3810f9 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -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; diff --git a/rust/loki-rs/tests/parity_settings/settings.json b/rust/loki-rs/tests/parity_settings/settings.json new file mode 100644 index 0000000..ec65cae --- /dev/null +++ b/rust/loki-rs/tests/parity_settings/settings.json @@ -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." + } + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 58b0b98..02409c1 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -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; diff --git a/rust/loki-rs/tests/settings.rs b/rust/loki-rs/tests/settings.rs new file mode 100644 index 0000000..eaa73de --- /dev/null +++ b/rust/loki-rs/tests/settings.rs @@ -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(""), "{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 = 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 = 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 = 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()); + } +}