1use std::sync::Arc;
14
15use axum::extract::{Query, State};
16use axum::http::StatusCode;
17use axum::response::{IntoResponse, Json, Response};
18use deadpool_postgres::Pool;
19use serde::{Deserialize, Serialize};
20
21const PROJECTION_MONTH_DAYS: f64 = 30.0;
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27pub struct UsageBreakdownRow {
28 pub person: String,
29 pub project: String,
30 pub model: String,
31 pub provider: String,
32 pub requests: i64,
33 pub input_tokens: i64,
34 pub output_tokens: i64,
35 pub cost_usd: f64,
36 pub saved_tokens: i64,
37 pub saved_usd: f64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct UsageTotals {
43 pub requests: i64,
44 pub cost_usd: f64,
45 pub saved_usd: f64,
46 pub reference_cost_usd: f64,
49 pub active_persons: i64,
51 #[serde(skip_serializing_if = "Option::is_none")]
57 pub projection_seats: Option<u32>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub projection_usd_per_month: Option<f64>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct UsageBreakdownResponse {
65 pub from: String,
66 pub to: String,
67 pub rows: Vec<UsageBreakdownRow>,
68 pub totals: UsageTotals,
69}
70
71#[derive(Debug, Clone, Deserialize)]
73pub struct UsageQuery {
74 pub from: Option<String>,
75 pub to: Option<String>,
76}
77
78#[derive(Clone)]
81pub struct AdminState {
82 pub pool: Pool,
83 pub seats: Option<u32>,
85 pub org_label: Option<String>,
87 pub started_at: std::time::Instant,
89 pub providers: Vec<super::admin_status::ProviderStatus>,
91 pub routing_enabled: bool,
93 pub routing_aliases: std::collections::BTreeMap<String, String>,
96 pub reference_model: Option<String>,
98 pub local_shadow_rate: f64,
100}
101
102pub fn router(state: AdminState) -> axum::Router {
104 axum::Router::new()
105 .route("/api/admin/usage", axum::routing::get(get_usage))
106 .route(
107 "/api/admin/timeseries",
108 axum::routing::get(super::admin_timeseries::get_timeseries),
109 )
110 .route(
111 "/api/admin/status",
112 axum::routing::get(super::admin_status::get_status),
113 )
114 .route("/api/admin/evidence", axum::routing::get(get_evidence))
115 .with_state(Arc::new(state))
116}
117
118async fn get_evidence(
122 State(state): State<Arc<AdminState>>,
123 Query(q): Query<UsageQuery>,
124) -> Response {
125 let (from, to) = match resolve_window(q.from.as_deref(), q.to.as_deref()) {
126 Ok(w) => w,
127 Err(msg) => {
128 return (
129 StatusCode::BAD_REQUEST,
130 Json(serde_json::json!({"error": msg})),
131 )
132 .into_response();
133 }
134 };
135 match super::evidence::generate(&state.pool, from, to).await {
136 Ok(artifact) => (
137 StatusCode::OK,
138 [(
139 axum::http::header::CONTENT_DISPOSITION,
140 "attachment; filename=\"leanctx-evidence.json\"",
141 )],
142 Json(serde_json::to_value(&artifact).unwrap_or_default()),
143 )
144 .into_response(),
145 Err(e) => {
146 tracing::warn!("evidence export failed: {e:#}");
147 (
148 StatusCode::BAD_GATEWAY,
149 Json(serde_json::json!({"error": "evidence export failed — see gateway logs"})),
150 )
151 .into_response()
152 }
153 }
154}
155
156const USAGE_BREAKDOWN_SQL: &str = "
159SELECT person, project, model, provider,
160 count(*) AS requests,
161 sum(input_tokens)::BIGINT AS input_tokens,
162 sum(output_tokens)::BIGINT AS output_tokens,
163 sum(cost_usd) AS cost_usd,
164 sum(saved_tokens)::BIGINT AS saved_tokens,
165 sum(saved_usd) AS saved_usd
166FROM usage_events
167WHERE ts >= $1 AND ts <= $2
168GROUP BY person, project, model, provider
169ORDER BY cost_usd DESC";
170
171const USAGE_TOTALS_SQL: &str = "
172SELECT count(*) AS requests,
173 coalesce(sum(cost_usd), 0) AS cost_usd,
174 coalesce(sum(saved_usd), 0) AS saved_usd,
175 coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd,
176 count(DISTINCT person) AS active_persons
177FROM usage_events
178WHERE ts >= $1 AND ts <= $2";
179
180async fn get_usage(State(state): State<Arc<AdminState>>, Query(q): Query<UsageQuery>) -> Response {
181 let (from, to) = match resolve_window(q.from.as_deref(), q.to.as_deref()) {
182 Ok(w) => w,
183 Err(msg) => {
184 return (
185 StatusCode::BAD_REQUEST,
186 Json(serde_json::json!({"error": msg})),
187 )
188 .into_response();
189 }
190 };
191
192 match usage_breakdown(&state.pool, from, to, state.seats).await {
193 Ok(resp) => Json(resp).into_response(),
194 Err(e) => {
195 tracing::warn!("admin usage query failed: {e:#}");
196 (
197 StatusCode::SERVICE_UNAVAILABLE,
198 Json(serde_json::json!({"error": "usage store unavailable"})),
199 )
200 .into_response()
201 }
202 }
203}
204
205pub(super) fn resolve_window(
208 from: Option<&str>,
209 to: Option<&str>,
210) -> Result<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>), String> {
211 let parse = |s: &str, which: &str| {
212 chrono::DateTime::parse_from_rfc3339(s)
213 .map(|d| d.with_timezone(&chrono::Utc))
214 .map_err(|e| format!("invalid `{which}` timestamp (RFC 3339 expected): {e}"))
215 };
216 let to_ts = match to {
217 Some(s) => parse(s, "to")?,
218 None => chrono::Utc::now(),
219 };
220 let from_ts = match from {
221 Some(s) => parse(s, "from")?,
222 None => to_ts - chrono::Duration::days(30),
223 };
224 if from_ts > to_ts {
225 return Err("`from` must not be after `to`".into());
226 }
227 Ok((from_ts, to_ts))
228}
229
230pub async fn usage_breakdown(
235 pool: &Pool,
236 from: chrono::DateTime<chrono::Utc>,
237 to: chrono::DateTime<chrono::Utc>,
238 seats: Option<u32>,
239) -> anyhow::Result<UsageBreakdownResponse> {
240 let client = pool.get().await?;
241
242 let rows = client
243 .query(USAGE_BREAKDOWN_SQL, &[&from, &to])
244 .await?
245 .iter()
246 .map(|r| UsageBreakdownRow {
247 person: r.get("person"),
248 project: r.get("project"),
249 model: r.get("model"),
250 provider: r.get("provider"),
251 requests: r.get("requests"),
252 input_tokens: r.get("input_tokens"),
253 output_tokens: r.get("output_tokens"),
254 cost_usd: r.get("cost_usd"),
255 saved_tokens: r.get("saved_tokens"),
256 saved_usd: r.get("saved_usd"),
257 })
258 .collect();
259
260 let t = client.query_one(USAGE_TOTALS_SQL, &[&from, &to]).await?;
261 let totals = build_totals(
262 t.get("requests"),
263 t.get("cost_usd"),
264 t.get("saved_usd"),
265 t.get("reference_cost_usd"),
266 t.get("active_persons"),
267 seats,
268 to - from,
269 );
270
271 Ok(UsageBreakdownResponse {
272 from: from.to_rfc3339(),
273 to: to.to_rfc3339(),
274 rows,
275 totals,
276 })
277}
278
279fn build_totals(
282 requests: i64,
283 cost_usd: f64,
284 saved_usd: f64,
285 reference_cost_usd: f64,
286 active_persons: i64,
287 seats: Option<u32>,
288 window: chrono::Duration,
289) -> UsageTotals {
290 let window_days = window.num_seconds() as f64 / 86_400.0;
291 let projection = seats
292 .filter(|_| active_persons > 0 && window_days > 0.0)
293 .map(|s| {
294 #[allow(clippy::cast_precision_loss)]
295 let per_person_per_month =
296 saved_usd / active_persons as f64 / window_days * PROJECTION_MONTH_DAYS;
297 per_person_per_month * f64::from(s)
298 });
299 UsageTotals {
300 requests,
301 cost_usd,
302 saved_usd,
303 reference_cost_usd,
304 active_persons,
305 projection_seats: seats.filter(|_| projection.is_some()),
306 projection_usd_per_month: projection,
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn projection_scales_per_person_savings_to_seats_and_month() {
316 let t = build_totals(
319 1_000,
320 2_000.0,
321 500.0,
322 3_000.0,
323 10,
324 Some(800),
325 chrono::Duration::days(15),
326 );
327 assert_eq!(t.projection_seats, Some(800));
328 let p = t.projection_usd_per_month.expect("projection");
329 assert!((p - 80_000.0).abs() < 1e-6, "got {p}");
330 }
331
332 #[test]
333 fn projection_absent_without_seats_or_activity() {
334 let t = build_totals(10, 1.0, 1.0, 0.0, 5, None, chrono::Duration::days(30));
336 assert_eq!(t.projection_usd_per_month, None);
337 assert_eq!(t.projection_seats, None);
338 let t = build_totals(0, 0.0, 0.0, 0.0, 0, Some(800), chrono::Duration::days(30));
340 assert_eq!(t.projection_usd_per_month, None);
341 assert_eq!(t.projection_seats, None, "seats hidden when unusable");
342 }
343
344 #[test]
345 fn window_defaults_and_validation() {
346 let (from, to) = resolve_window(None, None).expect("default window");
347 assert!((to - from).num_days() == 30);
348
349 let (from, to) = resolve_window(Some("2026-07-01T00:00:00Z"), Some("2026-07-31T23:59:59Z"))
350 .expect("explicit window");
351 assert_eq!(from.to_rfc3339(), "2026-07-01T00:00:00+00:00");
352 assert!(to > from);
353
354 assert!(resolve_window(Some("not-a-date"), None).is_err());
355 assert!(
356 resolve_window(Some("2026-08-01T00:00:00Z"), Some("2026-07-01T00:00:00Z")).is_err(),
357 "inverted window must be rejected"
358 );
359 }
360
361 #[test]
362 fn response_serializes_stably() {
363 let resp = UsageBreakdownResponse {
365 from: "2026-07-01T00:00:00+00:00".into(),
366 to: "2026-07-31T23:59:59+00:00".into(),
367 rows: vec![UsageBreakdownRow {
368 person: "alice@example.com".into(),
369 project: "billing".into(),
370 model: "claude-sonnet-4-5".into(),
371 provider: "Anthropic".into(),
372 requests: 1240,
373 input_tokens: 9_000_000,
374 output_tokens: 480_000,
375 cost_usd: 312.40,
376 saved_tokens: 3_100_000,
377 saved_usd: 210.11,
378 }],
379 totals: build_totals(
380 1240,
381 312.40,
382 210.11,
383 522.51,
384 1,
385 Some(800),
386 chrono::Duration::days(30),
387 ),
388 };
389 let json = serde_json::to_value(&resp).expect("serializes");
390 assert_eq!(json["rows"][0]["person"], "alice@example.com");
391 assert_eq!(json["totals"]["active_persons"], 1);
392 assert!(json["totals"]["projection_usd_per_month"].is_f64());
393 let parsed: UsageBreakdownResponse = serde_json::from_value(json).expect("round-trips");
394 assert_eq!(parsed, resp);
395 }
396}