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