Skip to main content

systemprompt_analytics/repository/costs/
per_user.rs

1//! Per-user cost queries for `CostAnalyticsRepository`.
2//!
3//! Scopes spend, token, and conversation reads from `ai_requests` to a single
4//! [`UserId`], including model/agent breakdowns and recent-context summaries
5//! used for per-user billing and usage views.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use super::CostAnalyticsRepository;
11use crate::Result;
12use chrono::{DateTime, Utc};
13use systemprompt_identifiers::{ContextId, UserId};
14
15use crate::models::cli::{
16    ContextGroupRow, ContextSummaryRow, CostBreakdownRow, CostSummaryRow, PreviousCostRow,
17    RecentContextRow,
18};
19
20impl CostAnalyticsRepository {
21    pub async fn get_summary_for_user(
22        &self,
23        user_id: &UserId,
24        start: DateTime<Utc>,
25        end: DateTime<Utc>,
26    ) -> Result<CostSummaryRow> {
27        sqlx::query_as!(
28            CostSummaryRow,
29            r#"
30            SELECT
31                COUNT(*)::bigint as "requests!",
32                SUM(cost_microdollars)::bigint as "cost",
33                SUM(tokens_used)::bigint as "tokens"
34            FROM ai_requests
35            WHERE created_at >= $1 AND created_at < $2 AND user_id = $3
36            "#,
37            start,
38            end,
39            user_id.as_str()
40        )
41        .fetch_one(&*self.pool)
42        .await
43        .map_err(Into::into)
44    }
45
46    pub async fn get_previous_cost_for_user(
47        &self,
48        user_id: &UserId,
49        start: DateTime<Utc>,
50        end: DateTime<Utc>,
51    ) -> Result<PreviousCostRow> {
52        sqlx::query_as!(
53            PreviousCostRow,
54            r#"
55            SELECT SUM(cost_microdollars)::bigint as "cost"
56            FROM ai_requests
57            WHERE created_at >= $1 AND created_at < $2 AND user_id = $3
58            "#,
59            start,
60            end,
61            user_id.as_str()
62        )
63        .fetch_one(&*self.pool)
64        .await
65        .map_err(Into::into)
66    }
67
68    pub async fn get_breakdown_by_model_for_user(
69        &self,
70        user_id: &UserId,
71        start: DateTime<Utc>,
72        end: DateTime<Utc>,
73        limit: i64,
74    ) -> Result<Vec<CostBreakdownRow>> {
75        sqlx::query_as!(
76            CostBreakdownRow,
77            r#"
78            SELECT
79                model as "name!",
80                COALESCE(SUM(cost_microdollars), 0)::bigint as "cost!",
81                COUNT(*)::bigint as "requests!",
82                COALESCE(SUM(tokens_used), 0)::bigint as "tokens!"
83            FROM ai_requests
84            WHERE created_at >= $1 AND created_at < $2 AND user_id = $4
85              AND model IS NOT NULL
86            GROUP BY model
87            ORDER BY SUM(cost_microdollars) DESC NULLS LAST
88            LIMIT $3
89            "#,
90            start,
91            end,
92            limit,
93            user_id.as_str()
94        )
95        .fetch_all(&*self.pool)
96        .await
97        .map_err(Into::into)
98    }
99
100    pub async fn get_context_summary_for_user(
101        &self,
102        user_id: &UserId,
103        start: DateTime<Utc>,
104        end: DateTime<Utc>,
105    ) -> Result<ContextSummaryRow> {
106        sqlx::query_as!(
107            ContextSummaryRow,
108            r#"
109            SELECT
110                COUNT(DISTINCT context_id)::bigint as "conversations!",
111                COUNT(*)::bigint as "ai_requests!"
112            FROM ai_requests
113            WHERE created_at >= $1 AND created_at < $2
114              AND user_id = $3
115              AND context_id IS NOT NULL
116            "#,
117            start,
118            end,
119            user_id.as_str()
120        )
121        .fetch_one(&*self.pool)
122        .await
123        .map_err(Into::into)
124    }
125
126    pub async fn get_contexts_by_model_for_user(
127        &self,
128        user_id: &UserId,
129        start: DateTime<Utc>,
130        end: DateTime<Utc>,
131        limit: i64,
132    ) -> Result<Vec<ContextGroupRow>> {
133        sqlx::query_as!(
134            ContextGroupRow,
135            r#"
136            SELECT
137                model as "name!",
138                COUNT(DISTINCT context_id)::bigint as "conversations!",
139                COUNT(*)::bigint as "ai_requests!"
140            FROM ai_requests
141            WHERE created_at >= $1 AND created_at < $2
142              AND user_id = $3
143              AND context_id IS NOT NULL
144              AND model IS NOT NULL
145            GROUP BY model
146            ORDER BY COUNT(DISTINCT context_id) DESC
147            LIMIT $4
148            "#,
149            start,
150            end,
151            user_id.as_str(),
152            limit
153        )
154        .fetch_all(&*self.pool)
155        .await
156        .map_err(Into::into)
157    }
158
159    pub async fn get_contexts_by_agent_for_user(
160        &self,
161        user_id: &UserId,
162        start: DateTime<Utc>,
163        end: DateTime<Utc>,
164        limit: i64,
165    ) -> Result<Vec<ContextGroupRow>> {
166        sqlx::query_as!(
167            ContextGroupRow,
168            r#"
169            SELECT
170                COALESCE(at.agent_name, 'unattributed') as "name!",
171                COUNT(DISTINCT r.context_id)::bigint as "conversations!",
172                COUNT(*)::bigint as "ai_requests!"
173            FROM ai_requests r
174            LEFT JOIN agent_tasks at ON at.task_id = r.task_id
175            WHERE r.created_at >= $1 AND r.created_at < $2
176              AND r.user_id = $3
177              AND r.context_id IS NOT NULL
178            GROUP BY COALESCE(at.agent_name, 'unattributed')
179            ORDER BY COUNT(DISTINCT r.context_id) DESC
180            LIMIT $4
181            "#,
182            start,
183            end,
184            user_id.as_str(),
185            limit
186        )
187        .fetch_all(&*self.pool)
188        .await
189        .map_err(Into::into)
190    }
191
192    pub async fn get_recent_contexts_for_user(
193        &self,
194        user_id: &UserId,
195        end: DateTime<Utc>,
196        limit: i64,
197    ) -> Result<Vec<RecentContextRow>> {
198        sqlx::query_as!(
199            RecentContextRow,
200            r#"
201            SELECT
202                ctx.context_id as "context_id!: ContextId",
203                ctx.last_activity as "last_activity!",
204                ctx.ai_requests as "ai_requests!",
205                last_req.model,
206                last_task.agent_name
207            FROM (
208                SELECT
209                    r.context_id,
210                    MAX(r.created_at) AS last_activity,
211                    COUNT(*) AS ai_requests
212                FROM ai_requests r
213                WHERE r.user_id = $1
214                  AND r.created_at < $2
215                  AND r.context_id IS NOT NULL
216                GROUP BY r.context_id
217                ORDER BY MAX(r.created_at) DESC
218                LIMIT $3
219            ) ctx
220            LEFT JOIN LATERAL (
221                SELECT model, task_id FROM ai_requests
222                WHERE context_id = ctx.context_id
223                ORDER BY created_at DESC
224                LIMIT 1
225            ) last_req ON TRUE
226            LEFT JOIN agent_tasks last_task ON last_task.task_id = last_req.task_id
227            ORDER BY ctx.last_activity DESC
228            "#,
229            user_id.as_str(),
230            end,
231            limit
232        )
233        .fetch_all(&*self.pool)
234        .await
235        .map_err(Into::into)
236    }
237}