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