1use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex, OnceLock};
11use std::time::{Instant, SystemTime, UNIX_EPOCH};
12
13static TRACE_SEQ: AtomicU64 = AtomicU64::new(0);
15
16fn 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
44pub trait CacheAuditor: Send + Sync {
46 fn judge(&self, input: &JudgmentInput) -> Verdict;
47}
48
49pub struct RuleAuditor;
52
53impl CacheAuditor for RuleAuditor {
54 fn judge(&self, input: &JudgmentInput) -> Verdict {
55 rule_judge(input)
56 }
57}
58
59#[derive(Default)]
61struct SessionTracker {
62 rounds: u32,
63 prev_body_len: u64,
64}
65
66pub struct TraceLayer {
69 inner: Arc<dyn LanguageModel>,
70 sink: Arc<dyn TraceSink>,
71 auditor: Option<Arc<dyn CacheAuditor>>,
72 strict: bool,
75 default_session: Option<String>,
77 scope_salt: u64,
79 session_tracker: Arc<Mutex<HashMap<String, SessionTracker>>>,
80}
81
82struct RequestCtx {
84 session_id: Option<String>,
85 scope_key: u64,
86 sent_at_unix_ms: i64,
87 monotonic_ms: u64,
89}
90
91struct 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 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 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 #[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 #[must_use]
142 pub fn with_auditor(mut self, auditor: Arc<dyn CacheAuditor>) -> Self {
143 self.auditor = Some(auditor);
144 self
145 }
146
147 #[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 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 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 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 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 (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 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, route_affinity_known: false,
367 lcp: lcp_input,
368 system_tokens: 0, 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 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
423struct TraceRecordingStream<S> {
431 inner: S,
432 rec_ctx: Option<Arc<RecordCtx>>,
433 usage: Usage,
435 error: Option<String>,
436 recorded: bool,
438}
439
440impl<S> TraceRecordingStream<S> {
441 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 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 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 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 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 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 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 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}