Lot 29 : télémétrie des complétions de la façade

Quand la façade tient la conversation (LOKI_CHAT=rust), ses complétions
rejoignent l'anneau de télémétrie du Go (perf_log.go) au lieu de passer
inaperçues : /api/perf/summary les compte, l'optimiseur en tire ses
poids, et l'interface reçoit à nouveau la perte de cache.

- chat/perf.rs : perfRecFromStats, perfRecFromWire (lu sans échec
  possible, mêmes tolérances que perfNum, timings d'un autre type
  présents mais vides comme le Go) et perfPrefix (FNV-1a cumulé) ;
- la boucle du tour range chaque complétion lue, numérotée dans le tour,
  avec ses empreintes si elle est complète ; une complétion coupée par un
  stop est rangée incomplète ; la dernière statistique porte la nature,
  la perte, ce qui s'est intercalé et l'alerte ;
- natures : main (tour), verify (vérification du mode Code), compact
  (résumé de compaction, rangé dès que la réponse se lit) ;
- le Go (/internal/v1/perf/record) range le relevé comme les siens et
  calcule l'alerte au seuil du preset actif, jamais pour un preset
  externe.

Tests : relevés et empreintes (chat/perf.rs), télémétrie de la boucle
(complète, coupée), du vrai hôte et des résumés (tests/lot6.rs),
TestFacadePerf côté Go.

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-16 01:17:49 +00:00
1 parent f6c191922f
commit 8a61879bfd
15 files changed
+731 -16

No files matched your search

