mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
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:
17 files changed
+5006
-22
No files matched your search
@@ -494,7 +494,12 @@ impl ChatService {
|
||||
let cfg = store.config().await;
|
||||
let local_key = store.get_str(BK_STATE, "api_key").await;
|
||||
let ep = resolve_chat_endpoint(&cfg, &local_key);
|
||||
let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, None);
|
||||
let verdict = self
|
||||
.shared
|
||||
.tpl
|
||||
.caps_current(ep.external)
|
||||
.map(|r| r.verdict());
|
||||
let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, verdict.as_ref());
|
||||
let ccfg = CompactConfig::from_config(&cfg, echo.clone());
|
||||
let port = llm_port(&cfg);
|
||||
let summarizer = EngineSummarizer {
|
||||
@@ -1062,6 +1067,7 @@ impl ChatShared {
|
||||
compact_memory: super::compact::CompactMemory::new(),
|
||||
images: super::host::ImageCache::new(img_dir),
|
||||
patterns: super::stream::RetryPatterns::builtin(),
|
||||
tpl: std::sync::Arc::new(super::tplprobe::TplProbe::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ use super::wire::{
|
||||
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME,
|
||||
build_chat_payload, content_parts, part_image_url, wire_messages,
|
||||
};
|
||||
use crate::store::Store;
|
||||
use crate::store::{BK_STATE, Store};
|
||||
|
||||
/// Taille au-delà de laquelle le cache d'images oublie les plus anciennes
|
||||
/// (`imgCacheMax`).
|
||||
@@ -284,6 +284,8 @@ pub struct ChatShared {
|
||||
pub compact_memory: CompactMemory,
|
||||
pub images: ImageCache,
|
||||
pub patterns: Arc<RetryPatterns>,
|
||||
/// Sonde de gabarit (`chat_tplprobe.go`) : derniers verdicts et cache.
|
||||
pub tpl: Arc<super::tplprobe::TplProbe>,
|
||||
}
|
||||
|
||||
/// Ce que la compaction sait du tour.
|
||||
@@ -490,6 +492,39 @@ impl TurnHost for GoTurnHost {
|
||||
(self.queued)()
|
||||
}
|
||||
|
||||
fn prefix_unstable(
|
||||
&self,
|
||||
tools: &[Tool],
|
||||
kwargs: Option<&Map<String, Value>>,
|
||||
effort: &str,
|
||||
) -> bool {
|
||||
let shape = super::tplprobe::Shape::new(tools, kwargs, effort);
|
||||
self.shared.tpl.caps_for(false, &shape).is_some_and(|r| {
|
||||
r.prefix_stable == super::wire::TplTri::No
|
||||
&& !r.model.is_empty()
|
||||
&& r.model == super::wire::reasoning_echo_model(&self.cfg)
|
||||
})
|
||||
}
|
||||
|
||||
/// Fil principal seulement (`perfMain`) : une tâche ou un sous-agent ne
|
||||
/// déclenche pas la sonde.
|
||||
fn completed(&self, tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) {
|
||||
if self.compact.main_conv.is_empty() {
|
||||
return;
|
||||
}
|
||||
let shape = super::tplprobe::Shape::new(tools, kwargs, effort);
|
||||
let (shared, port) = (self.shared.clone(), self.port);
|
||||
tokio::spawn(async move {
|
||||
let key = shared.store.get_str(BK_STATE, "api_key").await;
|
||||
let env = super::tplprobe::Env {
|
||||
external: false,
|
||||
port: port.into(),
|
||||
key,
|
||||
};
|
||||
shared.tpl.kick(env, shape);
|
||||
});
|
||||
}
|
||||
|
||||
async fn prepare_images(&self, msgs: &[Message]) {
|
||||
self.shared.images.prefetch(&self.shared.store, msgs).await;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
//! - [`turn`] : la boucle d'un tour (complétions, outils, filets) ;
|
||||
//! - [`host`] : le tour branché sur le moteur, les outils du Go et la base ;
|
||||
//! - [`history`] : début et fin d'un tour côté historique ;
|
||||
//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour).
|
||||
//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour) ;
|
||||
//! - [`tplprobe`] : sonde de gabarit (rendus de `/apply-template`).
|
||||
|
||||
pub mod compact;
|
||||
pub mod conv;
|
||||
@@ -31,6 +32,7 @@ pub mod nudges;
|
||||
pub mod persist;
|
||||
pub mod sessions;
|
||||
pub mod stream;
|
||||
pub mod tplprobe;
|
||||
pub mod turn;
|
||||
pub mod types;
|
||||
pub mod verify;
|
||||
|
||||
@@ -75,6 +75,45 @@ pub fn append_nudge(messages: &mut Vec<Message>, extra: &mut Vec<Message>, text:
|
||||
}
|
||||
}
|
||||
|
||||
/// La clé `NUDGE_IN_TOOL` est-elle posée (`nudgeInToolOn`, sans la sonde) ?
|
||||
pub fn nudge_in_tool_key(cfg: &std::collections::HashMap<String, String>) -> bool {
|
||||
matches!(
|
||||
cfg.get("NUDGE_IN_TOOL")
|
||||
.map(|v| v.trim().to_lowercase())
|
||||
.as_deref(),
|
||||
Some("on" | "1" | "true" | "yes" | "oui")
|
||||
)
|
||||
}
|
||||
|
||||
/// `nudgeIntoTool` : le rappel au bout du dernier résultat d'outil, dans la
|
||||
/// vue du modèle ET dans l'historique, à l'identique. `false` : dernier
|
||||
/// message d'une autre forme, ou historique réécrit entre-temps — l'appelant
|
||||
/// passe alors par [`append_nudge`].
|
||||
pub fn nudge_into_tool(messages: &mut [Message], extra: &mut [Message], text: &str) -> bool {
|
||||
let (Some(last), Some(kept)) = (messages.last(), extra.last()) else {
|
||||
return false;
|
||||
};
|
||||
let (Some(Value::String(s)), Some(Value::String(ks))) = (&last.content, &kept.content) else {
|
||||
return false;
|
||||
};
|
||||
if s != ks
|
||||
|| last.role != "tool"
|
||||
|| kept.role != "tool"
|
||||
|| last.tool_call_id != kept.tool_call_id
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut m = last.clone();
|
||||
m.content = Some(Value::String(format!("{s}\n\n{text}")));
|
||||
if let Some(l) = extra.last_mut() {
|
||||
*l = m.clone();
|
||||
}
|
||||
if let Some(l) = messages.last_mut() {
|
||||
*l = m;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `steerSystem` : la consigne s'ajoute au système de tête (texte), ou
|
||||
/// devient un système en tête — jamais un système en fin de fil, que les
|
||||
/// gabarits stricts refusent.
|
||||
@@ -224,4 +263,54 @@ mod tests {
|
||||
(TOOL_PREVIEW_CHARS, TOOL_PREVIEW_CHARS + 5, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rappel_dans_le_resultat_d_outil() {
|
||||
let tm = |c: &str| Message {
|
||||
role: "tool".into(),
|
||||
content: Some(Value::String(c.into())),
|
||||
tool_call_id: "t1".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let um = |c: &str| Message {
|
||||
role: "user".into(),
|
||||
content: Some(Value::String(c.into())),
|
||||
..Default::default()
|
||||
};
|
||||
let mut msgs = vec![um("q"), tm("res")];
|
||||
let mut extra = vec![tm("res")];
|
||||
assert!(nudge_into_tool(&mut msgs, &mut extra, "[system] stop"));
|
||||
assert_eq!(
|
||||
msgs[1].content,
|
||||
Some(Value::String("res\n\n[system] stop".into()))
|
||||
);
|
||||
assert_eq!(msgs[1], extra[0]);
|
||||
// Dernier message d'une autre forme, ou historique réécrit.
|
||||
let mut m2 = vec![tm("res"), um("img")];
|
||||
assert!(!nudge_into_tool(&mut m2, &mut [tm("res")], "x"));
|
||||
assert!(!nudge_into_tool(&mut [tm("res")], &mut [], "x"));
|
||||
assert!(!nudge_into_tool(&mut [tm("res")], &mut [tm("autre")], "x"));
|
||||
let mut other_id = tm("res");
|
||||
other_id.tool_call_id = "t2".into();
|
||||
assert!(!nudge_into_tool(&mut [tm("res")], &mut [other_id], "x"));
|
||||
let mut empty = tm("");
|
||||
empty.content = None;
|
||||
assert!(!nudge_into_tool(&mut [empty.clone()], &mut [empty], "x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cle_nudge_in_tool() {
|
||||
for (v, on) in [
|
||||
("on", true),
|
||||
(" TRUE ", true),
|
||||
("oui", true),
|
||||
("off", false),
|
||||
("", false),
|
||||
] {
|
||||
let cfg =
|
||||
std::collections::HashMap::from([("NUDGE_IN_TOOL".to_string(), v.to_string())]);
|
||||
assert_eq!(nudge_in_tool_key(&cfg), on, "{v}");
|
||||
}
|
||||
assert!(!nudge_in_tool_key(&std::collections::HashMap::new()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
//! Sonde de gabarit (lot 23 de la migration) — port de `chat_tplprobe.go`.
|
||||
//!
|
||||
//! Que fait le gabarit de chat du modèle chargé de ce qu'on lui envoie ?
|
||||
//! - `preserves_history` : le raisonnement d'un tour PASSÉ est-il rendu ?
|
||||
//! - `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
|
||||
//! préfixe exact du suivant ?
|
||||
//! - `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
|
||||
//!
|
||||
//! Mêmes règles de conduite que le Go : moteur local seulement, rien d'autre
|
||||
//! que `POST /apply-template` (sans état, sans slot), lancée après une
|
||||
//! complétion terminée, avec les outils et `chat_template_kwargs` de cette
|
||||
//! complétion ; tout échec donne « unknown ».
|
||||
//!
|
||||
//! Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
|
||||
//! servent la vérification autour d'une mise à jour du moteur.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::value::RawValue;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::types::{Tool, ToolCall, ToolCallFunc};
|
||||
use super::wire::TplTri;
|
||||
use crate::gojson;
|
||||
|
||||
/// Délai de chaque requête (`tplProbeTimeout`).
|
||||
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Au plus une vérification par minute (`tplProbeEvery`).
|
||||
pub const PROBE_EVERY: Duration = Duration::from_secs(60);
|
||||
const CACHE_MAX: usize = 16;
|
||||
const TRIES_503: u32 = 3;
|
||||
const BODY_MAX: usize = 8 << 20;
|
||||
|
||||
const MARK_SYS: &str = "loki-sonde-systeme";
|
||||
const MARK_A: &str = "loki-sonde-question-a";
|
||||
const MARK_R1: &str = "loki-sonde-raisonnement-r1";
|
||||
const MARK_C1: &str = "loki-sonde-reponse-c1";
|
||||
const MARK_T1: &str = "loki-sonde-resultat-t1";
|
||||
const MARK_B: &str = "loki-sonde-question-b";
|
||||
const CALL_ID: &str = "a1B2c3D4e";
|
||||
|
||||
/// `tplRenderSep`.
|
||||
pub const RENDER_SEP: &str = "\n\x00--- ";
|
||||
|
||||
impl TplTri {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
TplTri::Yes => "yes",
|
||||
TplTri::No => "no",
|
||||
TplTri::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `tplProbeResult`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProbeResult {
|
||||
pub preserves_history: TplTri,
|
||||
pub prefix_stable: TplTri,
|
||||
pub renders_reasoning: TplTri,
|
||||
pub build: String,
|
||||
pub model: String,
|
||||
pub template_hash: String,
|
||||
pub tools: usize,
|
||||
pub kwargs: Option<Map<String, Value>>,
|
||||
pub effort: String,
|
||||
pub note: String,
|
||||
pub at: SystemTime,
|
||||
/// La réponse tient tant que moteur, modèle et gabarit restent les mêmes.
|
||||
pub cacheable: bool,
|
||||
}
|
||||
|
||||
impl ProbeResult {
|
||||
/// `tplUnknownResult`.
|
||||
pub fn unknown(note: &str) -> Self {
|
||||
ProbeResult {
|
||||
preserves_history: TplTri::Unknown,
|
||||
prefix_stable: TplTri::Unknown,
|
||||
renders_reasoning: TplTri::Unknown,
|
||||
build: String::new(),
|
||||
model: String::new(),
|
||||
template_hash: String::new(),
|
||||
tools: 0,
|
||||
kwargs: None,
|
||||
effort: String::new(),
|
||||
note: note.into(),
|
||||
at: SystemTime::now(),
|
||||
cacheable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Le JSON du Go (champs dans l'ordre de la struct, `omitempty` compris).
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut m = Map::new();
|
||||
m.insert(
|
||||
"preserves_history".into(),
|
||||
json!(self.preserves_history.as_str()),
|
||||
);
|
||||
m.insert("prefix_stable".into(), json!(self.prefix_stable.as_str()));
|
||||
m.insert(
|
||||
"renders_reasoning".into(),
|
||||
json!(self.renders_reasoning.as_str()),
|
||||
);
|
||||
for (k, v) in [
|
||||
("build", &self.build),
|
||||
("model", &self.model),
|
||||
("template_hash", &self.template_hash),
|
||||
] {
|
||||
if !v.is_empty() {
|
||||
m.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
m.insert("tools".into(), json!(self.tools));
|
||||
if let Some(k) = self.kwargs.as_ref().filter(|k| !k.is_empty()) {
|
||||
m.insert("chat_template_kwargs".into(), Value::Object(k.clone()));
|
||||
}
|
||||
if !self.effort.is_empty() {
|
||||
m.insert("reasoning_effort".into(), json!(self.effort));
|
||||
}
|
||||
if !self.note.is_empty() {
|
||||
m.insert("note".into(), json!(self.note));
|
||||
}
|
||||
m.insert("at".into(), json!(rfc3339(self.at)));
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// Le sous-ensemble que lit la politique d'écho.
|
||||
pub fn verdict(&self) -> super::wire::TplVerdict {
|
||||
super::wire::TplVerdict {
|
||||
model: self.model.clone(),
|
||||
renders_reasoning: self.renders_reasoning,
|
||||
preserves_history: self.preserves_history,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Instant au format RFC 3339 (UTC, nanosecondes), comme `time.Time`.
|
||||
pub fn rfc3339(t: SystemTime) -> String {
|
||||
let d = t.duration_since(UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = d.as_secs() as i64;
|
||||
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||||
// Jours depuis 1970 → date civile (algorithme de Howard Hinnant).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
let mut s = format!(
|
||||
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}",
|
||||
rem / 3600,
|
||||
rem % 3600 / 60,
|
||||
rem % 60
|
||||
);
|
||||
let ns = d.subsec_nanos();
|
||||
if ns > 0 {
|
||||
let frac = format!("{ns:09}");
|
||||
s.push('.');
|
||||
s.push_str(frac.trim_end_matches('0'));
|
||||
}
|
||||
s.push('Z');
|
||||
s
|
||||
}
|
||||
|
||||
/// `tplShape` : la forme de la requête à reproduire.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct Shape {
|
||||
/// Outils figés en JSON ; `None` = aucun outil envoyé.
|
||||
pub tools: Option<String>,
|
||||
pub n_tools: usize,
|
||||
pub first: String,
|
||||
pub kwargs: Option<Map<String, Value>>,
|
||||
pub effort: String,
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
/// `newTplShape`.
|
||||
pub fn new(tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) -> Self {
|
||||
let mut s = Shape {
|
||||
n_tools: tools.len(),
|
||||
effort: effort.into(),
|
||||
..Shape::default()
|
||||
};
|
||||
if !tools.is_empty() {
|
||||
s.tools = Some(gojson::marshal(tools));
|
||||
s.first = tools[0].function.name.clone();
|
||||
}
|
||||
s.kwargs = kwargs.cloned();
|
||||
s
|
||||
}
|
||||
|
||||
/// `tplShape.hash` : FNV-64a des outils, des kwargs et de l'effort.
|
||||
pub fn hash(&self) -> String {
|
||||
let mut h = Fnv::new();
|
||||
if let Some(t) = &self.tools {
|
||||
h.write(t.as_bytes());
|
||||
}
|
||||
h.write(&[0]);
|
||||
let kw = match &self.kwargs {
|
||||
Some(k) => gojson::marshal(&Value::Object(k.clone())),
|
||||
None => "null".into(),
|
||||
};
|
||||
h.write(kw.as_bytes());
|
||||
h.write(&[0]);
|
||||
h.write(self.effort.as_bytes());
|
||||
format!("{:016x}", h.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64 bits (`hash/fnv.New64a`).
|
||||
struct Fnv(u64);
|
||||
|
||||
impl Fnv {
|
||||
fn new() -> Self {
|
||||
Fnv(0xcbf2_9ce4_8422_2325)
|
||||
}
|
||||
fn write(&mut self, b: &[u8]) {
|
||||
for &c in b {
|
||||
self.0 ^= u64::from(c);
|
||||
self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fnv_hex(b: &[u8]) -> String {
|
||||
let mut h = Fnv::new();
|
||||
h.write(b);
|
||||
format!("{:016x}", h.0)
|
||||
}
|
||||
|
||||
/// `tplMsg`.
|
||||
#[derive(Serialize, Clone)]
|
||||
struct Msg {
|
||||
role: &'static str,
|
||||
content: &'static str,
|
||||
#[serde(skip_serializing_if = "str::is_empty")]
|
||||
reasoning_content: &'static str,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
tool_calls: Vec<ToolCall>,
|
||||
#[serde(skip_serializing_if = "str::is_empty")]
|
||||
tool_call_id: &'static str,
|
||||
}
|
||||
|
||||
fn msg(role: &'static str, content: &'static str) -> Msg {
|
||||
Msg {
|
||||
role,
|
||||
content,
|
||||
reasoning_content: "",
|
||||
tool_calls: Vec::new(),
|
||||
tool_call_id: "",
|
||||
}
|
||||
}
|
||||
|
||||
fn call(name: &str) -> ToolCall {
|
||||
ToolCall {
|
||||
index: None,
|
||||
id: CALL_ID.into(),
|
||||
kind: "function".into(),
|
||||
function: ToolCallFunc {
|
||||
name: name.into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse du moteur : non-200 (`tplHTTPError`) ou autre échec.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ProbeError {
|
||||
Http { status: u16, body: String },
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProbeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ProbeError::Http { status, body } if !body.is_empty() => {
|
||||
write!(f, "HTTP {status} : {body}")
|
||||
}
|
||||
ProbeError::Http { status, .. } => write!(f, "HTTP {status}"),
|
||||
ProbeError::Other(e) => f.write_str(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProbeError {
|
||||
/// `tplDefinitive` : l'échec se reproduira à l'identique.
|
||||
fn definitive(&self) -> bool {
|
||||
match self {
|
||||
ProbeError::Http { status, .. } => {
|
||||
!matches!(status, 401 | 403 | 503 | 429) && (400..600).contains(status)
|
||||
}
|
||||
ProbeError::Other(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `/props` : ce que la sonde en retient.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct Props {
|
||||
pub build_info: String,
|
||||
pub model_path: String,
|
||||
pub chat_template: String,
|
||||
}
|
||||
|
||||
/// `tplProber` : le moteur local.
|
||||
#[derive(Clone)]
|
||||
pub struct Prober {
|
||||
pub base: String,
|
||||
/// Clé Bearer du moteur ("" = aucune).
|
||||
pub key: String,
|
||||
pub client: reqwest::Client,
|
||||
/// Attente entre deux essais sur un 503 (`tplProbeBackoff`).
|
||||
pub backoff: Duration,
|
||||
}
|
||||
|
||||
impl Prober {
|
||||
/// `tplLocalProber`.
|
||||
pub fn local(port: i64, key: &str, backoff: Duration) -> Self {
|
||||
crate::chat::engine::install_crypto();
|
||||
Prober {
|
||||
base: format!("http://localhost:{port}"),
|
||||
key: key.into(),
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(PROBE_TIMEOUT)
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
backoff,
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Result<Vec<u8>, ProbeError> {
|
||||
let mut rq = self
|
||||
.client
|
||||
.request(method, format!("{}{path}", self.base))
|
||||
.timeout(PROBE_TIMEOUT);
|
||||
if let Some(b) = body {
|
||||
rq = rq.header("Content-Type", "application/json").body(b);
|
||||
}
|
||||
if !self.key.is_empty() {
|
||||
rq = rq.header("Authorization", format!("Bearer {}", self.key));
|
||||
}
|
||||
let mut resp = rq
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ProbeError::Other(e.to_string()))?;
|
||||
let status = resp.status().as_u16();
|
||||
let mut data = Vec::new();
|
||||
while data.len() < BODY_MAX {
|
||||
match resp.chunk().await {
|
||||
Ok(Some(c)) => data.extend_from_slice(&c),
|
||||
Ok(None) => break,
|
||||
Err(e) => return Err(ProbeError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
data.truncate(BODY_MAX);
|
||||
if status != 200 {
|
||||
let msg = String::from_utf8_lossy(&data).trim().to_string();
|
||||
let msg = if msg.chars().count() > 160 {
|
||||
format!("{}…", msg.chars().take(160).collect::<String>())
|
||||
} else {
|
||||
msg
|
||||
};
|
||||
return Err(ProbeError::Http { status, body: msg });
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// `/health` à 200 ?
|
||||
pub async fn healthy(&self) -> bool {
|
||||
self.call(reqwest::Method::GET, "/health", None)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// `/props`.
|
||||
pub async fn props(&self) -> Result<Props, ProbeError> {
|
||||
let data = self.call(reqwest::Method::GET, "/props", None).await?;
|
||||
let bad = || ProbeError::Other("réponse non JSON".into());
|
||||
let v: Value = serde_json::from_slice(&data).map_err(|_| bad())?;
|
||||
let mut p = Props::default();
|
||||
match v {
|
||||
Value::Null => return Ok(p),
|
||||
Value::Object(m) => {
|
||||
for (k, dst) in [
|
||||
("build_info", &mut p.build_info),
|
||||
("model_path", &mut p.model_path),
|
||||
("chat_template", &mut p.chat_template),
|
||||
] {
|
||||
match m.get(k) {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(s)) => *dst = s.clone(),
|
||||
Some(_) => return Err(bad()),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(bad()),
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// `render` : POST /apply-template, relancé sur 503.
|
||||
async fn render(
|
||||
&self,
|
||||
shape: &Shape,
|
||||
msgs: &[Msg],
|
||||
gen_prompt: bool,
|
||||
) -> Result<String, ProbeError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
add_generation_prompt: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chat_template_kwargs: Option<&'a Map<String, Value>>,
|
||||
messages: &'a [Msg],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parallel_tool_calls: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_effort: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<&'a RawValue>,
|
||||
}
|
||||
let raw_tools = shape
|
||||
.tools
|
||||
.as_deref()
|
||||
.and_then(|t| serde_json::from_str::<&RawValue>(t).ok());
|
||||
let raw = gojson::marshal(&Body {
|
||||
add_generation_prompt: gen_prompt,
|
||||
chat_template_kwargs: shape.kwargs.as_ref(),
|
||||
messages: msgs,
|
||||
parallel_tool_calls: raw_tools.map(|_| false),
|
||||
reasoning_effort: (!shape.effort.is_empty()).then_some(shape.effort.as_str()),
|
||||
tools: raw_tools,
|
||||
});
|
||||
let mut tries = 1;
|
||||
loop {
|
||||
match self
|
||||
.call(
|
||||
reqwest::Method::POST,
|
||||
"/apply-template",
|
||||
Some(raw.clone().into_bytes()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(ProbeError::Http { status: 503, .. }) if tries < TRIES_503 => {
|
||||
tokio::time::sleep(self.backoff * tries).await;
|
||||
tries += 1;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
Ok(data) => {
|
||||
let prompt = serde_json::from_slice::<Value>(&data)
|
||||
.ok()
|
||||
.and_then(|v| v.get("prompt").cloned());
|
||||
return match prompt {
|
||||
Some(Value::String(p)) => Ok(p),
|
||||
_ => Err(ProbeError::Http {
|
||||
status: 200,
|
||||
body: "réponse sans prompt".into(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `probe` : les rendus et leurs verdicts.
|
||||
pub async fn probe(&self, shape: &Shape) -> ProbeResult {
|
||||
let mut r = ProbeResult::unknown("");
|
||||
let mut definitive = true;
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
let mut fail = |what: &str, e: &ProbeError, notes: &mut Vec<String>| {
|
||||
if !e.definitive() {
|
||||
definitive = false;
|
||||
}
|
||||
notes.push(format!("{what} : {e}"));
|
||||
};
|
||||
let sys = msg("system", MARK_SYS);
|
||||
let user_a = msg("user", MARK_A);
|
||||
let user_b = msg("user", MARK_B);
|
||||
let mut past = msg("assistant", MARK_C1);
|
||||
past.reasoning_content = MARK_R1;
|
||||
|
||||
// 1) Raisonnement d'un tour passé.
|
||||
match self
|
||||
.render(
|
||||
shape,
|
||||
&[sys.clone(), user_a.clone(), past, user_b.clone()],
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(e) => fail("historique", &e, &mut notes),
|
||||
Ok(h) if !h.contains(MARK_C1) || !h.contains(MARK_B) => {
|
||||
notes.push("historique : rendu incohérent".into());
|
||||
}
|
||||
Ok(h) if h.contains(MARK_R1) => r.preserves_history = TplTri::Yes,
|
||||
Ok(_) => r.preserves_history = TplTri::No,
|
||||
}
|
||||
|
||||
// 2) Stabilité du préfixe à travers un tour d'outil.
|
||||
let name = if shape.first.is_empty() {
|
||||
"lookup"
|
||||
} else {
|
||||
&shape.first
|
||||
};
|
||||
let mut c = msg("assistant", MARK_C1);
|
||||
c.reasoning_content = MARK_R1;
|
||||
c.tool_calls = vec![call(name)];
|
||||
let mut result = msg("tool", MARK_T1);
|
||||
result.tool_call_id = CALL_ID;
|
||||
let before = vec![sys, user_a, c, result];
|
||||
let mut after = before.clone();
|
||||
after.push(user_b);
|
||||
|
||||
let r1 = self.render(shape, &before, false).await;
|
||||
let mut r1g: Result<String, ProbeError> = Err(ProbeError::Other(String::new()));
|
||||
let mut r2: Result<String, ProbeError> = Err(ProbeError::Other(String::new()));
|
||||
if r1.is_ok() {
|
||||
r1g = self.render(shape, &before, true).await;
|
||||
}
|
||||
if r1.is_ok() && r1g.is_ok() {
|
||||
r2 = self.render(shape, &after, false).await;
|
||||
}
|
||||
match (&r1, &r1g, &r2) {
|
||||
(Err(e), _, _) | (Ok(_), Err(e), _) | (Ok(_), Ok(_), Err(e)) => {
|
||||
fail("préfixe", e, &mut notes)
|
||||
}
|
||||
(Ok(a), Ok(g), _) if a == g => {
|
||||
notes.push("add_generation_prompt ignoré par le moteur".into())
|
||||
}
|
||||
(Ok(a), Ok(_), Ok(b)) if !a.contains(MARK_T1) || !b.contains(MARK_B) => {
|
||||
notes.push("préfixe : rendu incohérent".into())
|
||||
}
|
||||
(Ok(a), Ok(_), Ok(b)) if b.starts_with(a.as_str()) => r.prefix_stable = TplTri::Yes,
|
||||
_ => r.prefix_stable = TplTri::No,
|
||||
}
|
||||
let r1_has = |m: &str| r1.as_ref().is_ok_and(|s| s.contains(m));
|
||||
if r.preserves_history == TplTri::Yes || r1_has(MARK_R1) {
|
||||
r.renders_reasoning = TplTri::Yes;
|
||||
} else if r1_has(MARK_T1) && r.preserves_history == TplTri::No {
|
||||
r.renders_reasoning = TplTri::No;
|
||||
notes.push("reasoning_content jamais rendu".into());
|
||||
}
|
||||
r.note = notes.join(" ; ");
|
||||
r.cacheable = definitive;
|
||||
r
|
||||
}
|
||||
|
||||
/// `tplRenderPrint` : les rendus concaténés de trois conversations
|
||||
/// synthétiques sur la forme donnée.
|
||||
pub async fn render_print(&self, shape: &Shape) -> Result<String, String> {
|
||||
let sys = msg("system", MARK_SYS);
|
||||
let user_a = msg("user", MARK_A);
|
||||
let user_b = msg("user", MARK_B);
|
||||
let name = if shape.first.is_empty() {
|
||||
"lookup"
|
||||
} else {
|
||||
&shape.first
|
||||
};
|
||||
let mut with_r = msg("assistant", MARK_C1);
|
||||
with_r.reasoning_content = MARK_R1;
|
||||
let mut c = msg("assistant", MARK_C1);
|
||||
c.tool_calls = vec![call(name)];
|
||||
let mut result = msg("tool", MARK_T1);
|
||||
result.tool_call_id = CALL_ID;
|
||||
let convs: [(&str, Vec<Msg>); 3] = [
|
||||
(
|
||||
"historique sans raisonnement",
|
||||
vec![
|
||||
sys.clone(),
|
||||
user_a.clone(),
|
||||
msg("assistant", MARK_C1),
|
||||
user_b.clone(),
|
||||
],
|
||||
),
|
||||
(
|
||||
"historique avec raisonnement",
|
||||
vec![sys.clone(), user_a.clone(), with_r, user_b.clone()],
|
||||
),
|
||||
("tour d'outil", vec![sys, user_a, c, result, user_b]),
|
||||
];
|
||||
let mut b = String::new();
|
||||
for (name, msgs) in convs {
|
||||
let out = self
|
||||
.render(shape, &msgs, true)
|
||||
.await
|
||||
.map_err(|e| format!("{name} : {e}"))?;
|
||||
b.push_str(&format!("{RENDER_SEP}{name} ---\n"));
|
||||
b.push_str(&out);
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
}
|
||||
|
||||
/// `tplRenderDiff` : identiques → (true, "") ; sinon la conversation où les
|
||||
/// relevés divergent et un extrait de part et d'autre du premier écart.
|
||||
pub fn render_diff(before: &str, after: &str) -> (bool, String) {
|
||||
if before == after {
|
||||
return (true, String::new());
|
||||
}
|
||||
let (bb, ab) = (before.as_bytes(), after.as_bytes());
|
||||
let mut i = 0;
|
||||
while i < bb.len() && i < ab.len() && bb[i] == ab[i] {
|
||||
i += 1;
|
||||
}
|
||||
let mut section = String::new();
|
||||
if let Some(k) = find_last(&bb[..i], RENDER_SEP.as_bytes()) {
|
||||
let rest = &bb[k + RENDER_SEP.len()..];
|
||||
if let Some(e) = find(rest, b" ---") {
|
||||
section = String::from_utf8_lossy(&rest[..e]).into_owned();
|
||||
}
|
||||
}
|
||||
let excerpt = |s: &[u8]| -> String {
|
||||
let mut from = i.saturating_sub(40);
|
||||
let mut to = (i + 60).min(s.len());
|
||||
while from > 0 && !rune_start(s[from]) {
|
||||
from -= 1;
|
||||
}
|
||||
while to < s.len() && !rune_start(s[to]) {
|
||||
to += 1;
|
||||
}
|
||||
let mut x = String::from_utf8_lossy(&s[from..to])
|
||||
.replace('\n', "⏎")
|
||||
.replace('\x00', "");
|
||||
if from > 0 {
|
||||
x = format!("…{x}");
|
||||
}
|
||||
if to < s.len() {
|
||||
x.push('…');
|
||||
}
|
||||
x
|
||||
};
|
||||
let mut d = format!("avant « {} » / après « {} »", excerpt(bb), excerpt(ab));
|
||||
if !section.is_empty() {
|
||||
d = format!("{section} : {d}");
|
||||
}
|
||||
(false, d)
|
||||
}
|
||||
|
||||
fn rune_start(b: u8) -> bool {
|
||||
b & 0xC0 != 0x80
|
||||
}
|
||||
|
||||
fn find(h: &[u8], n: &[u8]) -> Option<usize> {
|
||||
h.windows(n.len()).position(|w| w == n)
|
||||
}
|
||||
|
||||
fn find_last(h: &[u8], n: &[u8]) -> Option<usize> {
|
||||
if h.len() < n.len() {
|
||||
return None;
|
||||
}
|
||||
(0..=h.len() - n.len())
|
||||
.rev()
|
||||
.find(|&i| &h[i..i + n.len()] == n)
|
||||
}
|
||||
|
||||
/// `tplProbeLine`.
|
||||
pub fn probe_line(r: &ProbeResult) -> String {
|
||||
let dash = |s: &str| {
|
||||
if s.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
let mut s = format!(
|
||||
"[tplprobe] build={} modèle={} gabarit={} outils={} historique_raisonnement={} préfixe_stable={} raisonnement_rendu={}",
|
||||
dash(&r.build),
|
||||
dash(&r.model),
|
||||
dash(&r.template_hash),
|
||||
r.tools,
|
||||
r.preserves_history.as_str(),
|
||||
r.prefix_stable.as_str(),
|
||||
r.renders_reasoning.as_str()
|
||||
);
|
||||
if !r.note.is_empty() {
|
||||
s.push_str(&format!(" ({})", r.note));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Où sonder : le moteur local, sa clé, et si un preset externe est actif.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Env {
|
||||
pub external: bool,
|
||||
pub port: i64,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
cache: HashMap<String, ProbeResult>,
|
||||
last: Option<ProbeResult>,
|
||||
by_shape: HashMap<String, ProbeResult>,
|
||||
running: bool,
|
||||
tried: Option<Instant>,
|
||||
logged: String,
|
||||
shape_last: Option<Shape>,
|
||||
}
|
||||
|
||||
/// La sonde : son cache et ses derniers verdicts (l'état global du Go).
|
||||
pub struct TplProbe {
|
||||
state: Mutex<State>,
|
||||
/// Déclenchement automatique après les complétions (`tplProbeAuto`).
|
||||
pub auto: bool,
|
||||
/// Attente entre deux essais sur un 503.
|
||||
pub backoff: Duration,
|
||||
/// Intervalle minimal entre deux vérifications automatiques.
|
||||
pub every: Duration,
|
||||
}
|
||||
|
||||
impl Default for TplProbe {
|
||||
fn default() -> Self {
|
||||
TplProbe::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Le plus ancien verdict d'une table (pour lui faire de la place).
|
||||
fn oldest(m: &HashMap<String, ProbeResult>) -> Option<String> {
|
||||
m.iter().min_by_key(|(_, v)| v.at).map(|(k, _)| k.clone())
|
||||
}
|
||||
|
||||
impl TplProbe {
|
||||
pub fn new() -> Self {
|
||||
TplProbe {
|
||||
state: Mutex::new(State::default()),
|
||||
auto: true,
|
||||
backoff: Duration::from_secs(1),
|
||||
every: PROBE_EVERY,
|
||||
}
|
||||
}
|
||||
|
||||
fn st(&self) -> std::sync::MutexGuard<'_, State> {
|
||||
self.state.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// `tplLastShape` : la forme de la dernière requête sondée, ou une
|
||||
/// requête nue.
|
||||
pub fn last_shape(&self) -> Shape {
|
||||
self.st().shape_last.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `tplCapsCurrent` : le dernier verdict, rien pour un preset externe.
|
||||
pub fn caps_current(&self, external: bool) -> Option<ProbeResult> {
|
||||
if external {
|
||||
return None;
|
||||
}
|
||||
self.st().last.clone()
|
||||
}
|
||||
|
||||
/// `tplCapsFor` : le dernier verdict pour CETTE forme.
|
||||
pub fn caps_for(&self, external: bool, shape: &Shape) -> Option<ProbeResult> {
|
||||
if external {
|
||||
return None;
|
||||
}
|
||||
self.st().by_shape.get(&shape.hash()).cloned()
|
||||
}
|
||||
|
||||
/// Pose le verdict d'une forme comme si la sonde venait de conclure
|
||||
/// (tests, et reprise d'un verdict connu).
|
||||
pub fn remember(&self, shape: &Shape, r: ProbeResult) {
|
||||
let mut st = self.st();
|
||||
st.last = Some(r.clone());
|
||||
Self::keep_shape(&mut st, &shape.hash(), &r);
|
||||
}
|
||||
|
||||
/// `tplProbeKeepShape` (verrou tenu).
|
||||
fn keep_shape(st: &mut State, shape: &str, r: &ProbeResult) {
|
||||
if !st.by_shape.contains_key(shape)
|
||||
&& st.by_shape.len() >= CACHE_MAX
|
||||
&& let Some(k) = oldest(&st.by_shape)
|
||||
{
|
||||
st.by_shape.remove(&k);
|
||||
}
|
||||
st.by_shape.insert(shape.into(), r.clone());
|
||||
}
|
||||
|
||||
/// `tplProbeStore`.
|
||||
fn store(&self, key: &str, shape: &str, r: &ProbeResult) {
|
||||
let mut st = self.st();
|
||||
st.last = Some(r.clone());
|
||||
Self::keep_shape(&mut st, shape, r);
|
||||
if key.is_empty() || !r.cacheable {
|
||||
return;
|
||||
}
|
||||
if st.cache.len() >= CACHE_MAX
|
||||
&& let Some(k) = oldest(&st.cache)
|
||||
{
|
||||
st.cache.remove(&k);
|
||||
}
|
||||
st.cache.insert(key.into(), r.clone());
|
||||
}
|
||||
|
||||
/// Le prober du moteur local.
|
||||
pub fn prober(&self, env: &Env) -> Prober {
|
||||
Prober::local(env.port, &env.key, self.backoff)
|
||||
}
|
||||
|
||||
/// `tplProbeEnsure` : verdict pour la forme, depuis le cache ou une sonde.
|
||||
pub async fn ensure(&self, env: &Env, shape: &Shape) -> ProbeResult {
|
||||
if env.external {
|
||||
return ProbeResult::unknown("preset externe : pas de sonde");
|
||||
}
|
||||
self.st().shape_last = Some(shape.clone());
|
||||
let p = self.prober(env);
|
||||
if !p.healthy().await {
|
||||
return ProbeResult::unknown("moteur pas prêt (/health)");
|
||||
}
|
||||
let props = match p.props().await {
|
||||
Ok(pr) => pr,
|
||||
Err(e) => {
|
||||
let r = ProbeResult::unknown(&format!("/props illisible : {e}"));
|
||||
self.store("", &shape.hash(), &r);
|
||||
return r;
|
||||
}
|
||||
};
|
||||
let tmpl_hash = fnv_hex(props.chat_template.as_bytes());
|
||||
let key = format!(
|
||||
"{}\x00{}\x00{tmpl_hash}\x00{}",
|
||||
props.build_info,
|
||||
props.model_path,
|
||||
shape.hash()
|
||||
);
|
||||
{
|
||||
let mut st = self.st();
|
||||
if let Some(r) = st.cache.get(&key).cloned() {
|
||||
st.last = Some(r.clone());
|
||||
Self::keep_shape(&mut st, &shape.hash(), &r);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
let mut r = p.probe(shape).await;
|
||||
r.build = props.build_info.clone();
|
||||
r.template_hash = tmpl_hash;
|
||||
r.tools = shape.n_tools;
|
||||
r.kwargs = shape.kwargs.clone();
|
||||
r.effort = shape.effort.clone();
|
||||
if !props.model_path.is_empty() {
|
||||
r.model = crate::paths::base_name(&props.model_path);
|
||||
}
|
||||
let line = probe_line(&r);
|
||||
let fresh = {
|
||||
let mut st = self.st();
|
||||
let fresh = line != st.logged;
|
||||
st.logged = line.clone();
|
||||
fresh
|
||||
};
|
||||
if fresh {
|
||||
eprintln!("{line}");
|
||||
}
|
||||
self.store(&key, &shape.hash(), &r);
|
||||
r
|
||||
}
|
||||
|
||||
/// `tplProbeKick` : après une complétion complète du fil principal, en
|
||||
/// tâche de fond ; au plus une sonde en vol, au plus une par `every`.
|
||||
pub fn kick(self: &std::sync::Arc<Self>, env: Env, shape: Shape) {
|
||||
if !self.auto {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut st = self.st();
|
||||
if st.running || st.tried.is_some_and(|t| t.elapsed() < self.every) {
|
||||
return;
|
||||
}
|
||||
st.running = true;
|
||||
st.tried = Some(Instant::now());
|
||||
}
|
||||
let me = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(30), me.ensure(&env, &shape)).await;
|
||||
me.st().running = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fnv64a() {
|
||||
// Vecteurs de référence FNV-1a 64 bits.
|
||||
assert_eq!(fnv_hex(b""), "cbf29ce484222325");
|
||||
assert_eq!(fnv_hex(b"a"), "af63dc4c8601ec8c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instant_rfc3339() {
|
||||
assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(
|
||||
rfc3339(UNIX_EPOCH + Duration::new(1_709_251_200, 500_000_000)),
|
||||
"2024-03-01T00:00:00.5Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_omitempty() {
|
||||
let mut r = ProbeResult::unknown("");
|
||||
r.kwargs = Some(Map::new());
|
||||
let v = r.to_json();
|
||||
assert!(v.get("chat_template_kwargs").is_none());
|
||||
assert!(v.get("note").is_none());
|
||||
assert_eq!(v["tools"], 0);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,9 @@
|
||||
//! l'historique ([`TurnResult::extra`]).
|
||||
//!
|
||||
//! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) :
|
||||
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT (slot et
|
||||
//! rejeux « cache KV plein »), SLOT_PERSIST, préchauffage, sonde de gabarit,
|
||||
//! télémétrie `perfRecord`, comptage des requêtes en vol du moteur. Avec l'une
|
||||
//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, SIDE_SLOT (slot et rejeux « cache KV
|
||||
//! plein »), SLOT_PERSIST, préchauffage, télémétrie `perfRecord`, comptage
|
||||
//! des requêtes en vol du moteur. Avec l'une
|
||||
//! de ces clés, le Go garde la conversation (`facadeChatBlocker`, décidé au
|
||||
//! démarrage).
|
||||
|
||||
@@ -152,6 +152,20 @@ pub trait TurnHost: Send + Sync {
|
||||
}
|
||||
/// Messages mis en file par l'utilisateur, à injecter maintenant.
|
||||
fn drain_queued(&self) -> Vec<Message>;
|
||||
/// Complétion lue en entier sur le moteur local, outils ni coupés ni
|
||||
/// neutralisés : la sonde de gabarit peut rejouer sa forme
|
||||
/// (`tplProbeKick`).
|
||||
fn completed(&self, _tools: &[Tool], _kwargs: Option<&Map<String, Value>>, _effort: &str) {}
|
||||
/// La sonde a-t-elle conclu « préfixe instable » pour CE modèle et pour
|
||||
/// cette forme de requête (`nudgeInToolOn`, hors clé et preset externe) ?
|
||||
fn prefix_unstable(
|
||||
&self,
|
||||
_tools: &[Tool],
|
||||
_kwargs: Option<&Map<String, Value>>,
|
||||
_effort: &str,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
/// Prépare ce que [`Self::image_data_url`] devra rendre pour ces messages
|
||||
/// (lecture et déchiffrement des images rangées), avant chaque requête.
|
||||
fn prepare_images(&self, _msgs: &[Message]) -> impl Future<Output = ()> + Send {
|
||||
@@ -384,7 +398,19 @@ pub async fn run_turn<H: TurnHost>(
|
||||
if !nudge.is_empty() {
|
||||
s.budget_nudges += 1;
|
||||
log_budget(s.tool_runs, budget, s.budget_nudges);
|
||||
append_nudge(&mut messages, &mut extra, &nudge);
|
||||
// NUDGE_IN_TOOL (opt-in, gabarits dont le rendu bouge quand un
|
||||
// message user s'ajoute) : au bout du dernier résultat d'outil.
|
||||
// La sonde ne connaît que la forme normale : une relance d'une
|
||||
// autre forme se replie sur le message à part.
|
||||
let placed = !s.disable_tools
|
||||
&& !s.tool_choice_none
|
||||
&& super::nudges::nudge_in_tool_key(&tc.cfg)
|
||||
&& !ep.external
|
||||
&& host.prefix_unstable(&tc.tools, kwargs.as_ref(), &effort)
|
||||
&& super::nudges::nudge_into_tool(&mut messages, &mut extra, &nudge);
|
||||
if !placed {
|
||||
append_nudge(&mut messages, &mut extra, &nudge);
|
||||
}
|
||||
}
|
||||
let calls_on = !tc.tools.is_empty() && !s.disable_tools && !s.tool_choice_none;
|
||||
let pol = turn_echo_policy(ep.external || s.echo_off_turn, || tc.echo.clone());
|
||||
@@ -585,6 +611,11 @@ pub async fn run_turn<H: TurnHost>(
|
||||
if out.is_complete(read_err.is_some()) {
|
||||
// Dernier événement de la complétion : copie complète des stats.
|
||||
cb(StreamEvent::Stats(out.stats.clone()));
|
||||
// Sonde de gabarit, en tâche de fond et après coup : rien n'en
|
||||
// revient dans la requête suivante.
|
||||
if !ep.external && !s.disable_tools && !s.tool_choice_none {
|
||||
host.completed(&tc.tools, kwargs.as_ref(), &effort);
|
||||
}
|
||||
if !ep.external {
|
||||
s.peak_gen = s.peak_gen.max(out.stats.gen_tokens);
|
||||
if s.last_est > 0 && out.stats.prompt_tokens_total > 0 {
|
||||
|
||||
@@ -17,6 +17,9 @@ enum Script {
|
||||
Cut(Vec<String>),
|
||||
}
|
||||
|
||||
/// Une complétion signalée à la sonde : (outils, kwargs, effort).
|
||||
type Completion = (usize, Option<Map<String, Value>>, String);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
script: Mutex<VecDeque<Script>>,
|
||||
@@ -29,6 +32,10 @@ struct Fake {
|
||||
efforts: Mutex<Vec<(String, String)>>,
|
||||
/// L'arrêt à déclencher pendant l'exécution d'un outil.
|
||||
stop_on_exec: Mutex<Option<watch::Sender<bool>>>,
|
||||
/// Verdict « préfixe instable » de la sonde (NUDGE_IN_TOOL).
|
||||
unstable: Mutex<bool>,
|
||||
/// Complétions signalées à la sonde : (outils, kwargs, effort).
|
||||
completions: Mutex<Vec<Completion>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
@@ -127,6 +134,15 @@ impl TurnHost for Fake {
|
||||
self.efforts.lock().unwrap().push((want.into(), got.into()));
|
||||
}
|
||||
fn echo_refuse(&self, _model: &str) {}
|
||||
fn completed(&self, tools: &[Tool], kwargs: Option<&Map<String, Value>>, effort: &str) {
|
||||
self.completions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((tools.len(), kwargs.cloned(), effort.into()));
|
||||
}
|
||||
fn prefix_unstable(&self, _t: &[Tool], _k: Option<&Map<String, Value>>, _e: &str) -> bool {
|
||||
*self.unstable.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn content(s: &str) -> String {
|
||||
@@ -339,6 +355,17 @@ async fn erreur_500_tool_choice_none_puis_outils_retires() {
|
||||
);
|
||||
assert!(host.body(2).get("tools").is_none(), "puis retirés");
|
||||
assert_eq!(host.body(2)["messages"][0]["role"], "system");
|
||||
// Une relance outils coupés n'est pas la forme normale : pas de sonde.
|
||||
assert!(host.completions.lock().unwrap().is_empty());
|
||||
// Pas davantage outils neutralisés (tool_choice « none »).
|
||||
let host = Fake::new(vec![
|
||||
Script::Status(500, "parse error".into()),
|
||||
answer("texte"),
|
||||
]);
|
||||
let (r, _) = run(&host, &config(&["glob"]), &Cancel::never()).await;
|
||||
assert_eq!(r.err, None);
|
||||
assert_eq!(host.body(1)["tool_choice"], "none");
|
||||
assert!(host.completions.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -471,6 +498,104 @@ async fn budget_d_outils_rappel() {
|
||||
}));
|
||||
}
|
||||
|
||||
/// NUDGE_IN_TOOL : avec la clé et un gabarit « préfixe instable », le rappel
|
||||
/// part au bout du dernier résultat d'outil (envoyé et gardé à l'identique),
|
||||
/// sans message user à part ; sans verdict, ou sans la clé, à part.
|
||||
#[tokio::test]
|
||||
async fn rappel_de_budget_dans_le_resultat() {
|
||||
for (key, unstable, inside) in [
|
||||
("on", true, true),
|
||||
("on", false, false),
|
||||
("", true, false),
|
||||
("Oui", true, true),
|
||||
("external", true, false),
|
||||
] {
|
||||
let mut script: Vec<Script> = (0..3)
|
||||
.map(|i| {
|
||||
tool_step(
|
||||
&format!("c{i}"),
|
||||
"bash",
|
||||
&format!("{{\"command\":\"ls {i}\"}}"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
script.push(answer("fin"));
|
||||
let host = Fake::new(script);
|
||||
*host.unstable.lock().unwrap() = unstable;
|
||||
let mut tc = config(&["bash"]);
|
||||
tc.cfg.insert("AGENT_BUDGET".into(), "2".into());
|
||||
if key == "external" {
|
||||
// Preset externe : le rappel reste à part, quoi que dise la sonde.
|
||||
let ext = HashMap::from([
|
||||
("EXTERNAL".to_string(), "1".to_string()),
|
||||
(
|
||||
"EXTERNAL_URL".to_string(),
|
||||
"http://127.0.0.1:1/v1".to_string(),
|
||||
),
|
||||
]);
|
||||
tc.ep = resolve_chat_endpoint(&ext, "");
|
||||
tc.cfg.insert("NUDGE_IN_TOOL".into(), "on".into());
|
||||
} else {
|
||||
tc.cfg.insert("NUDGE_IN_TOOL".into(), key.into());
|
||||
}
|
||||
let (r, _) = run(&host, &tc, &Cancel::never()).await;
|
||||
let msgs = host.body(2)["messages"].as_array().unwrap().clone();
|
||||
let last = msgs.last().unwrap();
|
||||
let ctx = format!("{key}/{unstable}");
|
||||
if inside {
|
||||
assert_eq!(last["role"], "tool", "{ctx}");
|
||||
let c = last["content"].as_str().unwrap();
|
||||
assert!(
|
||||
c.starts_with("résultat de bash\n\n") && c.contains("2 tool calls"),
|
||||
"{ctx} : {c}"
|
||||
);
|
||||
assert!(
|
||||
!msgs.iter().any(|m| m["role"] == "user"
|
||||
&& m["content"].as_str().unwrap_or("").contains("2 tool calls")),
|
||||
"{ctx}"
|
||||
);
|
||||
let kept = r.extra.iter().filter(|m| m.role == "tool").nth(1).unwrap();
|
||||
assert_eq!(
|
||||
kept.content.as_ref().and_then(Value::as_str),
|
||||
Some(c),
|
||||
"{ctx} : historique"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(last["role"], "user", "{ctx}");
|
||||
assert!(
|
||||
last["content"].as_str().unwrap().contains("2 tool calls"),
|
||||
"{ctx}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chaque complétion lue en entier est signalée à la sonde, avec la forme
|
||||
/// de la requête ; pas sur un preset externe.
|
||||
#[tokio::test]
|
||||
async fn completions_signalees_a_la_sonde() {
|
||||
let host = Fake::new(vec![tool_step("c1", "bash", "{}"), answer("fin")]);
|
||||
let mut tc = config(&["bash"]);
|
||||
tc.reasoning.effort = "low".into();
|
||||
let (_, _) = run(&host, &tc, &Cancel::never()).await;
|
||||
assert_eq!(
|
||||
*host.completions.lock().unwrap(),
|
||||
vec![(1, None, "low".to_string()), (1, None, "low".to_string())]
|
||||
);
|
||||
let host = Fake::new(vec![answer("fin")]);
|
||||
let mut tc = config(&["bash"]);
|
||||
let mut cfg = HashMap::new();
|
||||
cfg.insert("EXTERNAL".to_string(), "1".to_string());
|
||||
cfg.insert(
|
||||
"EXTERNAL_URL".to_string(),
|
||||
"http://127.0.0.1:1/v1".to_string(),
|
||||
);
|
||||
tc.ep = resolve_chat_endpoint(&cfg, "");
|
||||
assert!(tc.ep.external);
|
||||
let (_, _) = run(&host, &tc, &Cancel::never()).await;
|
||||
assert!(host.completions.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn arret_pendant_un_outil() {
|
||||
let host = Fake::new(vec![tool_step("c1", "bash", r#"{"command":"sleep 100"}"#)]);
|
||||
|
||||
@@ -65,6 +65,7 @@ pub fn is_internal(path: &str) -> bool {
|
||||
| "/internal/facade/v1/reload"
|
||||
| "/internal/facade/v1/project/switch"
|
||||
| "/internal/facade/v1/project/drop"
|
||||
| "/internal/facade/v1/chat/tpl"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,6 +91,22 @@ pub async fn serve_internal(chat: &Arc<ChatService>, req: Request<Incoming>) ->
|
||||
&json!({ "error": "événement illisible" }),
|
||||
),
|
||||
},
|
||||
// Dernier verdict de la sonde de gabarit (`tplCapsCurrent`), pour le
|
||||
// résumé des performances que sert encore le Go.
|
||||
"/internal/facade/v1/chat/tpl" => {
|
||||
let cfg = chat.shared.store.config().await;
|
||||
match chat
|
||||
.shared
|
||||
.tpl
|
||||
.caps_current(crate::engstatus::is_external(&cfg))
|
||||
{
|
||||
Some(r) => send_json(
|
||||
StatusCode::OK,
|
||||
&json!({ "ok": true, "result": r.to_json() }),
|
||||
),
|
||||
None => send_json(StatusCode::OK, &json!({ "ok": false })),
|
||||
}
|
||||
}
|
||||
"/internal/facade/v1/project/switch" | "/internal/facade/v1/project/drop" => {
|
||||
let v = read_json(req.into_body()).await.unwrap_or_default();
|
||||
let slug = str_of(&v, "slug");
|
||||
|
||||
+195
-1
@@ -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
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user