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 #[serde(default)]
40 pub measured_requests: i64,
41 #[serde(default)]
43 pub estimated_requests: i64,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct UsageTotals {
49 pub requests: i64,
50 pub cost_usd: f64,
51 pub saved_usd: f64,
52 pub reference_cost_usd: f64,
55 pub active_persons: i64,
57 #[serde(default)]
59 pub measured_requests: i64,
60 #[serde(default)]
62 pub estimated_requests: i64,
63 #[serde(skip_serializing_if = "Option::is_none")]
69 pub projection_seats: Option<u32>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub projection_usd_per_month: Option<f64>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct UsageBreakdownResponse {
77 pub from: String,
78 pub to: String,
79 pub rows: Vec<UsageBreakdownRow>,
80 pub totals: UsageTotals,
81}
82
83#[derive(Debug, Clone, Deserialize)]
85pub struct UsageQuery {
86 pub from: Option<String>,
87 pub to: Option<String>,
88}
89
90#[derive(Clone)]
93pub struct AdminState {
94 pub pool: Pool,
95 pub seats: Option<u32>,
97 pub org_label: Option<String>,
99 pub started_at: std::time::Instant,
101 pub providers: Vec<super::admin_status::ProviderStatus>,
103 pub routing_enabled: bool,
105 pub routing_aliases: std::collections::BTreeMap<String, String>,
108 pub reference_model: Option<String>,
110 pub local_shadow_rate: f64,
112 pub mcp_servers: Vec<crate::core::config::ResolvedMcpServer>,
115}
116
117pub fn router(state: AdminState) -> axum::Router {
119 axum::Router::new()
120 .route("/api/admin/usage", axum::routing::get(get_usage))
121 .route(
122 "/api/admin/timeseries",
123 axum::routing::get(super::admin_timeseries::get_timeseries),
124 )
125 .route(
126 "/api/admin/status",
127 axum::routing::get(super::admin_status::get_status),
128 )
129 .route("/api/admin/evidence", axum::routing::get(get_evidence))
130 .route(
131 "/api/admin/mcp",
132 axum::routing::get(super::mcp::admin::get_mcp),
133 )
134 .with_state(Arc::new(state))
135}
136
137async fn get_evidence(
141 State(state): State<Arc<AdminState>>,
142 Query(q): Query<UsageQuery>,
143) -> Response {
144 let (from, to) = match resolve_window(q.from.as_deref(), q.to.as_deref()) {
145 Ok(w) => w,
146 Err(msg) => {
147 return (
148 StatusCode::BAD_REQUEST,
149 Json(serde_json::json!({"error": msg})),
150 )
151 .into_response();
152 }
153 };
154 match super::evidence::generate(&state.pool, from, to).await {
155 Ok(artifact) => (
156 StatusCode::OK,
157 [(
158 axum::http::header::CONTENT_DISPOSITION,
159 "attachment; filename=\"leanctx-evidence.json\"",
160 )],
161 Json(serde_json::to_value(&artifact).unwrap_or_default()),
162 )
163 .into_response(),
164 Err(e) => {
165 tracing::warn!("evidence export failed: {e:#}");
166 (
167 StatusCode::BAD_GATEWAY,
168 Json(serde_json::json!({"error": "evidence export failed — see gateway logs"})),
169 )
170 .into_response()
171 }
172 }
173}
174
175const USAGE_BREAKDOWN_SQL: &str = "
178SELECT person, project, model, provider,
179 count(*) AS requests,
180 sum(input_tokens)::BIGINT AS input_tokens,
181 sum(output_tokens)::BIGINT AS output_tokens,
182 sum(cost_usd) AS cost_usd,
183 sum(saved_tokens)::BIGINT AS saved_tokens,
184 sum(saved_usd) AS saved_usd,
185 count(*) FILTER (WHERE cost_source = 'provider') AS measured_requests,
186 count(*) FILTER (WHERE cost_source = 'heuristic') AS estimated_requests
187FROM usage_events
188WHERE ts >= $1 AND ts <= $2
189GROUP BY person, project, model, provider
190ORDER BY cost_usd DESC";
191
192const USAGE_TOTALS_SQL: &str = "
193SELECT count(*) AS requests,
194 coalesce(sum(cost_usd), 0) AS cost_usd,
195 coalesce(sum(saved_usd), 0) AS saved_usd,
196 coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd,
197 count(DISTINCT person) AS active_persons,
198 count(*) FILTER (WHERE cost_source = 'provider') AS measured_requests,
199 count(*) FILTER (WHERE cost_source = 'heuristic') AS estimated_requests
200FROM usage_events
201WHERE ts >= $1 AND ts <= $2";
202
203async fn get_usage(State(state): State<Arc<AdminState>>, Query(q): Query<UsageQuery>) -> Response {
204 let (from, to) = match resolve_window(q.from.as_deref(), q.to.as_deref()) {
205 Ok(w) => w,
206 Err(msg) => {
207 return (
208 StatusCode::BAD_REQUEST,
209 Json(serde_json::json!({"error": msg})),
210 )
211 .into_response();
212 }
213 };
214
215 match usage_breakdown(&state.pool, from, to, state.seats).await {
216 Ok(resp) => Json(resp).into_response(),
217 Err(e) => {
218 tracing::warn!("admin usage query failed: {e:#}");
219 (
220 StatusCode::SERVICE_UNAVAILABLE,
221 Json(serde_json::json!({"error": "usage store unavailable"})),
222 )
223 .into_response()
224 }
225 }
226}
227
228pub(super) fn resolve_window(
231 from: Option<&str>,
232 to: Option<&str>,
233) -> Result<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>), String> {
234 let parse = |s: &str, which: &str| {
235 chrono::DateTime::parse_from_rfc3339(s)
236 .map(|d| d.with_timezone(&chrono::Utc))
237 .map_err(|e| format!("invalid `{which}` timestamp (RFC 3339 expected): {e}"))
238 };
239 let to_ts = match to {
240 Some(s) => parse(s, "to")?,
241 None => chrono::Utc::now(),
242 };
243 let from_ts = match from {
244 Some(s) => parse(s, "from")?,
245 None => to_ts - chrono::Duration::days(30),
246 };
247 if from_ts > to_ts {
248 return Err("`from` must not be after `to`".into());
249 }
250 Ok((from_ts, to_ts))
251}
252
253pub async fn usage_breakdown(
258 pool: &Pool,
259 from: chrono::DateTime<chrono::Utc>,
260 to: chrono::DateTime<chrono::Utc>,
261 seats: Option<u32>,
262) -> anyhow::Result<UsageBreakdownResponse> {
263 let client = pool.get().await?;
264
265 let rows = client
266 .query(USAGE_BREAKDOWN_SQL, &[&from, &to])
267 .await?
268 .iter()
269 .map(|r| UsageBreakdownRow {
270 person: r.get("person"),
271 project: r.get("project"),
272 model: r.get("model"),
273 provider: r.get("provider"),
274 requests: r.get("requests"),
275 input_tokens: r.get("input_tokens"),
276 output_tokens: r.get("output_tokens"),
277 cost_usd: r.get("cost_usd"),
278 saved_tokens: r.get("saved_tokens"),
279 saved_usd: r.get("saved_usd"),
280 measured_requests: r.get("measured_requests"),
281 estimated_requests: r.get("estimated_requests"),
282 })
283 .collect();
284
285 let t = client.query_one(USAGE_TOTALS_SQL, &[&from, &to]).await?;
286 let totals = build_totals(
287 Aggregates {
288 requests: t.get("requests"),
289 cost_usd: t.get("cost_usd"),
290 saved_usd: t.get("saved_usd"),
291 reference_cost_usd: t.get("reference_cost_usd"),
292 active_persons: t.get("active_persons"),
293 measured_requests: t.get("measured_requests"),
294 estimated_requests: t.get("estimated_requests"),
295 },
296 seats,
297 to - from,
298 );
299
300 Ok(UsageBreakdownResponse {
301 from: from.to_rfc3339(),
302 to: to.to_rfc3339(),
303 rows,
304 totals,
305 })
306}
307
308#[derive(Debug, Clone, Copy, Default)]
310struct Aggregates {
311 requests: i64,
312 cost_usd: f64,
313 saved_usd: f64,
314 reference_cost_usd: f64,
315 active_persons: i64,
316 measured_requests: i64,
317 estimated_requests: i64,
318}
319
320fn build_totals(agg: Aggregates, seats: Option<u32>, window: chrono::Duration) -> UsageTotals {
323 let window_days = window.num_seconds() as f64 / 86_400.0;
324 let projection = seats
325 .filter(|_| agg.active_persons > 0 && window_days > 0.0)
326 .map(|s| {
327 #[allow(clippy::cast_precision_loss)]
328 let per_person_per_month =
329 agg.saved_usd / agg.active_persons as f64 / window_days * PROJECTION_MONTH_DAYS;
330 per_person_per_month * f64::from(s)
331 });
332 UsageTotals {
333 requests: agg.requests,
334 cost_usd: agg.cost_usd,
335 saved_usd: agg.saved_usd,
336 reference_cost_usd: agg.reference_cost_usd,
337 active_persons: agg.active_persons,
338 measured_requests: agg.measured_requests,
339 estimated_requests: agg.estimated_requests,
340 projection_seats: seats.filter(|_| projection.is_some()),
341 projection_usd_per_month: projection,
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn projection_scales_per_person_savings_to_seats_and_month() {
351 let t = build_totals(
354 Aggregates {
355 requests: 1_000,
356 cost_usd: 2_000.0,
357 saved_usd: 500.0,
358 reference_cost_usd: 3_000.0,
359 active_persons: 10,
360 ..Default::default()
361 },
362 Some(800),
363 chrono::Duration::days(15),
364 );
365 assert_eq!(t.projection_seats, Some(800));
366 let p = t.projection_usd_per_month.expect("projection");
367 assert!((p - 80_000.0).abs() < 1e-6, "got {p}");
368 }
369
370 #[test]
371 fn projection_absent_without_seats_or_activity() {
372 let t = build_totals(
374 Aggregates {
375 requests: 10,
376 cost_usd: 1.0,
377 saved_usd: 1.0,
378 active_persons: 5,
379 ..Default::default()
380 },
381 None,
382 chrono::Duration::days(30),
383 );
384 assert_eq!(t.projection_usd_per_month, None);
385 assert_eq!(t.projection_seats, None);
386 let t = build_totals(Aggregates::default(), Some(800), chrono::Duration::days(30));
388 assert_eq!(t.projection_usd_per_month, None);
389 assert_eq!(t.projection_seats, None, "seats hidden when unusable");
390 }
391
392 #[test]
393 fn window_defaults_and_validation() {
394 let (from, to) = resolve_window(None, None).expect("default window");
395 assert_eq!((to - from).num_days(), 30);
396
397 let (from, to) = resolve_window(Some("2026-07-01T00:00:00Z"), Some("2026-07-31T23:59:59Z"))
398 .expect("explicit window");
399 assert_eq!(from.to_rfc3339(), "2026-07-01T00:00:00+00:00");
400 assert!(to > from);
401
402 assert!(resolve_window(Some("not-a-date"), None).is_err());
403 assert!(
404 resolve_window(Some("2026-08-01T00:00:00Z"), Some("2026-07-01T00:00:00Z")).is_err(),
405 "inverted window must be rejected"
406 );
407 }
408
409 #[test]
410 fn response_serializes_stably() {
411 let resp = UsageBreakdownResponse {
413 from: "2026-07-01T00:00:00+00:00".into(),
414 to: "2026-07-31T23:59:59+00:00".into(),
415 rows: vec![UsageBreakdownRow {
416 person: "alice@example.com".into(),
417 project: "billing".into(),
418 model: "claude-sonnet-4-5".into(),
419 provider: "Anthropic".into(),
420 requests: 1240,
421 input_tokens: 9_000_000,
422 output_tokens: 480_000,
423 cost_usd: 312.40,
424 saved_tokens: 3_100_000,
425 saved_usd: 210.11,
426 measured_requests: 40,
427 estimated_requests: 3,
428 }],
429 totals: build_totals(
430 Aggregates {
431 requests: 1240,
432 cost_usd: 312.40,
433 saved_usd: 210.11,
434 reference_cost_usd: 522.51,
435 active_persons: 1,
436 measured_requests: 40,
437 estimated_requests: 3,
438 },
439 Some(800),
440 chrono::Duration::days(30),
441 ),
442 };
443 let json = serde_json::to_value(&resp).expect("serializes");
444 assert_eq!(json["rows"][0]["person"], "alice@example.com");
445 assert_eq!(json["totals"]["active_persons"], 1);
446 assert_eq!(json["totals"]["measured_requests"], 40);
447 assert_eq!(json["rows"][0]["estimated_requests"], 3);
448 assert!(json["totals"]["projection_usd_per_month"].is_f64());
449 let parsed: UsageBreakdownResponse = serde_json::from_value(json).expect("round-trips");
450 assert_eq!(parsed, resp);
451 }
452}