Skip to main content

systemprompt_runtime/trace/models/
ai.rs

1//! AI request DTOs: filters, list views, detail rows, aggregate stats, and
2//! conversation messages.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use systemprompt_identifiers::{AiRequestId, TraceId, UserId};
10
11#[derive(Debug, Clone)]
12pub struct AiRequestFilter {
13    pub limit: i64,
14    pub since: Option<DateTime<Utc>>,
15    pub until: Option<DateTime<Utc>>,
16    pub before: Option<RequestCursor>,
17    pub model: Option<String>,
18    pub provider: Option<String>,
19    pub user: Option<String>,
20    pub billing_source: Option<String>,
21}
22
23impl AiRequestFilter {
24    pub const fn new(limit: i64) -> Self {
25        Self {
26            limit,
27            since: None,
28            until: None,
29            before: None,
30            model: None,
31            provider: None,
32            user: None,
33            billing_source: None,
34        }
35    }
36
37    pub const fn with_since(mut self, since: DateTime<Utc>) -> Self {
38        self.since = Some(since);
39        self
40    }
41
42    pub const fn with_until(mut self, until: DateTime<Utc>) -> Self {
43        self.until = Some(until);
44        self
45    }
46
47    pub fn with_before(mut self, before: RequestCursor) -> Self {
48        self.before = Some(before);
49        self
50    }
51
52    systemprompt_models::builder_methods! {
53        with_model(model) -> String,
54        with_provider(provider) -> String,
55        with_user(user) -> String,
56        with_billing_source(billing_source) -> String,
57    }
58}
59
60/// Keyset position for paging `list_ai_requests` past its newest-first page:
61/// rows strictly older than `(created_at, id)` in the list's own sort order.
62///
63/// The wire form is `<created_at RFC3339>@<request_id>`, which a caller
64/// derives from the last row of the page it just received. `@` because the
65/// remote CLI gateway refuses shell metacharacters (`|`, `;`, `&`, …) in
66/// arguments, and a cursor has to survive that path.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct RequestCursor {
69    pub created_at: DateTime<Utc>,
70    pub id: AiRequestId,
71}
72
73impl RequestCursor {
74    pub const SEPARATOR: char = '@';
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
78pub enum RequestCursorError {
79    #[error("cursor is missing the `@` between the timestamp and the request id")]
80    MissingSeparator,
81    #[error("cursor has an empty request id")]
82    EmptyId,
83    #[error("cursor timestamp `{stamp}` is not RFC 3339: {source}")]
84    InvalidTimestamp {
85        stamp: String,
86        source: chrono::ParseError,
87    },
88}
89
90impl std::str::FromStr for RequestCursor {
91    type Err = RequestCursorError;
92
93    fn from_str(raw: &str) -> Result<Self, Self::Err> {
94        let (stamp, id) = raw
95            .trim()
96            .split_once(Self::SEPARATOR)
97            .ok_or(RequestCursorError::MissingSeparator)?;
98        if id.is_empty() {
99            return Err(RequestCursorError::EmptyId);
100        }
101        let created_at = DateTime::parse_from_rfc3339(stamp)
102            .map_err(|source| RequestCursorError::InvalidTimestamp {
103                stamp: stamp.to_owned(),
104                source,
105            })?
106            .with_timezone(&Utc);
107        Ok(Self {
108            created_at,
109            id: AiRequestId::new(id),
110        })
111    }
112}
113
114impl std::fmt::Display for RequestCursor {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        write!(
117            f,
118            "{}{}{}",
119            self.created_at
120                .to_rfc3339_opts(chrono::SecondsFormat::Micros, true),
121            Self::SEPARATOR,
122            self.id.as_str()
123        )
124    }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct AiRequestListItem {
129    pub id: AiRequestId,
130    pub created_at: DateTime<Utc>,
131    pub trace_id: Option<TraceId>,
132    pub user_id: UserId,
133    pub actor_kind: String,
134    pub actor_id: String,
135    pub client_kind: String,
136    pub client_attestation: String,
137    pub provider: Option<String>,
138    pub model: Option<String>,
139    pub input_tokens: Option<i32>,
140    pub output_tokens: Option<i32>,
141    pub cache_read_tokens: Option<i32>,
142    pub cache_creation_tokens: Option<i32>,
143    pub reasoning_tokens: Option<i32>,
144    pub cost_microdollars: i64,
145    pub latency_ms: Option<i32>,
146    pub status: String,
147    pub finish_reason: Option<String>,
148    pub billing_source: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct AiRequestDetail {
153    pub id: AiRequestId,
154    pub user_id: UserId,
155    pub actor_kind: String,
156    pub actor_id: String,
157    pub provider: Option<String>,
158    pub model: Option<String>,
159    pub input_tokens: Option<i32>,
160    pub output_tokens: Option<i32>,
161    pub cost_microdollars: i64,
162    pub latency_ms: Option<i32>,
163    pub status: String,
164    pub error_message: Option<String>,
165    pub client_kind: String,
166    pub client_attestation: String,
167    pub client_evidence: Option<AiRequestClientEvidence>,
168}
169
170/// The `ai_request_client_evidence` row as stored; strings are the column
171/// values, so a reader that wants the closed enums parses them.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct AiRequestClientEvidence {
174    pub kind_source: String,
175    pub attested_host: Option<String>,
176    pub declared_client: Option<String>,
177    pub native_marker: Option<String>,
178    pub ua_product: Option<String>,
179    pub ua_version: Option<String>,
180    pub sdk_lang: Option<String>,
181    pub sdk_package_version: Option<String>,
182    pub sdk_runtime: Option<String>,
183    pub sdk_runtime_version: Option<String>,
184    pub sdk_os: Option<String>,
185    pub sdk_arch: Option<String>,
186}
187
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct AiRequestStats {
190    pub total_requests: i64,
191    pub total_input_tokens: i64,
192    pub total_output_tokens: i64,
193    pub total_cost_microdollars: i64,
194    pub avg_latency_ms: i64,
195    pub by_provider: Vec<ProviderStatsRow>,
196    pub by_model: Vec<ModelStatsRow>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ProviderStatsRow {
201    pub provider: String,
202    pub request_count: i64,
203    pub total_tokens: i64,
204    pub total_cost_microdollars: i64,
205    pub avg_latency_ms: i64,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ModelStatsRow {
210    pub model: String,
211    pub provider: String,
212    pub request_count: i64,
213    pub total_tokens: i64,
214    pub total_cost_microdollars: i64,
215    pub avg_latency_ms: i64,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct AiRequestInfo {
220    pub id: AiRequestId,
221    pub provider: Option<String>,
222    pub model: Option<String>,
223    pub max_tokens: Option<i32>,
224    pub input_tokens: Option<i32>,
225    pub output_tokens: Option<i32>,
226    pub cost_microdollars: i64,
227    pub latency_ms: Option<i32>,
228}
229
230/// A slice of a request's audit rows: `offset` rows in, at most `limit` rows,
231/// in sequence order. A `limit` of zero means "all".
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub struct AuditPage {
234    pub offset: i64,
235    pub limit: i64,
236}
237
238impl AuditPage {
239    pub const ALL: Self = Self {
240        offset: 0,
241        limit: 0,
242    };
243
244    pub const fn sql_limit(self) -> Option<i64> {
245        if self.limit > 0 {
246            Some(self.limit)
247        } else {
248            None
249        }
250    }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct ConversationMessage {
255    pub role: String,
256    pub content: String,
257    pub sequence_number: i32,
258}