Lot 23 : sonde de gabarit en Rust (avec LOKI_CHAT=rust)

Port de chat_tplprobe.go : la façade sonde le gabarit du moteur après ses
complétions (/health, /props, /apply-template), avec les verdicts
preserves_history, prefix_stable et renders_reasoning, le cache par
build/modèle/gabarit/forme et la relance bornée.

- l'écho du raisonnement suit le verdict de la sonde (echo_policy_for) ;
- NUDGE_IN_TOOL ne garde plus la conversation au Go : le rappel de budget
  va dans le dernier résultat d'outil quand le préfixe n'est pas stable ;
- le Go lit le verdict de la façade (/internal/facade/v1/chat/tpl) quand
  elle tient la conversation.

Parité : parity_tplprobe_test.go tire du Go un journal d'échanges rejoué
octet pour octet par tests/tplprobe.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 21:34:57 +00:00
1 parent 78766a28d9
commit 0f3a3d52c1
17 files changed
+5006 -22

No files matched your search

+195 -1
View File
@@ -497,9 +497,10 @@ use loki_rs::chat::compact::{CompactCaps, CompactConfig, CompactMemory};
use loki_rs::chat::engine::EngineHttp;
use loki_rs::chat::host::{ChatShared, EngineSummarizer, GoTurnHost, ImageCache, TurnCompact};
use loki_rs::chat::stream::RetryPatterns;
use loki_rs::chat::tplprobe::{ProbeResult, Shape};
use loki_rs::chat::turn::{Cancel, TurnConfig, run_turn};
use loki_rs::chat::types::{Message, StreamEvent, Tool, ToolFunction};
use loki_rs::chat::wire::{ChatEndpoint, EchoRefusals, EffortFallbacks, TurnReasoning};
use loki_rs::chat::wire::{ChatEndpoint, EchoRefusals, EffortFallbacks, TplTri, TurnReasoning};
/// Faux moteur : rend les réponses SSE dans l'ordre, garde les requêtes.
async fn fake_engine(
@@ -541,6 +542,22 @@ async fn fake_engine_gated(
if req.uri().path() == "/health" {
return ok("ok".into());
}
// Sonde de gabarit (lancée après chaque complétion
// du fil principal) : un gabarit qui rend toujours
// la même chose. Ses rendus ne comptent pas parmi
// les requêtes vues.
if req.uri().path() == "/props" {
return ok(r#"{"build_info":"b1-x","model_path":"/m/m1.gguf","chat_template":"t"}"#.into());
}
if req.uri().path() == "/apply-template" {
let body = req.into_body().collect().await.unwrap().to_bytes();
let b: serde_json::Value = serde_json::from_slice(&body).unwrap();
PROBE_RENDERS.lock().unwrap().push(b.clone());
return ok(
serde_json::json!({ "prompt": render_without_reasoning(&b) })
.to_string(),
);
}
let auth = req
.headers()
.get("authorization")
@@ -578,6 +595,34 @@ async fn fake_engine_gated(
(format!("http://{addr}/v1/chat/completions"), seen)
}
/// Un gabarit qui ne rend jamais `reasoning_content` : rôle, texte, appels
/// d'outils, et l'amorce de réponse si elle est demandée.
fn render_without_reasoning(b: &serde_json::Value) -> String {
let mut out: Vec<String> = Vec::new();
for m in b["messages"].as_array().into_iter().flatten() {
let calls: Vec<&str> = m["tool_calls"]
.as_array()
.into_iter()
.flatten()
.filter_map(|c| c["function"]["name"].as_str())
.collect();
out.push(format!(
"{}:{}{}",
m["role"].as_str().unwrap_or(""),
m["content"].as_str().unwrap_or(""),
calls.join(",")
));
}
let mut p = out.join("\n");
if b["add_generation_prompt"] == true {
p.push_str("\nassistant:");
}
p
}
/// Rendus demandés par la sonde de gabarit, tous moteurs confondus.
static PROBE_RENDERS: StdMutex<Vec<serde_json::Value>> = StdMutex::new(Vec::new());
fn sse(chunks: &[serde_json::Value]) -> String {
chunks
.iter()
@@ -620,6 +665,7 @@ async fn tour_complet_par_le_go() {
compact_memory: CompactMemory::new(),
images: ImageCache::new(imgdir),
patterns: RetryPatterns::builtin(),
tpl: Arc::new(loki_rs::chat::tplprobe::TplProbe::new()),
});
let cfg = std::collections::HashMap::new();
let host = GoTurnHost {
@@ -872,6 +918,154 @@ async fn tour_de_bout_en_bout() {
assert!(again.dup);
}
/// Lot 23 : après un tour, la façade sonde le gabarit du moteur avec la
/// forme de la requête (mêmes outils), et range le verdict que le Go relit
/// pour son résumé des performances.
#[tokio::test]
async fn sonde_de_gabarit_apres_un_tour() {
let (url, _seen) = fake_engine(tool_then_answer("fini")).await;
let (svc, _go, _stop, _t) = service(Some(&url)).await;
svc.send(serde_json::json!({"message": "liste", "cid": "s1"}))
.await
.unwrap();
until_done(&svc, 1).await;
until(|| svc.shared.tpl.caps_current(false).is_some()).await;
let r = svc.shared.tpl.caps_current(false).unwrap();
assert_eq!(r.tools, 1);
assert_eq!(r.build, "b1-x");
assert_eq!(r.model, "m1.gguf");
assert_eq!(r.note, "reasoning_content jamais rendu");
assert_eq!(
(r.preserves_history, r.prefix_stable, r.renders_reasoning),
(TplTri::No, TplTri::Yes, TplTri::No)
);
let renders = PROBE_RENDERS.lock().unwrap().clone();
assert!(
renders
.iter()
.any(|b| b["tools"][0]["function"]["name"] == "bash"
&& b["parallel_tool_calls"] == false),
"{renders:?}"
);
// Un preset externe : rien d'affiché.
assert!(svc.shared.tpl.caps_current(true).is_none());
}
/// Le verdict de la sonde décide pour l'hôte : rappel dans le résultat
/// d'outil seulement si le préfixe est instable pour CE modèle ; sonde
/// lancée pour le fil principal, jamais pour une tâche.
#[tokio::test]
async fn hote_et_sonde_de_gabarit() {
let (svc, _go, _stop, _t) = service(None).await;
let shared = svc.shared.clone();
let host = |main: &str, model: &str| {
let cfg = std::collections::HashMap::from([("MODEL".to_string(), model.to_string())]);
let ep = ChatEndpoint {
url: "http://127.0.0.1:1/v1/chat/completions".into(),
model: "loki".into(),
key: String::new(),
external: false,
};
GoTurnHost {
shared: shared.clone(),
conv_id: "c1".into(),
caps: serde_json::json!({}),
cfg: cfg.clone(),
port: 1,
summarizer: EngineSummarizer {
http: shared.http.clone(),
ep,
cfg,
patterns: shared.patterns.clone(),
port: 1,
},
compact: TurnCompact {
cfg: CompactConfig::default(),
caps: CompactCaps::default(),
criteria: String::new(),
main_conv: main.into(),
tools: vec![],
effort: String::new(),
kwargs: Default::default(),
},
queued: Box::new(Vec::new),
}
};
let bash = Tool {
kind: "function".into(),
function: ToolFunction {
name: "bash".into(),
description: String::new(),
parameters: serde_json::json!({"type":"object"}),
},
};
let tools = vec![bash];
let shape = Shape::new(&tools, None, "");
let mut r = ProbeResult::unknown("");
r.model = "m1.gguf".into();
r.prefix_stable = TplTri::No;
shared.tpl.remember(&shape, r.clone());
use loki_rs::chat::turn::TurnHost;
assert!(host("c1", "/m/m1.gguf").prefix_unstable(&tools, None, ""));
assert!(!host("c1", "/m/autre.gguf").prefix_unstable(&tools, None, ""));
assert!(!host("c1", "/m/m1.gguf").prefix_unstable(&[], None, ""));
r.prefix_stable = TplTri::Yes;
shared.tpl.remember(&shape, r.clone());
assert!(!host("c1", "/m/m1.gguf").prefix_unstable(&tools, None, ""));
r.prefix_stable = TplTri::No;
r.model = String::new();
shared.tpl.remember(&shape, r);
assert!(!host("c1", "").prefix_unstable(&tools, None, ""));
// Une tâche (pas de discussion principale) ne lance pas la sonde.
host("", "").completed(&tools, None, "");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(shared.tpl.last_shape(), Shape::default());
host("c1", "").completed(&tools, None, "");
until(|| shared.tpl.last_shape() == shape).await;
}
/// REASONING_ECHO : un gabarit qui ne rend jamais le raisonnement (verdict de
/// la sonde, après le premier tour) coupe l'écho au tour suivant.
#[tokio::test]
async fn echo_coupe_par_le_verdict_de_la_sonde() {
let reasoned = sse(&[
serde_json::json!({"choices":[{"delta":{"reasoning_content": "je réfléchis"}}]}),
serde_json::json!({"choices":[{"delta":{"content": "un"}}]}),
serde_json::json!({"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":40,"completion_tokens":2}}),
]);
let (url, seen) = fake_engine(vec![reasoned.clone(), reasoned]).await;
let (svc, go, _stop, _t) = service(Some(&url)).await;
go.set("config", "REASONING_ECHO", "on");
go.set("config", "MODEL", "/m/m1.gguf");
svc.send(serde_json::json!({"message": "q1", "cid": "e1"}))
.await
.unwrap();
until_done(&svc, 1).await;
until(|| svc.shared.tpl.caps_current(false).is_some()).await;
assert_eq!(
svc.shared
.tpl
.caps_current(false)
.unwrap()
.renders_reasoning,
TplTri::No
);
svc.send(serde_json::json!({"message": "q2", "cid": "e2"}))
.await
.unwrap();
until_done(&svc, 2).await;
let seen = seen.lock().unwrap().clone();
let second = &seen.last().unwrap().1;
let past = second["messages"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "assistant")
.unwrap();
assert!(past.get("reasoning_content").is_none(), "{past}");
}
/// Contenu seul, fin normale, avec l'usage (une mesure par complétion).
fn answer(text: &str) -> String {
sse(&[
File diff suppressed because it is too large. Load diff
+264
View File
@@ -0,0 +1,264 @@
//! Lot 23 : sonde de gabarit portée en Rust (`chat/tplprobe.rs`).
//!
//! Le journal tiré du Go (`internal/loki/parity_tplprobe_test.go` →
//! `tests/parity_tplprobe/tplprobe.json`) est rejoué échange par échange : un
//! faux moteur rend les réponses enregistrées dans l'ordre et vérifie que le
//! port envoie, octet pour octet, les mêmes requêtes (méthode, chemin, clé,
//! corps). Puis verdicts, lignes de journal, derniers verdicts (courant et par
//! forme), cache, relevés du rendu, comparaisons et empreintes des formes.
use std::collections::VecDeque;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::chat::tplprobe::{self as tp, Env, Prober, Shape, TplProbe};
use loki_rs::chat::types::Tool;
use serde_json::{Map, Value};
use tokio::net::TcpListener;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_tplprobe/tplprobe.json")).expect("journal")
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
/// Le faux moteur : les échanges attendus, et les écarts constatés.
#[derive(Default)]
struct Script {
expected: VecDeque<Value>,
errors: Vec<String>,
}
async fn fake_engine(script: Arc<Mutex<Script>>) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = l.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let script = script.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let script = script.clone();
async move {
let method = req.method().to_string();
let path = req.uri().path().to_string();
let auth = req
.headers()
.get("authorization")
.map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned())
.unwrap_or_default();
let body = req.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8_lossy(&body).into_owned();
let mut sc = script.lock().unwrap();
let (code, reply) = match sc.expected.pop_front() {
None => {
sc.errors
.push(format!("requête en trop : {method} {path} {body}"));
(599, String::new())
}
Some(want) => {
let got =
(method.as_str(), path.as_str(), auth.as_str(), body.as_str());
let exp = (
s(&want["method"]),
s(&want["path"]),
s(&want["auth"]),
s(&want["body"]),
);
if got != exp {
sc.errors.push(format!("attendu {exp:?}\n reçu {got:?}"));
}
(
want["status"].as_u64().unwrap() as u16,
s(&want["reply"]).to_string(),
)
}
};
let mut r = Response::new(Full::new(Bytes::from(reply)));
*r.status_mut() = hyper::StatusCode::from_u16(code).unwrap();
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
port
}
fn shape(fx: &Value, name: &str) -> Shape {
let sh = &fx["shapes"][name];
let tools: Vec<Tool> = if s(&sh["tools"]).is_empty() {
Vec::new()
} else {
serde_json::from_str(s(&sh["tools"])).unwrap()
};
let kwargs: Option<Map<String, Value>> = sh["kwargs"].as_object().cloned();
Shape::new(&tools, kwargs.as_ref(), s(&sh["effort"]))
}
/// Le verdict comme le journal le range : sans l'instant, avec `cacheable`.
fn result_json(r: &tp::ProbeResult) -> Value {
let mut v = r.to_json();
let m = v.as_object_mut().unwrap();
m.remove("at");
m.insert("cacheable".into(), Value::Bool(r.cacheable));
v
}
fn opt_json(r: Option<tp::ProbeResult>) -> Value {
r.map(|r| result_json(&r)).unwrap_or(Value::Null)
}
fn take(script: &Arc<Mutex<Script>>, ctx: &str) {
let mut sc = script.lock().unwrap();
assert!(sc.errors.is_empty(), "{ctx} :\n{}", sc.errors.join("\n"));
assert!(
sc.expected.is_empty(),
"{ctx} : échanges non faits {:?}",
sc.expected
);
sc.errors.clear();
}
#[tokio::test]
async fn parite_des_verdicts() {
let fx = fixture();
for sc in fx["scenarios"].as_array().unwrap() {
let name = s(&sc["name"]);
let script = Arc::new(Mutex::new(Script::default()));
let port = fake_engine(script.clone()).await;
let mut probe = TplProbe::new();
probe.auto = false;
probe.backoff = Duration::from_millis(1);
// Preset externe : décidé sur la configuration, comme le Go.
let cfg: std::collections::HashMap<String, String> = sc["config"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.clone(), s(v).to_string()))
.collect();
let env = Env {
external: loki_rs::engstatus::is_external(&cfg),
port: port.into(),
key: s(&sc["api_key"]).to_string(),
};
assert_eq!(
env.external,
sc["external"].as_bool().unwrap_or(false),
"{name}"
);
for (i, st) in sc["steps"].as_array().unwrap().iter().enumerate() {
let ctx = format!("{name} #{i}");
script.lock().unwrap().expected = st["exchanges"]
.as_array()
.into_iter()
.flatten()
.cloned()
.collect();
let sh = shape(&fx, s(&st["shape"]));
let r = probe.ensure(&env, &sh).await;
take(&script, &ctx);
assert_eq!(result_json(&r), st["result"], "{ctx} : verdict");
assert_eq!(tp::probe_line(&r), s(&st["line"]), "{ctx} : journal");
assert_eq!(
opt_json(probe.caps_current(env.external)),
st["current"],
"{ctx} : courant"
);
assert_eq!(
opt_json(probe.caps_for(env.external, &sh)),
st["for"],
"{ctx} : forme"
);
// Un preset externe n'affiche jamais le verdict du moteur local.
assert!(probe.caps_current(true).is_none(), "{ctx}");
assert!(probe.caps_for(true, &sh).is_none(), "{ctx}");
}
}
}
#[tokio::test]
async fn parite_des_releves() {
let fx = fixture();
let script = Arc::new(Mutex::new(Script::default()));
let port = fake_engine(script.clone()).await;
let p = Prober::local(port.into(), "", Duration::from_millis(1));
for (i, pc) in fx["prints"].as_array().unwrap().iter().enumerate() {
script.lock().unwrap().expected = pc["exchanges"]
.as_array()
.into_iter()
.flatten()
.cloned()
.collect();
let got = p.render_print(&shape(&fx, s(&pc["shape"]))).await;
take(&script, &format!("relevé #{i}"));
match got {
Ok(out) => {
assert_eq!(out, s(&pc["out"]), "relevé #{i}");
assert_eq!(s(&pc["err"]), "", "relevé #{i}");
}
Err(e) => {
assert_eq!(e, s(&pc["err"]), "relevé #{i}");
assert_eq!(s(&pc["out"]), "", "relevé #{i}");
}
}
}
for d in fx["diffs"].as_array().unwrap() {
let (same, detail) = tp::render_diff(s(&d["before"]), s(&d["after"]));
assert_eq!(same, d["same"].as_bool().unwrap(), "{d}");
assert_eq!(detail, s(&d["detail"]), "{d}");
}
for (name, h) in fx["hashes"].as_object().unwrap() {
assert_eq!(shape(&fx, name).hash(), s(h), "empreinte {name}");
}
}
/// La sonde automatique : une à la fois, au plus une par intervalle, et
/// jamais quand elle est coupée.
#[tokio::test]
async fn declenchement_borne() {
let script = Arc::new(Mutex::new(Script::default()));
let port = fake_engine(script.clone()).await;
let env = Env {
external: false,
port: port.into(),
key: String::new(),
};
let health =
|| serde_json::json!({"method": "GET", "path": "/health", "status": 503, "reply": ""});
let mut off = TplProbe::new();
off.auto = false;
let off = Arc::new(off);
off.kick(env.clone(), Shape::default());
tokio::time::sleep(Duration::from_millis(100)).await;
take(&script, "coupée");
let mut p = TplProbe::new();
p.every = Duration::from_millis(300);
let p = Arc::new(p);
script.lock().unwrap().expected = [health()].into();
p.kick(env.clone(), Shape::default());
p.kick(env.clone(), Shape::default()); // encore en vol : rien
tokio::time::sleep(Duration::from_millis(100)).await;
take(&script, "première");
p.kick(env.clone(), Shape::default()); // finie, mais trop tôt : rien
tokio::time::sleep(Duration::from_millis(50)).await;
take(&script, "trop tôt");
tokio::time::sleep(Duration::from_millis(300)).await;
script.lock().unwrap().expected = [health()].into();
p.kick(env.clone(), Shape::default());
tokio::time::sleep(Duration::from_millis(100)).await;
take(&script, "après l'intervalle");
// La forme sondée en dernier est retenue pour le relevé du rendu.
assert_eq!(p.last_shape(), Shape::default());
}