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 provider: Option<String>,
133    pub model: Option<String>,
134    pub input_tokens: Option<i32>,
135    pub output_tokens: Option<i32>,
136    pub cache_read_tokens: Option<i32>,
137    pub cache_creation_tokens: Option<i32>,
138    pub reasoning_tokens: Option<i32>,
139    pub cost_microdollars: i64,
140    pub latency_ms: Option<i32>,
141    pub status: String,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct AiRequestDetail {
146    pub id: AiRequestId,
147    pub user_id: UserId,
148    pub actor_kind: String,
149    pub actor_id: String,
150    pub provider: Option<String>,
151    pub model: Option<String>,
152    pub input_tokens: Option<i32>,
153    pub output_tokens: Option<i32>,
154    pub cost_microdollars: i64,
155    pub latency_ms: Option<i32>,
156    pub status: String,
157    pub error_message: Option<String>,
158}
159
160#[derive(Debug, Clone, Default, Serialize, Deserialize)]
161pub struct AiRequestStats {
162    pub total_requests: i64,
163    pub total_input_tokens: i64,
164    pub total_output_tokens: i64,
165    pub total_cost_microdollars: i64,
166    pub avg_latency_ms: i64,
167    pub by_provider: Vec<ProviderStatsRow>,
168    pub by_model: Vec<ModelStatsRow>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ProviderStatsRow {
173    pub provider: String,
174    pub request_count: i64,
175    pub total_tokens: i64,
176    pub total_cost_microdollars: i64,
177    pub avg_latency_ms: i64,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ModelStatsRow {
182    pub model: String,
183    pub provider: String,
184    pub request_count: i64,
185    pub total_tokens: i64,
186    pub total_cost_microdollars: i64,
187    pub avg_latency_ms: i64,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AiRequestInfo {
192    pub id: AiRequestId,
193    pub provider: Option<String>,
194    pub model: Option<String>,
195    pub max_tokens: Option<i32>,
196    pub input_tokens: Option<i32>,
197    pub output_tokens: Option<i32>,
198    pub cost_microdollars: i64,
199    pub latency_ms: Option<i32>,
200}
201
202/// A slice of a request's audit rows: `offset` rows in, at most `limit` rows,
203/// in sequence order. A `limit` of zero means "all".
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub struct AuditPage {
206    pub offset: i64,
207    pub limit: i64,
208}
209
210impl AuditPage {
211    pub const ALL: Self = Self {
212        offset: 0,
213        limit: 0,
214    };
215
216    pub const fn sql_limit(self) -> Option<i64> {
217        if self.limit > 0 {
218            Some(self.limit)
219        } else {
220            None
221        }
222    }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ConversationMessage {
227    pub role: String,
228    pub content: String,
229    pub sequence_number: i32,
230}