+4 -1
View File
@@ -853,6 +853,9 @@ struct WireMsg {
tool_calls: Option<Vec<Value>>,
}
/// Réponse lue mais sans choix.
pub const ERR_EMPTY_SUMMARY: &str = "résumé: réponse vide";
/// Décodage d'une réponse non streamée de /v1/chat/completions, comme
/// `postSummary` : seule la PREMIÈRE valeur JSON compte (un octet parasite
/// après elle est ignoré), aucun choix → « résumé: réponse vide ».
@@ -866,7 +869,7 @@ pub fn parse_summary_response(raw: &[u8]) -> Result<SummaryChoice, SummaryError>
Some(Ok(r)) => r,
};
let Some(ch) = resp.choices.and_then(|c| c.into_iter().next()) else {
return Err(SummaryError::Other("résumé: réponse vide".into()));
return Err(SummaryError::Other(ERR_EMPTY_SUMMARY.into()));
};
Ok(SummaryChoice {
content: ch.message.content.unwrap_or_default(),
+5 -2
View File
@@ -23,8 +23,9 @@
//! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé.
//!
//! Écarts assumés (restent au Go) : PROJ_SNAPSHOT et KEEP_TURN_IMAGES
//! (refusés par `/turn/view`), préchauffage, télémétrie, motifs
//! `retry_patterns` personnalisés.
//! (refusés par `/turn/view`), préchauffage, motifs `retry_patterns`
//! personnalisés. La télémétrie des complétions (tours, vérifications,
//! résumés) rejoint l'anneau du Go (`/internal/v1/perf/record`).
use std::collections::VecDeque;
use std::future::Future;
@@ -508,6 +509,7 @@ impl ChatService {
cfg: cfg.clone(),
patterns: self.shared.patterns.clone(),
port,
perf: Some((store.clone(), self.sessions.conv().id())),
};
TurnEnv {
cfg,
@@ -797,6 +799,7 @@ impl ChatService {
echo: echo.clone(),
compact: ccfg.clone(),
patterns: self.shared.patterns.clone(),
perf_kind: super::perf::KIND_MAIN,
};
let me = self.clone();
let host = GoTurnHost {
+27 -1
View File
@@ -170,6 +170,9 @@ pub struct EngineSummarizer {
pub cfg: HashMap<String, String>,
pub patterns: Arc<RetryPatterns>,
pub port: u16,
/// Télémétrie des résumés (kind=compact), rattachée à cette discussion :
/// `None` sans base (tests).
pub perf: Option<(Store, String)>,
}
impl EngineSummarizer {
@@ -200,7 +203,19 @@ impl EngineSummarizer {
body: String::from_utf8_lossy(b).trim().to_string(),
});
}
parse_summary_response(&raw)
let out = parse_summary_response(&raw);
// Télémétrie de la compaction : réponse non streamée, timings et usage
// à la racine, lus sans échec possible — dès que la réponse se lit.
let decoded = match &out {
Ok(_) => true,
Err(SummaryError::Other(e)) => e == super::compact::ERR_EMPTY_SUMMARY,
Err(_) => false,
};
if decoded && let Some((store, conv)) = &self.perf {
let rec = super::perf::PerfRec::from_wire(super::perf::KIND_COMPACT, conv, &raw);
super::perf::record(store, &rec, &[], self.ep.external).await;
}
out
}
}
@@ -393,6 +408,17 @@ impl TurnHost for GoTurnHost {
}
}
async fn perf(
&self,
rec: &super::perf::PerfRec,
prefix: &[u64],
external: bool,
) -> super::perf::PerfNoted {
let mut rec = rec.clone();
rec.conv = self.conv_id.clone();
super::perf::record(&self.shared.store, &rec, prefix, external).await
}
async fn exec_tool(&self, name: &str, args: &Map<String, Value>, label: &str) -> ToolOutcome {
// Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go.
if crate::tools::is_local(name) {
+3 -1
View File
@@ -18,7 +18,8 @@
//! - [`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) ;
//! - [`tplprobe`] : sonde de gabarit (rendus de `/apply-template`).
//! - [`tplprobe`] : sonde de gabarit (rendus de `/apply-template`) ;
//! - [`perf`] : télémétrie des complétions (relevés rangés par le Go).
pub mod compact;
pub mod conv;
@@ -29,6 +30,7 @@ pub mod host;
pub mod journal;
pub mod lease;
pub mod nudges;
pub mod perf;
pub mod persist;
pub mod sessions;
pub mod stream;
+279
View File
@@ -0,0 +1,279 @@
//! Télémétrie des complétions de la façade (lot 29) — port de la partie
//! « relevé » de `perf_log.go` : `perfRecFromStats`, `perfRecFromWire`,
//! `perfPrefix`. L'anneau, le calcul de la perte de cache et son seuil
//! d'alerte restent au Go (`/internal/v1/perf/record`) : les complétions de
//! la façade rejoignent celles du Go dans `/api/perf/summary`.
//!
//! Des identifiants et des compteurs, jamais de texte. Rien ne retourne au
//! moteur.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::types::{Message, StatsEvent};
use crate::store::Store;
/// Nature d'une complétion.
pub const KIND_MAIN: &str = "main";
pub const KIND_VERIFY: &str = "verify";
pub const KIND_COMPACT: &str = "compact";
fn is_zero_i(v: &i64) -> bool {
*v == 0
}
fn is_zero_f(v: &f64) -> bool {
*v == 0.0
}
/// `perfRec` (sans `at` ni `seq`, posés par le Go).
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct PerfRec {
pub kind: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub conv: String,
pub iter: i64,
pub complete: bool,
/// usage.prompt_tokens : la taille du prompt entier.
#[serde(skip_serializing_if = "is_zero_i")]
pub total: i64,
/// cache_n ; `None` : inconnu.
#[serde(skip_serializing_if = "Option::is_none")]
pub cached: Option<i64>,
/// prompt_n : les jetons réellement recalculés.
#[serde(skip_serializing_if = "is_zero_i")]
pub new: i64,
#[serde(skip_serializing_if = "is_zero_f")]
pub pp_ms: f64,
#[serde(skip_serializing_if = "is_zero_f")]
pub pp_tps: f64,
#[serde(rename = "gen", skip_serializing_if = "is_zero_i")]
pub generated: i64,
#[serde(skip_serializing_if = "is_zero_f")]
pub tg_tps: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_n: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_accepted: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttft_ms: Option<i64>,
}
impl PerfRec {
/// `perfRecFromStats` : la complétion telle que la boucle l'a vue.
pub fn from_stats(kind: &str, conv: &str, iter: i64, complete: bool, s: &StatsEvent) -> Self {
PerfRec {
kind: kind.into(),
conv: conv.into(),
iter,
complete,
total: s.prompt_tokens_total,
cached: s.cache_tokens,
new: s.prompt_tokens,
pp_ms: s.prompt_ms,
pp_tps: s.prompt_per_second,
generated: s.gen_tokens,
tg_tps: s.gen_per_second,
draft_n: s.draft_n,
draft_accepted: s.draft_accepted,
ttft_ms: s.ttft_ms,
}
}
/// `perfRecFromWire` : une réponse NON streamée (compaction), lue d'un
/// bloc. Jamais d'échec : un compteur illisible reste inconnu.
pub fn from_wire(kind: &str, conv: &str, raw: &[u8]) -> Self {
let mut r = PerfRec {
kind: kind.into(),
conv: conv.into(),
..Default::default()
};
// Seule la première valeur JSON compte (`json.Decoder`).
let v = serde_json::Deserializer::from_slice(raw)
.into_iter::<Value>()
.next()
.and_then(Result::ok)
.unwrap_or(Value::Null);
// Une valeur non nulle d'un autre type (chaîne, nombre) alloue quand
// même la structure côté Go avant l'erreur de type : présente, vide.
let has_timings = v.get("timings").is_some_and(|t| !t.is_null());
let timings = v.get("timings").filter(|t| t.is_object());
if let Some(t) = timings {
let n = |k: &str| perf_num(t.get(k));
r.new = n("prompt_n").unwrap_or(0.0) as i64;
r.pp_ms = n("prompt_ms").unwrap_or(0.0);
r.pp_tps = n("prompt_per_second").unwrap_or(0.0);
r.generated = n("predicted_n").unwrap_or(0.0) as i64;
r.tg_tps = n("predicted_per_second").unwrap_or(0.0);
r.cached = n("cache_n").map(|f| f as i64);
r.draft_n = n("draft_n").map(|f| f as i64);
r.draft_accepted = n("draft_n_accepted").map(|f| f as i64);
}
let usage = v.get("usage").filter(|u| u.is_object());
if let Some(u) = usage {
r.total = perf_num(u.get("prompt_tokens")).unwrap_or(0.0) as i64;
if r.generated == 0 {
r.generated = perf_num(u.get("completion_tokens")).unwrap_or(0.0) as i64;
}
}
if r.cached.is_none() {
r.cached = usage
.and_then(|u| u.get("prompt_tokens_details"))
.filter(|d| d.is_object())
.and_then(|d| perf_num(d.get("cached_tokens")))
.map(|f| f as i64);
}
r.complete = r.total > 0 || has_timings;
r
}
}
/// `perfNum` : un nombre (ou une chaîne qui en contient un), fini, positif
/// et < 1e12 ; sinon inconnu.
fn perf_num(v: Option<&Value>) -> Option<f64> {
let f = match v? {
Value::Number(n) => n.as_f64()?,
Value::String(s) => s.parse::<f64>().ok()?,
_ => return None,
};
(f.is_finite() && (0.0..1e12).contains(&f)).then_some(f)
}
/// FNV-1a 64 bits.
fn fnv1a(h: &mut u64, b: &[u8]) {
for &c in b {
*h ^= u64::from(c);
*h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
/// `perfPrefix` : empreintes cumulées des messages envoyés (l'empreinte i
/// couvre les messages 0..i). Une requête PROLONGE la précédente de la même
/// discussion quand l'empreinte de la précédente est un de ses préfixes.
pub fn prefix(msgs: &[Message]) -> Vec<u64> {
let mut h = 0xcbf2_9ce4_8422_2325u64;
msgs.iter()
.map(|m| {
fnv1a(&mut h, &serde_json::to_vec(m).unwrap_or_default());
fnv1a(&mut h, &[0]);
h
})
.collect()
}
/// Ce que le Go a tiré de la complétion rangée.
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
pub struct PerfNoted {
#[serde(default)]
pub lost: Option<i64>,
#[serde(default)]
pub lost_after: String,
#[serde(default)]
pub alert: bool,
}
/// Range la complétion dans l'anneau du Go. Le Go injoignable : rien (la
/// télémétrie ne fait jamais échouer un tour).
pub async fn record(store: &Store, rec: &PerfRec, prefix: &[u64], external: bool) -> PerfNoted {
match store
.internal_post(
"/internal/v1/perf/record",
&json!({ "rec": rec, "prefix": prefix, "external": external }),
)
.await
{
Ok((code, v)) if code.is_success() => serde_json::from_value(v).unwrap_or_default(),
_ => PerfNoted::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn releve_d_une_reponse_entiere() {
let r = PerfRec::from_wire(
KIND_COMPACT,
"c1",
br#"{"timings":{"prompt_n":"120","prompt_ms":50.5,"prompt_per_second":2376,"predicted_n":30,"predicted_per_second":40,"cache_n":900,"draft_n":-1},"usage":{"prompt_tokens":1020,"completion_tokens":31}} parasite"#,
);
assert_eq!(
r,
PerfRec {
kind: "compact".into(),
conv: "c1".into(),
complete: true,
total: 1020,
cached: Some(900),
new: 120,
pp_ms: 50.5,
pp_tps: 2376.0,
generated: 30,
tg_tps: 40.0,
..Default::default()
}
);
// Sans timings : le cache d'OpenAI, les jetons générés de l'usage.
let r = PerfRec::from_wire(
KIND_COMPACT,
"",
br#"{"usage":{"prompt_tokens":10,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":4}}}"#,
);
assert_eq!(
(r.complete, r.total, r.generated, r.cached),
(true, 10, 3, Some(4))
);
// Rien de lisible : incomplète, tout inconnu.
let r = PerfRec::from_wire(KIND_COMPACT, "", b"pas du json");
assert!(!r.complete);
assert_eq!(r.cached, None);
let r = PerfRec::from_wire(
KIND_COMPACT,
"",
br#"{"timings":null,"usage":{"prompt_tokens":1e13}}"#,
);
assert_eq!((r.complete, r.total), (false, 0));
// Des timings d'un autre type : présents mais vides, comme le Go.
let r = PerfRec::from_wire(KIND_COMPACT, "", br#"{"timings":"x"}"#);
assert_eq!((r.complete, r.new), (true, 0));
}
#[test]
fn empreintes_cumulees() {
let m = |c: &str| Message {
role: "user".into(),
content: Some(json!(c)),
..Default::default()
};
let a = prefix(&[m("a"), m("b")]);
let b = prefix(&[m("a"), m("b"), m("c")]);
assert_eq!(a.len(), 2);
assert_eq!(b[..2], a[..]);
assert_ne!(prefix(&[m("x"), m("b")])[1], a[1]);
assert!(prefix(&[]).is_empty());
// FNV-1a de référence : « a » seul.
let mut h = 0xcbf2_9ce4_8422_2325u64;
fnv1a(&mut h, b"a");
assert_eq!(h, 0xaf63_dc4c_8601_ec8c);
}
#[test]
fn releve_au_format_du_go() {
let s = StatsEvent {
prompt_tokens: 5,
prompt_tokens_total: 100,
cache_tokens: Some(95),
gen_tokens: 7,
gen_per_second: 20.5,
ttft_ms: Some(30),
..Default::default()
};
let r = PerfRec::from_stats(KIND_MAIN, "c", 2, true, &s);
assert_eq!(
serde_json::to_value(&r).unwrap(),
json!({ "kind": "main", "conv": "c", "iter": 2, "complete": true, "total": 100,
"cached": 95, "new": 5, "gen": 7, "tg_tps": 20.5, "ttft_ms": 30 })
);
}
}
+51 -10
View File
@@ -23,12 +23,15 @@
//! `cb` (comme le `ChatCallback` Go) ; le tour rend ses messages à ajouter à
//! l'historique ([`TurnResult::extra`]).
//!
//! Télémétrie : chaque complétion lue est rangée (`perfRecord`, par
//! [`TurnHost::perf`]) ; la dernière statistique d'une complétion complète
//! porte la nature et la perte de cache calculée par le Go.
//!
//! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) :
//! 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).
//! plein »), SLOT_PERSIST, préchauffage, 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).
use std::collections::HashMap;
use std::future::Future;
@@ -54,6 +57,7 @@ use super::nudges::{
MAX_NUDGES, THINK_NUDGE_FIRST, THINK_NUDGE_STUCK, append_nudge, budget_nudge, dedupable_tool,
log_budget, repeated_call_result, steer_system, tool_result_preview,
};
use super::perf::PerfRec;
use super::stream::{
CacheHintFn, MAX_PATTERN_RETRIES, PreflightFn, RetryPatterns, StreamConfig, StreamParser,
call_args, retry_corrective, tool_call_label,
@@ -181,6 +185,16 @@ pub trait TurnHost: Send + Sync {
fn cache_hint(&self) -> Option<CacheHintFn> {
None
}
/// Range la télémétrie d'une complétion (`perfRecord`), rattachée à la
/// discussion du tour ; rend la perte de cache calculée et l'alerte.
fn perf(
&self,
_rec: &super::perf::PerfRec,
_prefix: &[u64],
_external: bool,
) -> impl Future<Output = super::perf::PerfNoted> + Send {
async { super::perf::PerfNoted::default() }
}
/// Garde d'écriture du mode Code (`codeWriteGuard`).
fn preflight(&self) -> Option<PreflightFn> {
None
@@ -202,6 +216,8 @@ pub struct TurnConfig {
pub echo: EchoPolicy,
pub compact: CompactConfig,
pub patterns: Arc<RetryPatterns>,
/// Nature des complétions pour la télémétrie (`perfMain`, `perfVerify`).
pub perf_kind: &'static str,
}
/// Fin du tour : messages à ajouter à l'historique, et l'erreur s'il y en a
@@ -348,6 +364,8 @@ struct Loop {
echo_tpl_retry: bool,
tool_runs: i64,
budget_nudges: i64,
/// Complétions rangées dans la télémétrie (`perfIter`).
perf_iter: i64,
}
/// `runChatLoop` : le tour, de la première requête à la réponse finale.
@@ -415,8 +433,9 @@ pub async fn run_turn<H: TurnHost>(
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());
host.prepare_images(&messages).await;
let (body, echo_sent) = {
let (body, echo_sent, sent_prefix) = {
let (sent, echo_sent) = wire_messages(&messages, s.tool_choice_none, &pol);
let sent_prefix = super::perf::prefix(&sent);
let payload = build_chat_payload(
ep,
&sent,
@@ -432,7 +451,11 @@ pub async fn run_turn<H: TurnHost>(
&tc.cfg,
&|r| host.image_data_url(r),
);
(serde_json::to_vec(&payload).unwrap_or_default(), echo_sent)
(
serde_json::to_vec(&payload).unwrap_or_default(),
echo_sent,
sent_prefix,
)
};
let t_req = Instant::now();
let reply = match cancel.or_stop(host.send(ep, body)).await {
@@ -603,14 +626,32 @@ pub async fn run_turn<H: TurnHost>(
}
}
// Arrêt pendant la lecture : le tour s'arrête là, sans erreur, et rien
// de plus ne part vers l'écran.
// de plus ne part vers l'écran. La complétion coupée reste dans la
// télémétrie, incomplète.
if stopped {
let rec =
PerfRec::from_stats(tc.perf_kind, "", s.perf_iter, false, &Default::default());
host.perf(&rec, &[], ep.external).await;
return TurnResult { extra, err: None };
}
let out = parser.finish(&mut emit);
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()));
// Télémétrie : une complétion coupée avant son chunk final n'a ni cache
// ni total fiables — rangée incomplète, sans calcul de perte.
let complete = out.is_complete(read_err.is_some());
let rec = PerfRec::from_stats(tc.perf_kind, "", s.perf_iter, complete, &out.stats);
let noted = host
.perf(&rec, if complete { &sent_prefix } else { &[] }, ep.external)
.await;
s.perf_iter += 1;
if complete {
// Dernier événement de la complétion : copie complète des stats,
// avec la perte de cache vue par la télémétrie.
let mut st = out.stats.clone();
st.kind = tc.perf_kind.into();
st.lost = noted.lost;
st.lost_after = noted.lost_after;
st.lost_alert = noted.alert;
cb(StreamEvent::Stats(st));
// 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 {
+88 -1
View File
@@ -2,7 +2,7 @@
use super::*;
use crate::chat::compact::CompactConfig;
use crate::chat::types::ToolFunction;
use crate::chat::types::{StatsEvent, ToolFunction};
use crate::chat::wire::resolve_chat_endpoint;
use serde_json::json;
use std::collections::VecDeque;
@@ -36,6 +36,10 @@ struct Fake {
unstable: Mutex<bool>,
/// Complétions signalées à la sonde : (outils, kwargs, effort).
completions: Mutex<Vec<Completion>>,
/// Télémétrie rangée : (relevé, empreintes, externe) ; et ce que le Go
/// en tire.
perfs: Mutex<Vec<(crate::chat::perf::PerfRec, Vec<u64>, bool)>>,
noted: Mutex<crate::chat::perf::PerfNoted>,
}
impl Fake {
@@ -143,6 +147,18 @@ impl TurnHost for Fake {
fn prefix_unstable(&self, _t: &[Tool], _k: Option<&Map<String, Value>>, _e: &str) -> bool {
*self.unstable.lock().unwrap()
}
async fn perf(
&self,
rec: &crate::chat::perf::PerfRec,
prefix: &[u64],
external: bool,
) -> crate::chat::perf::PerfNoted {
self.perfs
.lock()
.unwrap()
.push((rec.clone(), prefix.to_vec(), external));
self.noted.lock().unwrap().clone()
}
}
fn content(s: &str) -> String {
@@ -190,6 +206,7 @@ fn config(tools: &[&str]) -> TurnConfig {
echo: EchoPolicy::default(),
compact: CompactConfig::default(),
patterns: RetryPatterns::builtin(),
perf_kind: "main",
}
}
@@ -623,3 +640,73 @@ async fn flux_externe_coupe_repris_apres_le_texte_affiche() {
let (r, _) = run(&host, &config(&[]), &Cancel::never()).await;
assert!(r.err.unwrap().contains("a été coupé en cours de route"));
}
/// Télémétrie : chaque complétion rangée, numérotée dans le tour, avec les
/// empreintes des messages envoyés ; la dernière statistique porte la
/// nature et la perte de cache vue par le Go.
#[tokio::test]
async fn telemetrie_des_completions() {
use crate::chat::perf::PerfNoted;
let host = Fake::new(vec![
tool_step("c1", "glob", r#"{"pattern":"*.go"}"#),
answer("Trouvé"),
]);
*host.noted.lock().unwrap() = PerfNoted {
lost: Some(40),
lost_after: "foreign".into(),
alert: true,
};
let mut tc = config(&["glob"]);
tc.perf_kind = "verify";
let (r, evs) = run(&host, &tc, &Cancel::never()).await;
assert_eq!(r.err, None);
let perfs = host.perfs.lock().unwrap().clone();
assert_eq!(perfs.len(), 2);
for (i, (rec, prefix, external)) in perfs.iter().enumerate() {
assert_eq!(rec.kind, "verify");
assert_eq!(rec.iter, i as i64);
assert!(rec.complete);
assert_eq!((rec.total, rec.generated), (100, 5));
assert!(!external);
assert!(!prefix.is_empty());
}
// La seconde requête prolonge la première : mêmes empreintes en tête.
let (a, b) = (&perfs[0].1, &perfs[1].1);
assert_eq!(a.len(), 1);
assert_eq!(b.len(), 3);
assert_eq!(b[..1], a[..]);
let last: Vec<&StatsEvent> = evs
.iter()
.filter_map(|e| match e {
StreamEvent::Stats(s) if !s.kind.is_empty() => Some(s),
_ => None,
})
.collect();
assert_eq!(last.len(), 2);
assert_eq!(
(
last[1].kind.as_str(),
last[1].lost,
last[1].lost_after.as_str(),
last[1].lost_alert
),
("verify", Some(40), "foreign", true)
);
}
/// Une complétion coupée avant son chunk final : rangée incomplète, sans
/// empreintes ; un stop pendant la lecture aussi.
#[tokio::test]
async fn telemetrie_incomplete() {
let host = Fake::new(vec![Script::Cut(vec![content("Bon")])]);
let (_, evs) = run(&host, &config(&[]), &Cancel::never()).await;
let perfs = host.perfs.lock().unwrap().clone();
assert_eq!(perfs.len(), 1);
assert!(!perfs[0].0.complete);
assert!(perfs[0].1.is_empty());
assert!(
!evs.iter()
.any(|e| matches!(e, StreamEvent::Stats(s) if !s.kind.is_empty())),
"pas de statistique finale"
);
}
+1
View File
@@ -408,6 +408,7 @@ impl ChatService {
echo: env.echo.clone(),
compact: env.ccfg.clone(),
patterns: self.shared.patterns.clone(),
perf_kind: super::perf::KIND_VERIFY,
};
let host = GoTurnHost {
shared: self.shared.clone(),
+10
View File
@@ -125,6 +125,8 @@ pub struct FakeGo {
/// pour une action donnée.
pub service_os: Mutex<Vec<String>>,
pub service_os_fail: Mutex<Option<(String, String)>>,
/// /internal/v1/perf/record reçus (corps entiers).
pub perf_records: Mutex<Vec<serde_json::Value>>,
}
impl FakeGo {
@@ -500,6 +502,14 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
}
resp(200, r#"{"ran":true,"error":""}"#)
}
"/internal/v1/perf/record" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let mut log = st.perf_records.lock().unwrap();
log.push(v);
let out = serde_json::json!({"seq": log.len(), "lost": 12, "lost_after": "task", "alert": false});
resp(200, out.to_string())
}
"/internal/v1/tune/context" => {
let v = serde_json::json!({
"busy": *st.tune_ctx_busy.lock().unwrap(),
+87
View File
@@ -680,6 +680,7 @@ async fn tour_complet_par_le_go() {
cfg: cfg.clone(),
patterns: shared.patterns.clone(),
port: 8080,
perf: None,
},
compact: TurnCompact {
cfg: CompactConfig::default(),
@@ -710,6 +711,7 @@ async fn tour_complet_par_le_go() {
echo: Default::default(),
compact: CompactConfig::default(),
patterns: shared.patterns.clone(),
perf_kind: "main",
};
let question = Message {
role: "user".into(),
@@ -761,6 +763,38 @@ async fn tour_complet_par_le_go() {
})
.collect();
assert_eq!(answer, "Voilà.");
// Télémétrie : chaque complétion rangée par le Go, rattachée à la
// discussion ; la première, sans usage, reste incomplète.
let perfs = go.perf_records.lock().unwrap().clone();
assert_eq!(perfs.len(), 2);
assert_eq!(perfs[0]["rec"]["complete"], false);
assert_eq!(perfs[0]["prefix"], serde_json::json!([]));
let r1 = &perfs[1]["rec"];
assert_eq!(
(
r1["conv"].clone(),
r1["kind"].clone(),
r1["iter"].clone(),
r1["total"].clone()
),
(
serde_json::json!("c7"),
serde_json::json!("main"),
serde_json::json!(1),
serde_json::json!(50)
)
);
assert_eq!(perfs[1]["prefix"].as_array().unwrap().len(), 3);
assert_eq!(perfs[1]["external"], false);
let last = evs
.iter()
.rev()
.find_map(|e| match e {
StreamEvent::Stats(s) if !s.kind.is_empty() => Some(s.clone()),
_ => None,
})
.unwrap();
assert_eq!((last.lost, last.lost_after.as_str()), (Some(12), "task"));
}
#[tokio::test]
@@ -978,6 +1012,7 @@ async fn hote_et_sonde_de_gabarit() {
cfg,
patterns: shared.patterns.clone(),
port: 1,
perf: None,
},
compact: TurnCompact {
cfg: CompactConfig::default(),
@@ -1768,3 +1803,55 @@ async fn parite_du_tour_complet() {
failures.join("\n\n")
);
}
/// Lot 29 : le résumé d'une compaction rejoint la télémétrie du Go
/// (kind=compact, rattaché à la discussion), dès que la réponse se lit.
#[tokio::test]
async fn telemetrie_des_resumes() {
use loki_rs::chat::compact::{Summarizer, TranscriptRequest};
let (st, go, _stop, _t) = store().await;
let (url, _seen) = fake_engine(vec![
serde_json::json!({
"choices": [{"message": {"content": "résumé"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 3000, "completion_tokens": 200},
"timings": {"prompt_n": 3000, "prompt_ms": 1500, "predicted_n": 200, "predicted_per_second": 40}
})
.to_string(),
r#"{"choices":[]}"#.into(),
"pas du json".into(),
])
.await;
let s = EngineSummarizer {
http: EngineHttp::new(),
ep: ChatEndpoint {
url,
model: "loki".into(),
key: String::new(),
external: false,
},
cfg: Default::default(),
patterns: RetryPatterns::builtin(),
port: 8080,
perf: Some((st, "c9".into())),
};
let req = || TranscriptRequest {
system: "s".into(),
transcript: "t".into(),
max_tokens: 500,
};
assert_eq!(
s.summarize_transcript(req()).await.unwrap().content,
"résumé"
);
assert!(s.summarize_transcript(req()).await.is_err(), "réponse vide");
assert!(s.summarize_transcript(req()).await.is_err(), "illisible");
let perfs = go.perf_records.lock().unwrap().clone();
assert_eq!(perfs.len(), 2, "la réponse illisible n'est pas rangée");
assert_eq!(
perfs[0]["rec"],
serde_json::json!({ "kind": "compact", "conv": "c9", "iter": 0, "complete": true,
"total": 3000, "new": 3000, "pp_ms": 1500.0, "gen": 200, "tg_tps": 40.0 })
);
assert_eq!(perfs[0]["prefix"], serde_json::json!([]));
assert_eq!(perfs[1]["rec"]["complete"], false);
}
+1
View File
@@ -195,6 +195,7 @@ async fn replay(fx: Fixture) -> Result<(), String> {
echo,
patterns: RetryPatterns::builtin(),
cfg,
perf_kind: "main",
};
let host = ParityHost {
replies: Mutex::new(fx.replies.into()),