Skip to main content

lean_ctx/gateway_server/
admin_api.rs

1//! Admin usage API (enterprise#20) — the self-hosted gateway's spend/savings
2//! breakdown, straight from `usage_events` (Doc 08 §3.3).
3//!
4//! `GET /api/admin/usage?from=<ISO>&to=<ISO>` returns the person × project ×
5//! model × provider cross-join with per-group token/cost/savings sums, plus
6//! totals and the seat projection. Runs in the **self-hosted `gateway-server`
7//! (OSS, local Postgres)** — "seeing your own instance" is local-free; the
8//! multi-tenant managed console is a separate commercial surface.
9//!
10//! Auth: the router is mounted behind the gateway's Bearer middleware by
11//! `gateway serve` (enterprise#10); this module contains no credential logic.
12
13use 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
21/// Days in the projection's reference month. The projection is an
22/// *extrapolation for planning*, clearly labeled — not a billing number.
23const PROJECTION_MONTH_DAYS: f64 = 30.0;
24
25/// One aggregated row of the person × project × model × provider cross-join.
26#[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/// Aggregate totals + the seat projection over the queried window.
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct UsageTotals {
43    pub requests: i64,
44    pub cost_usd: f64,
45    pub saved_usd: f64,
46    /// Reference (avoided-cost) sum for the window, when a `reference_model`
47    /// is configured (enterprise#15/#18); 0.0 otherwise.
48    pub reference_cost_usd: f64,
49    /// Distinct persons with ≥1 event in the window — the projection divisor.
50    pub active_persons: i64,
51    /// `saved_usd / active_persons × seats`, scaled to a 30-day month
52    /// (enterprise#20, Doc 04): "if every configured seat saved like the
53    /// currently active users, this is the monthly org-wide savings".
54    /// `None` when no seats are configured or nothing is active — the
55    /// cockpit never invents a projection.
56    #[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/// Response of `GET /api/admin/usage`.
63#[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/// Query parameters: ISO-8601 `from`/`to` (defaults: last 30 days up to now).
72#[derive(Debug, Clone, Deserialize)]
73pub struct UsageQuery {
74    pub from: Option<String>,
75    pub to: Option<String>,
76}
77
78/// Shared state of the admin router: the store pool + deployment parameters
79/// (the config/identity slice the status card and dashboard need).
80#[derive(Clone)]
81pub struct AdminState {
82    pub pool: Pool,
83    /// Seats for the projection (`[gateway_server].seats`).
84    pub seats: Option<u32>,
85    /// `[gateway_server].org_label` — branding for the dashboard header.
86    pub org_label: Option<String>,
87    /// Process start, for the status card's uptime.
88    pub started_at: std::time::Instant,
89    /// Resolved provider registry snapshot (id/shape/credential presence).
90    pub providers: Vec<super::admin_status::ProviderStatus>,
91    /// `[proxy.routing].enabled`.
92    pub routing_enabled: bool,
93    /// `[proxy.routing].aliases` — the curated model catalog served as
94    /// `GET /v1/models` on the proxy port (enterprise#63).
95    pub routing_aliases: std::collections::BTreeMap<String, String>,
96    /// `[proxy.baseline].reference_model`.
97    pub reference_model: Option<String>,
98    /// Effective local shadow rate (USD per MTok).
99    pub local_shadow_rate: f64,
100}
101
102/// Builds the admin API router. Mounted behind Bearer auth by `gateway serve`.
103pub 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
118/// `GET /api/admin/evidence?from=&to=` — the signed usage-evidence artifact
119/// (enterprise#36). Download-ready JSON; verify offline with
120/// `lean-ctx gateway evidence verify --file=…`.
121async 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
156/// The GROUP BY over `usage_events` (Doc 08 §3.3). Window bounds are bound
157/// parameters; everything else is static SQL (deterministic, injection-free).
158const 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
205/// Parses the window, defaulting to the last 30 days ending now. Rejects an
206/// inverted window instead of silently returning an empty result.
207pub(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
230/// Runs the cross-join + totals queries and assembles the response.
231///
232/// # Errors
233/// Propagates pool/query errors (the handler maps them to 503).
234pub 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
279/// Pure projection math (unit-tested): per-active-person savings × seats,
280/// normalized from the window length to a 30-day month.
281fn 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        // 10 active persons saved $500 over a 15-day window → $100/person/month;
317        // 800 seats → $80k/month.
318        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        // No seats configured → no projection, ever.
335        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        // Seats configured but zero active persons → nothing to extrapolate from.
339        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        // The response shape is a client contract (Doc 08 §3.3) — pin it.
364        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}