use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use deadpool_postgres::Pool;
use serde::{Deserialize, Serialize};
const PROJECTION_MONTH_DAYS: f64 = 30.0;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UsageBreakdownRow {
pub person: String,
pub project: String,
pub model: String,
pub provider: String,
pub requests: i64,
pub input_tokens: i64,
pub output_tokens: i64,
pub cost_usd: f64,
pub saved_tokens: i64,
pub saved_usd: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UsageTotals {
pub requests: i64,
pub cost_usd: f64,
pub saved_usd: f64,
pub reference_cost_usd: f64,
pub active_persons: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub projection_seats: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub projection_usd_per_month: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UsageBreakdownResponse {
pub from: String,
pub to: String,
pub rows: Vec<UsageBreakdownRow>,
pub totals: UsageTotals,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UsageQuery {
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Clone)]
pub struct AdminState {
pub pool: Pool,
pub seats: Option<u32>,
pub org_label: Option<String>,
pub started_at: std::time::Instant,
pub providers: Vec<super::admin_status::ProviderStatus>,
pub routing_enabled: bool,
pub routing_aliases: std::collections::BTreeMap<String, String>,
pub reference_model: Option<String>,
pub local_shadow_rate: f64,
}
pub fn router(state: AdminState) -> axum::Router {
axum::Router::new()
.route("/api/admin/usage", axum::routing::get(get_usage))
.route(
"/api/admin/timeseries",
axum::routing::get(super::admin_timeseries::get_timeseries),
)
.route(
"/api/admin/status",
axum::routing::get(super::admin_status::get_status),
)
.route("/api/admin/evidence", axum::routing::get(get_evidence))
.with_state(Arc::new(state))
}
async fn get_evidence(
State(state): State<Arc<AdminState>>,
Query(q): Query<UsageQuery>,
) -> Response {
let (from, to) = match resolve_window(q.from.as_deref(), q.to.as_deref()) {
Ok(w) => w,
Err(msg) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": msg})),
)
.into_response();
}
};
match super::evidence::generate(&state.pool, from, to).await {
Ok(artifact) => (
StatusCode::OK,
[(
axum::http::header::CONTENT_DISPOSITION,
"attachment; filename=\"leanctx-evidence.json\"",
)],
Json(serde_json::to_value(&artifact).unwrap_or_default()),
)
.into_response(),
Err(e) => {
tracing::warn!("evidence export failed: {e:#}");
(
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({"error": "evidence export failed — see gateway logs"})),
)
.into_response()
}
}
}
const USAGE_BREAKDOWN_SQL: &str = "
SELECT person, project, model, provider,
count(*) AS requests,
sum(input_tokens)::BIGINT AS input_tokens,
sum(output_tokens)::BIGINT AS output_tokens,
sum(cost_usd) AS cost_usd,
sum(saved_tokens)::BIGINT AS saved_tokens,
sum(saved_usd) AS saved_usd
FROM usage_events
WHERE ts >= $1 AND ts <= $2
GROUP BY person, project, model, provider
ORDER BY cost_usd DESC";
const USAGE_TOTALS_SQL: &str = "
SELECT count(*) AS requests,
coalesce(sum(cost_usd), 0) AS cost_usd,
coalesce(sum(saved_usd), 0) AS saved_usd,
coalesce(sum(reference_cost_usd), 0) AS reference_cost_usd,
count(DISTINCT person) AS active_persons
FROM usage_events
WHERE ts >= $1 AND ts <= $2";
async fn get_usage(State(state): State<Arc<AdminState>>, Query(q): Query<UsageQuery>) -> Response {
let (from, to) = match resolve_window(q.from.as_deref(), q.to.as_deref()) {
Ok(w) => w,
Err(msg) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": msg})),
)
.into_response();
}
};
match usage_breakdown(&state.pool, from, to, state.seats).await {
Ok(resp) => Json(resp).into_response(),
Err(e) => {
tracing::warn!("admin usage query failed: {e:#}");
(
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({"error": "usage store unavailable"})),
)
.into_response()
}
}
}
pub(super) fn resolve_window(
from: Option<&str>,
to: Option<&str>,
) -> Result<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>), String> {
let parse = |s: &str, which: &str| {
chrono::DateTime::parse_from_rfc3339(s)
.map(|d| d.with_timezone(&chrono::Utc))
.map_err(|e| format!("invalid `{which}` timestamp (RFC 3339 expected): {e}"))
};
let to_ts = match to {
Some(s) => parse(s, "to")?,
None => chrono::Utc::now(),
};
let from_ts = match from {
Some(s) => parse(s, "from")?,
None => to_ts - chrono::Duration::days(30),
};
if from_ts > to_ts {
return Err("`from` must not be after `to`".into());
}
Ok((from_ts, to_ts))
}
pub async fn usage_breakdown(
pool: &Pool,
from: chrono::DateTime<chrono::Utc>,
to: chrono::DateTime<chrono::Utc>,
seats: Option<u32>,
) -> anyhow::Result<UsageBreakdownResponse> {
let client = pool.get().await?;
let rows = client
.query(USAGE_BREAKDOWN_SQL, &[&from, &to])
.await?
.iter()
.map(|r| UsageBreakdownRow {
person: r.get("person"),
project: r.get("project"),
model: r.get("model"),
provider: r.get("provider"),
requests: r.get("requests"),
input_tokens: r.get("input_tokens"),
output_tokens: r.get("output_tokens"),
cost_usd: r.get("cost_usd"),
saved_tokens: r.get("saved_tokens"),
saved_usd: r.get("saved_usd"),
})
.collect();
let t = client.query_one(USAGE_TOTALS_SQL, &[&from, &to]).await?;
let totals = build_totals(
t.get("requests"),
t.get("cost_usd"),
t.get("saved_usd"),
t.get("reference_cost_usd"),
t.get("active_persons"),
seats,
to - from,
);
Ok(UsageBreakdownResponse {
from: from.to_rfc3339(),
to: to.to_rfc3339(),
rows,
totals,
})
}
fn build_totals(
requests: i64,
cost_usd: f64,
saved_usd: f64,
reference_cost_usd: f64,
active_persons: i64,
seats: Option<u32>,
window: chrono::Duration,
) -> UsageTotals {
let window_days = window.num_seconds() as f64 / 86_400.0;
let projection = seats
.filter(|_| active_persons > 0 && window_days > 0.0)
.map(|s| {
#[allow(clippy::cast_precision_loss)]
let per_person_per_month =
saved_usd / active_persons as f64 / window_days * PROJECTION_MONTH_DAYS;
per_person_per_month * f64::from(s)
});
UsageTotals {
requests,
cost_usd,
saved_usd,
reference_cost_usd,
active_persons,
projection_seats: seats.filter(|_| projection.is_some()),
projection_usd_per_month: projection,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn projection_scales_per_person_savings_to_seats_and_month() {
let t = build_totals(
1_000,
2_000.0,
500.0,
3_000.0,
10,
Some(800),
chrono::Duration::days(15),
);
assert_eq!(t.projection_seats, Some(800));
let p = t.projection_usd_per_month.expect("projection");
assert!((p - 80_000.0).abs() < 1e-6, "got {p}");
}
#[test]
fn projection_absent_without_seats_or_activity() {
let t = build_totals(10, 1.0, 1.0, 0.0, 5, None, chrono::Duration::days(30));
assert_eq!(t.projection_usd_per_month, None);
assert_eq!(t.projection_seats, None);
let t = build_totals(0, 0.0, 0.0, 0.0, 0, Some(800), chrono::Duration::days(30));
assert_eq!(t.projection_usd_per_month, None);
assert_eq!(t.projection_seats, None, "seats hidden when unusable");
}
#[test]
fn window_defaults_and_validation() {
let (from, to) = resolve_window(None, None).expect("default window");
assert!((to - from).num_days() == 30);
let (from, to) = resolve_window(Some("2026-07-01T00:00:00Z"), Some("2026-07-31T23:59:59Z"))
.expect("explicit window");
assert_eq!(from.to_rfc3339(), "2026-07-01T00:00:00+00:00");
assert!(to > from);
assert!(resolve_window(Some("not-a-date"), None).is_err());
assert!(
resolve_window(Some("2026-08-01T00:00:00Z"), Some("2026-07-01T00:00:00Z")).is_err(),
"inverted window must be rejected"
);
}
#[test]
fn response_serializes_stably() {
let resp = UsageBreakdownResponse {
from: "2026-07-01T00:00:00+00:00".into(),
to: "2026-07-31T23:59:59+00:00".into(),
rows: vec![UsageBreakdownRow {
person: "alice@example.com".into(),
project: "billing".into(),
model: "claude-sonnet-4-5".into(),
provider: "Anthropic".into(),
requests: 1240,
input_tokens: 9_000_000,
output_tokens: 480_000,
cost_usd: 312.40,
saved_tokens: 3_100_000,
saved_usd: 210.11,
}],
totals: build_totals(
1240,
312.40,
210.11,
522.51,
1,
Some(800),
chrono::Duration::days(30),
),
};
let json = serde_json::to_value(&resp).expect("serializes");
assert_eq!(json["rows"][0]["person"], "alice@example.com");
assert_eq!(json["totals"]["active_persons"], 1);
assert!(json["totals"]["projection_usd_per_month"].is_f64());
let parsed: UsageBreakdownResponse = serde_json::from_value(json).expect("round-trips");
assert_eq!(parsed, resp);
}
}