Skip to main content

aimux_core/trace/
record.rs

1//! TraceRecord data model (RFC-0015 §5.1): request identity + fingerprint +
2//! usage snapshot + verdict. Fully owned, `Clone`, `Send + Sync`. Plaintext
3//! bodies never enter a trace — only the hashed fingerprint does.
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use ts_rs::TS;
10
11use super::fingerprint::Fingerprint;
12use super::verdict::Verdict;
13
14/// Token usage snapshot (7 flat fields + raw passthrough).
15#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
16#[ts(export)]
17pub struct UsageSnapshot {
18    pub input_total: Option<u64>,
19    pub input_no_cache: Option<u64>,
20    pub cache_read: Option<u64>,
21    pub cache_write: Option<u64>,
22    pub output_total: Option<u64>,
23    pub output_text: Option<u64>,
24    pub output_reasoning: Option<u64>,
25    /// Raw provider usage payload (opaque passthrough; numbers only — no
26    /// prompt text).
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub raw: Option<Value>,
29}
30
31/// Request-side cache hints (best-effort, e.g. Anthropic `cache_control`).
32#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
33#[ts(export)]
34pub struct RequestCacheHints {
35    /// Whether any message requested `cache_control` (write) this call.
36    pub requested_write: bool,
37}
38
39/// One probed call. Plaintext never persists — fingerprints only.
40#[derive(Debug, Clone, Serialize, Deserialize, TS)]
41#[ts(export)]
42pub struct TraceRecord {
43    pub provider: String,
44    pub model: String,
45    pub request_id: Option<String>,
46    /// From `CallOptions.session_id` (RFC-0024) — explicit first, inference
47    /// fallback; may also be the layer's default session.
48    pub session_id: Option<String>,
49    /// Call-level unique id (association key for Recording / replay,
50    /// RFC-0023).
51    pub call_id: String,
52    /// When the call was sent (epoch ms).
53    #[ts(type = "number")]
54    pub sent_at_unix_ms: i64,
55    /// Monotonic clock ms (same domain as the store's TTL lookups; internal
56    /// bookkeeping, not part of the wire contract).
57    #[serde(skip)]
58    #[ts(skip)]
59    pub monotonic_sent_ms: u64,
60    /// Client-side LCP token upper bound (block upper bound, byte-proxy
61    /// len/4). `None` when no history matched. Consumed by `TraceStats`
62    /// aggregation.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub lcp_token_upper: Option<u64>,
65    /// Time to first streamed token (ms). Non-streaming: `None`.
66    #[ts(type = "number | null")]
67    pub ttft_ms: Option<u64>,
68    pub fingerprint: Fingerprint,
69    pub usage: UsageSnapshot,
70    /// Response-side cache headers (e.g. `x-openrouter-cache-status`).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub response_cache_headers: Option<HashMap<String, String>>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub request_cache_hints: Option<RequestCacheHints>,
75    /// Audit verdict (`None` when no auditor is attached — the default).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub verdict: Option<Verdict>,
78    /// Error string when the call failed (still recorded: failures are part
79    /// of the session).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub error: Option<String>,
82    /// Internal scope key (index only; never crosses the wire contract).
83    #[serde(skip)]
84    #[ts(skip)]
85    pub scope_key: u64,
86}
87
88impl TraceRecord {
89    /// Reported hit rate — strictly `cache_read / input_total` (RFC-0015
90    /// §5.2; never added to `cache_write`).
91    #[must_use]
92    pub fn reported_hit_rate(&self) -> Option<f64> {
93        match (self.usage.cache_read, self.usage.input_total) {
94            (Some(r), Some(t)) if t > 0 => Some(r as f64 / t as f64),
95            _ => None,
96        }
97    }
98
99    /// Client-side upper bound hit rate — token estimate / input_total.
100    #[must_use]
101    pub fn client_upper_bound_hit_rate(&self) -> Option<f64> {
102        let t = self.usage.input_total?;
103        if t == 0 {
104            return None;
105        }
106        Some(self.fingerprint.token_estimate.min(t) as f64 / t as f64)
107    }
108}