Skip to main content

aimux_core/trace/
layer.rs

1//! `TraceLayer` — the probe decorator (RFC-0015 §2/§5.3).
2//!
3//! Wrap `Arc<dyn LanguageModel>`; generation and streaming are automatically
4//! probed: fingerprint (denoised request body) → LCP lookup → judge → sink.
5//! No trait changes, no provider changes. The auditor is pluggable and off by
6//! default (verdict = `None`, plain passthrough).
7
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex, OnceLock};
11use std::time::{Instant, SystemTime, UNIX_EPOCH};
12
13/// Process-unique trace id sequence (trace ids are `trace-{pid}-{mono}-{seq}`).
14static TRACE_SEQ: AtomicU64 = AtomicU64::new(0);
15
16/// Process-wide monotonic clock origin — every `TraceLayer` shares it, so
17/// TTL comparisons stay in one clock domain even when multiple layers write
18/// to the same `RingTraceStore`.
19fn monotonic_origin() -> &'static Instant {
20    static ORIGIN: OnceLock<Instant> = OnceLock::new();
21    ORIGIN.get_or_init(Instant::now)
22}
23
24use async_trait::async_trait;
25use futures::{Stream, StreamExt};
26use std::pin::Pin;
27use std::task::{Context, Poll};
28
29use crate::error::AiMuxError;
30use crate::language_model::LanguageModel;
31use crate::options::CallOptions;
32use crate::result::{GenerateResult, StreamResult};
33use crate::stream_part::StreamPart;
34use crate::types::Usage;
35
36use super::fingerprint::{BlockChainFingerprint, Chain, Fingerprint, denoise};
37use super::hash;
38use super::record::{TraceRecord, UsageSnapshot};
39use super::store::{LcpResult, RingTraceStore, TraceSink};
40use super::verdict::{
41    JudgmentInput, LcpInput, SessionStats, Verdict, judge as rule_judge, matrix as verdict_matrix,
42};
43
44/// The audit engine (pluggable; `RuleAuditor` is the built-in rules engine).
45pub trait CacheAuditor: Send + Sync {
46    fn judge(&self, input: &JudgmentInput) -> Verdict;
47}
48
49/// Built-in rules engine (RFC-0015 §4.2). Stateless — the strict/shared mode
50/// comes from `JudgmentInput.strict`, set by the layer.
51pub struct RuleAuditor;
52
53impl CacheAuditor for RuleAuditor {
54    fn judge(&self, input: &JudgmentInput) -> Verdict {
55        rule_judge(input)
56    }
57}
58
59/// Per-session chain bookkeeping (R-2.2 low-hit warning).
60#[derive(Default)]
61struct SessionTracker {
62    rounds: u32,
63    prev_body_len: u64,
64}
65
66/// The probe decorator. Wrap a model; every call is fingerprinted and
67/// recorded to the sink.
68pub struct TraceLayer {
69    inner: Arc<dyn LanguageModel>,
70    sink: Arc<dyn TraceSink>,
71    auditor: Option<Arc<dyn CacheAuditor>>,
72    /// strict mode (RFC-0015 §4.3); default shared (safe for multi-process
73    /// / shared API keys).
74    strict: bool,
75    /// Default session id when `CallOptions.session_id` is absent.
76    default_session: Option<String>,
77    /// Scope salt (HMAC-style key for fingerprint scoping).
78    scope_salt: u64,
79    session_tracker: Arc<Mutex<HashMap<String, SessionTracker>>>,
80}
81
82/// Request identity computed before dispatch.
83struct RequestCtx {
84    session_id: Option<String>,
85    scope_key: u64,
86    sent_at_unix_ms: i64,
87    /// Monotonic clock ms — same clock domain as the store's TTL lookups.
88    monotonic_ms: u64,
89}
90
91/// Clone of the layer's recording state — lives inside the stream closure
92/// (the layer itself is borrowed only for the call duration).
93struct RecordCtx {
94    provider: String,
95    model: String,
96    sink: Arc<dyn TraceSink>,
97    auditor: Option<Arc<dyn CacheAuditor>>,
98    strict: bool,
99    scope_salt: u64,
100    session_tracker: Arc<Mutex<HashMap<String, SessionTracker>>>,
101    ctx: RequestCtx,
102    /// RFC-0023 关联 ID:复用 `options.call_id`(缺失时由构造处生成)。
103    call_id: Option<String>,
104    request_body: Option<serde_json::Value>,
105    response_headers: Option<HashMap<String, String>>,
106    ttft_ms: Arc<AtomicU64>,
107}
108
109impl TraceLayer {
110    /// Wrap a model with a sink (records every call).
111    pub fn new(inner: Arc<dyn LanguageModel>, sink: Arc<dyn TraceSink>) -> Self {
112        Self {
113            inner,
114            sink,
115            auditor: None,
116            strict: false,
117            default_session: None,
118            scope_salt: hash::mix(0x5eed_5eed ^ std::process::id() as u64),
119            session_tracker: Arc::new(Mutex::new(HashMap::new())),
120        }
121    }
122
123    /// Default session id (used when `CallOptions.session_id` is absent) +
124    /// session key (32-byte HMAC-style salt for fingerprint scoping).
125    #[must_use]
126    pub fn with_default_session(mut self, session_id: String, key: [u8; 32]) -> Self {
127        self.default_session = Some(session_id);
128        let mut acc = 0x9e37_79b9_7f4a_7c15u64;
129        for chunk in key.chunks(8) {
130            let mut w = 0u64;
131            for (i, b) in chunk.iter().enumerate() {
132                w ^= (*b as u64) << (8 * i);
133            }
134            acc = hash::mix(acc ^ w);
135        }
136        self.scope_salt = acc;
137        self
138    }
139
140    /// Attach an auditor (default: none — verdict stays `None`).
141    #[must_use]
142    pub fn with_auditor(mut self, auditor: Arc<dyn CacheAuditor>) -> Self {
143        self.auditor = Some(auditor);
144        self
145    }
146
147    /// Convenience: attach the built-in rules auditor in strict/shared mode.
148    #[must_use]
149    pub fn with_rules_auditor(mut self, strict: bool) -> Self {
150        self.strict = strict;
151        self.auditor = Some(Arc::new(RuleAuditor));
152        self
153    }
154
155    fn make_record_ctx(
156        &self,
157        options: &CallOptions,
158        request_body: Option<serde_json::Value>,
159        response_headers: Option<HashMap<String, String>>,
160        ttft_ms: Arc<AtomicU64>,
161    ) -> RecordCtx {
162        let session_id = options
163            .session_id
164            .clone()
165            .or_else(|| self.default_session.clone());
166        // B1: scope_key isolates by (provider, base_url, model_id) — not just
167        // model_id — so two TraceLayers over the same model_id but different
168        // providers (or base_urls) sharing one RingTraceStore never cross-match
169        // their LCP history. `base_url` comes from the inner model's config
170        // snapshot; providers that only report a minimal snapshot omit it, in
171        // which case provider + model_id still isolate the scope. The default
172        // `scope_salt` stays process-level (set in `new` / `with_default_session`).
173        let provider = self.inner.provider();
174        let model_id = self.inner.model_id();
175        let base_url = self.inner.config_snapshot().base_url;
176        let mut scope_bytes = Vec::with_capacity(
177            provider.len() + model_id.len() + base_url.as_deref().map_or(0, str::len),
178        );
179        scope_bytes.extend_from_slice(provider.as_bytes());
180        scope_bytes.extend_from_slice(model_id.as_bytes());
181        if let Some(b) = &base_url {
182            scope_bytes.extend_from_slice(b.as_bytes());
183        }
184        let scope_key = hash::hash64(self.scope_salt, &scope_bytes);
185        let sent_at_unix_ms = SystemTime::now()
186            .duration_since(UNIX_EPOCH)
187            .map(|d| d.as_millis() as i64)
188            .unwrap_or(0);
189        let monotonic_ms = monotonic_origin().elapsed().as_millis() as u64;
190        RecordCtx {
191            provider: provider.to_string(),
192            model: model_id.to_string(),
193            sink: self.sink.clone(),
194            auditor: self.auditor.clone(),
195            strict: self.strict,
196            scope_salt: self.scope_salt,
197            session_tracker: self.session_tracker.clone(),
198            call_id: options.call_id.clone(),
199            ctx: RequestCtx {
200                session_id,
201                scope_key,
202                sent_at_unix_ms,
203                monotonic_ms,
204            },
205            request_body,
206            response_headers,
207            ttft_ms,
208        }
209    }
210}
211
212impl RecordCtx {
213    /// Denoised bytes for the request body (fingerprint + LCP chain share
214    /// the same bytes).
215    fn denoised_bytes(&self) -> Option<Vec<u8>> {
216        let body = self.request_body.as_ref()?;
217        serde_json::to_vec(&denoise(body)).ok()
218    }
219
220    fn usage_snapshot(usage: &Usage) -> UsageSnapshot {
221        UsageSnapshot {
222            input_total: usage.input_tokens.total.map(u64::from),
223            input_no_cache: usage.input_tokens.no_cache.map(u64::from),
224            cache_read: usage.input_tokens.cache_read.map(u64::from),
225            cache_write: usage.input_tokens.cache_write.map(u64::from),
226            output_total: usage.output_tokens.total.map(u64::from),
227            output_text: usage.output_tokens.text.map(u64::from),
228            output_reasoning: usage.output_tokens.reasoning.map(u64::from),
229            raw: usage.raw.clone(),
230        }
231    }
232
233    /// Per-session chain stats for R-2.2 (rounds + prefix stability).
234    fn session_stats_for(&self, sid: &str, lcp_bytes: u64, body_len: u64) -> SessionStats {
235        let mut map = self.session_tracker.lock().unwrap();
236        let t = map.entry(sid.to_string()).or_default();
237        let rounds = t.rounds + 1;
238        let stable = t.prev_body_len > 0 && lcp_bytes * 100 >= t.prev_body_len * 95;
239        t.rounds = rounds;
240        t.prev_body_len = body_len;
241        SessionStats {
242            same_session_rounds: rounds,
243            prefix_stable: stable,
244            lcp_gt_1024: lcp_bytes / 4 > 1024,
245        }
246    }
247
248    fn ring_store(&self) -> Option<&RingTraceStore> {
249        self.sink.as_any().downcast_ref::<RingTraceStore>()
250    }
251
252    /// LCP lookup against the sink's history (no-op for non-ring sinks).
253    fn lookup_lcp(&self, chain: &Chain, ttl_ms: u64) -> (Option<LcpInput>, u64, u64, bool) {
254        let Some(store) = self.ring_store() else {
255            return (None, 0, 0, false);
256        };
257        let now_mono = monotonic_origin().elapsed().as_millis() as u64;
258        let lcp: LcpResult = store.lookup(self.ctx.scope_key, chain, now_mono, ttl_ms);
259        let same_session = lcp
260            .matched
261            .as_ref()
262            .map(|m| m.session.as_deref() == self.ctx.session_id.as_deref())
263            .unwrap_or(false);
264        let upper = if lcp.matched.is_some() {
265            // RFC F3: audit ceiling is the block UPPER bound (j+1)·B.
266            (lcp.matched_blocks as u64 + 1) * chain.block_size as u64
267        } else {
268            0
269        };
270        let input = lcp.matched.map(|_| LcpInput {
271            lcp_bytes: lcp.lcp_bytes,
272            lcp_upper_bytes: upper,
273            same_session,
274            matched_exists: true,
275        });
276        (input, lcp.lcp_bytes, upper, lcp.candidate_expired)
277    }
278
279    /// Build and record the trace for a completed call.
280    fn record(&self, usage: &Usage, error: Option<String>, request_id: Option<String>) {
281        let spec = verdict_matrix::for_provider(&self.provider, &self.model);
282        let fp =
283            BlockChainFingerprint::new(super::fingerprint::DEFAULT_BLOCK_SIZE, self.scope_salt);
284        let (fingerprint, chain) = match self.denoised_bytes() {
285            Some(bytes) => {
286                let c = fp.compute(&bytes);
287                (Fingerprint::from(&c), c)
288            }
289            None => (
290                Fingerprint {
291                    body_hash: String::new(),
292                    len_bytes: 0,
293                    block_size: 0,
294                    block_hashes: Vec::new(),
295                    token_estimate: 0,
296                },
297                Chain {
298                    body_hash: 0,
299                    len_bytes: 0,
300                    block_size: super::fingerprint::DEFAULT_BLOCK_SIZE,
301                    block_hashes: Vec::new(),
302                },
303            ),
304        };
305
306        let (lcp_input, lcp_bytes, lcp_upper_bytes, candidate_expired) =
307            if error.is_none() && !chain.block_hashes.is_empty() {
308                let (li, lb, lu, ce) = self.lookup_lcp(&chain, spec.ttl_ms);
309                (li, lb, lu, ce)
310            } else {
311                (None, 0, 0, false)
312            };
313
314        let sid = self
315            .ctx
316            .session_id
317            .clone()
318            .unwrap_or_else(|| format!("<scope:{}>", self.ctx.scope_key));
319        let session_stats = self.session_stats_for(&sid, lcp_bytes, fingerprint.len_bytes);
320
321        let verdict = if let Some(auditor) = &self.auditor {
322            let usage_present = usage.input_tokens.total.is_some();
323            let prompt_tokens = usage.input_tokens.total.unwrap_or(0) as u64;
324            let claimed = usage.input_tokens.cache_read.unwrap_or(0) as u64;
325            let inp = JudgmentInput {
326                spec,
327                strict: self.strict,
328                input_no_cache: usage.input_tokens.no_cache.map(u64::from),
329                input_cache_read: usage.input_tokens.cache_read.map(u64::from),
330                input_cache_write: usage.input_tokens.cache_write.map(u64::from),
331                first: self
332                    .ring_store()
333                    .map(|s| s.records_in_scope(self.ctx.scope_key) == 0)
334                    .unwrap_or(true),
335                prompt_tokens,
336                prompt_bytes: fingerprint.len_bytes,
337                claimed,
338                write: usage.input_tokens.cache_write.map(u64::from),
339                no_cache: usage.input_tokens.no_cache.map(u64::from),
340                hit: usage
341                    .raw
342                    .as_ref()
343                    .and_then(|r| r.get("prompt_cache_hit_tokens"))
344                    .and_then(serde_json::Value::as_u64),
345                miss: usage
346                    .raw
347                    .as_ref()
348                    .and_then(|r| r.get("prompt_cache_miss_tokens"))
349                    .and_then(serde_json::Value::as_u64),
350                usage_present,
351                response_cache_header_hit: self
352                    .response_headers
353                    .as_ref()
354                    .map(|h| {
355                        h.iter().any(|(k, v)| {
356                            k.to_ascii_lowercase().contains("cache-status")
357                                && v.to_ascii_uppercase().contains("HIT")
358                        })
359                    })
360                    .unwrap_or(false),
361                candidate_expired,
362                byte_proxy: true, // no tokenizer attached — byte proxy len/4
363                // The layer cannot know the deployment topology; route
364                // affinity is only guaranteed when the consumer opts in
365                // (single node / sticky / global cache).
366                route_affinity_known: false,
367                lcp: lcp_input,
368                system_tokens: 0, // semantic segments are not visible here
369                session_stats: Some(session_stats),
370            };
371            Some(auditor.judge(&inp))
372        } else {
373            None
374        };
375
376        let ttft_ms = self.ttft_ms.load(Ordering::Relaxed);
377        let ttft_ms = if ttft_ms == u64::MAX {
378            None
379        } else {
380            Some(ttft_ms)
381        };
382
383        // Client-side LCP token upper bound (for aggregation; None when no
384        // history matched). Derived from the block upper bound.
385        let lcp_token_upper = if lcp_upper_bytes > 0 {
386            Some(
387                (lcp_upper_bytes.min(fingerprint.len_bytes) / 4)
388                    .min(usage.input_tokens.total.unwrap_or(0) as u64),
389            )
390        } else {
391            None
392        };
393
394        let rec = TraceRecord {
395            provider: self.provider.clone(),
396            model: self.model.clone(),
397            request_id,
398            session_id: self.ctx.session_id.clone(),
399            call_id: self.call_id.clone().unwrap_or_else(|| {
400                format!(
401                    "trace-{}-{}-{}",
402                    std::process::id(),
403                    self.ctx.monotonic_ms,
404                    TRACE_SEQ.fetch_add(1, Ordering::Relaxed)
405                )
406            }),
407            sent_at_unix_ms: self.ctx.sent_at_unix_ms,
408            monotonic_sent_ms: self.ctx.monotonic_ms,
409            lcp_token_upper,
410            ttft_ms,
411            fingerprint,
412            usage: Self::usage_snapshot(usage),
413            response_cache_headers: self.response_headers.clone(),
414            request_cache_hints: None,
415            verdict,
416            error,
417            scope_key: self.ctx.scope_key,
418        };
419        self.sink.record(rec);
420    }
421}
422
423/// Drop-guard stream wrapper (F1): ensures the trace is recorded even when the
424/// caller drops the stream before completion (`take(N)` / abort / timeout).
425/// Mirrors `RecordingOutcomeStream`'s Drop pattern (recording.rs): the previous
426/// generator-based record only fired when the stream ran to completion, so
427/// early drops silently lost the call. This wrapper records on natural EOF, on
428/// a terminal part (`Finish` / `Error` / transport `Err`), or — via `Drop` —
429/// when the caller abandons the stream mid-flight.
430struct TraceRecordingStream<S> {
431    inner: S,
432    rec_ctx: Option<Arc<RecordCtx>>,
433    /// Accumulated usage (`Finish`) / error (`Error` / `Err`) observed so far.
434    usage: Usage,
435    error: Option<String>,
436    /// Whether the terminal record has been emitted (idempotent guard).
437    recorded: bool,
438}
439
440impl<S> TraceRecordingStream<S> {
441    /// Emit the trace record exactly once (idempotent across EOF / terminal /
442    /// Drop). `rec_ctx` is consumed so a later `Drop` is a no-op.
443    fn record_now(&mut self, usage: Usage, error: Option<String>) {
444        if self.recorded {
445            return;
446        }
447        self.recorded = true;
448        if let Some(ctx) = self.rec_ctx.take() {
449            ctx.record(&usage, error, None);
450        }
451    }
452}
453
454impl<S> Stream for TraceRecordingStream<S>
455where
456    S: Stream<Item = Result<StreamPart, AiMuxError>> + Unpin,
457{
458    type Item = Result<StreamPart, AiMuxError>;
459
460    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
461        let this = self.get_mut();
462        match Pin::new(&mut this.inner).poll_next(cx) {
463            Poll::Ready(Some(Ok(part))) => {
464                // Accumulate terminal evidence (mirrors the original
465                // end-of-stream record).
466                match &part {
467                    StreamPart::Finish { usage: u, .. } => this.usage = u.clone(),
468                    StreamPart::Error { error: e } => {
469                        this.error.get_or_insert_with(|| e.to_string());
470                    }
471                    _ => {}
472                }
473                Poll::Ready(Some(Ok(part)))
474            }
475            Poll::Ready(Some(Err(e))) => {
476                this.error.get_or_insert_with(|| e.to_string());
477                Poll::Ready(Some(Err(e)))
478            }
479            Poll::Ready(None) => {
480                // Natural end: record accumulated state (matches the original).
481                let usage = std::mem::take(&mut this.usage);
482                let error = this.error.take();
483                this.record_now(usage, error);
484                Poll::Ready(None)
485            }
486            Poll::Pending => Poll::Pending,
487        }
488    }
489}
490
491impl<S> Drop for TraceRecordingStream<S> {
492    fn drop(&mut self) {
493        if !self.recorded {
494            // Caller dropped before completion — record what was observed with
495            // a non-success marker so the call is not silently lost (F1).
496            let usage = std::mem::take(&mut self.usage);
497            let error = self
498                .error
499                .take()
500                .or_else(|| Some("stream dropped before completion".into()));
501            self.record_now(usage, error);
502        }
503    }
504}
505
506#[async_trait]
507impl LanguageModel for TraceLayer {
508    fn specification_version(&self) -> &'static str {
509        "v4"
510    }
511
512    fn provider(&self) -> &str {
513        self.inner.provider()
514    }
515
516    fn model_id(&self) -> &str {
517        self.inner.model_id()
518    }
519
520    fn retry_config(&self) -> crate::retry::RetryConfig {
521        self.inner.retry_config()
522    }
523
524    /// RFC-0023 §3.3: transparent decorators must forward the inner snapshot
525    /// (otherwise recording sees the decorator's minimal record).
526    fn config_snapshot(&self) -> crate::recording::ProviderRecord {
527        self.inner.config_snapshot()
528    }
529
530    async fn do_generate(&self, options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
531        let ctx = self.make_record_ctx(options, None, None, Arc::new(AtomicU64::new(u64::MAX)));
532        match self.inner.do_generate(options).await {
533            Ok(result) => {
534                let request_id = result.response.id.clone();
535                let mut rec_ctx = ctx;
536                rec_ctx.request_body = result.request_body.clone();
537                rec_ctx.response_headers = result.response_headers.clone();
538                rec_ctx.record(&result.usage, None, request_id);
539                Ok(result)
540            }
541            Err(e) => {
542                // Failures are part of the session too.
543                ctx.record(&Usage::default(), Some(e.to_string()), None);
544                Err(e)
545            }
546        }
547    }
548
549    async fn do_stream(&self, options: &CallOptions) -> Result<StreamResult, AiMuxError> {
550        let ttft = Arc::new(AtomicU64::new(u64::MAX));
551        let ctx = self.make_record_ctx(options, None, None, ttft.clone());
552        let result = match self.inner.do_stream(options).await {
553            Ok(r) => r,
554            Err(e) => {
555                ctx.record(&Usage::default(), Some(e.to_string()), None);
556                return Err(e);
557            }
558        };
559
560        let rec_ctx = Arc::new(RecordCtx {
561            request_body: result.request_body.clone(),
562            response_headers: result.response_headers.clone(),
563            ..ctx
564        });
565        let ttft_obs = ttft.clone();
566        let started = Instant::now();
567
568        // Wrap the stream: observe TTFT on the first MODEL-OUTPUT part
569        // (TextDelta / Reasoning / ToolCall — not StreamStart or meta).
570        let observed = result.stream.map(move |item| {
571            if ttft_obs.load(Ordering::Relaxed) == u64::MAX {
572                let is_model_output = matches!(
573                    &item,
574                    Ok(StreamPart::TextDelta { .. })
575                        | Ok(StreamPart::ReasoningDelta { .. })
576                        | Ok(StreamPart::ToolCall { .. })
577                );
578                if is_model_output {
579                    ttft_obs.store(started.elapsed().as_millis() as u64, Ordering::Relaxed);
580                }
581            }
582            item
583        });
584
585        // F1: wrap with a Drop-guard stream so the trace is recorded even when
586        // the caller drops the stream before completion (take(N) / abort /
587        // timeout). The generator-only record previously lost such calls.
588        let guarded = TraceRecordingStream {
589            inner: observed,
590            rec_ctx: Some(rec_ctx),
591            usage: Usage::default(),
592            error: None,
593            recorded: false,
594        };
595
596        Ok(StreamResult {
597            stream: Box::pin(guarded),
598            request_body: result.request_body,
599            response_headers: result.response_headers,
600        })
601    }
602}