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    /// Requests whose cost is the provider's own reported charge (#1179).
39    #[serde(default)]
40    pub measured_requests: i64,
41    /// Requests whose cost had to be estimated from a heuristic price match.
42    #[serde(default)]
43    pub estimated_requests: i64,
44}
45
46/// Aggregate totals + the seat projection over the queried window.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct UsageTotals {
49    pub requests: i64,
50    pub cost_usd: f64,
51    pub saved_usd: f64,
52    /// Reference (avoided-cost) sum for the window, when a `reference_model`
53    /// is configured (enterprise#15/#18); 0.0 otherwise.
54    pub reference_cost_usd: f64,
55    /// Distinct persons with ≥1 event in the window — the projection divisor.
56    pub active_persons: i64,
57    /// Requests billed at the provider's own reported charge (#1179).
58    #[serde(default)]
59    pub measured_requests: i64,
60    /// Requests whose cost is a heuristic estimate (no exact/live price).
61    #[serde(default)]
62    pub estimated_requests: i64,
63    /// `saved_usd / active_persons × seats`, scaled to a 30-day month
64    /// (enterprise#20, Doc 04): "if every configured seat saved like the
65    /// currently active users, this is the monthly org-wide savings".
66    /// `None` when no seats are configured or nothing is active — the
67    /// cockpit never invents a projection.
68    #[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/// Response of `GET /api/admin/usage`.
75#[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/// Query parameters: ISO-8601 `from`/`to` (defaults: last 30 days up to now).
84#[derive(Debug, Clone, Deserialize)]
85pub struct UsageQuery {
86    pub from: Option<String>,
87    pub to: Option<String>,
88}
89
90/// Shared state of the admin router: the store pool + deployment parameters
91/// (the config/identity slice the status card and dashboard need).
92#[derive(Clone)]
93pub struct AdminState {
94    pub pool: Pool,
95    /// Seats for the projection (`[gateway_server].seats`).
96    pub seats: Option<u32>,
97    /// `[gateway_server].org_label` — branding for the dashboard header.
98    pub org_label: Option<String>,
99    /// Process start, for the status card's uptime.
100    pub started_at: std::time::Instant,
101    /// Resolved provider registry snapshot (id/shape/credential presence).
102    pub providers: Vec<super::admin_status::ProviderStatus>,
103    /// `[proxy.routing].enabled`.
104    pub routing_enabled: bool,
105    /// `[proxy.routing].aliases` — the curated model catalog served as
106    /// `GET /v1/models` on the proxy port (enterprise#63).
107    pub routing_aliases: std::collections::BTreeMap<String, String>,
108    /// `[proxy.baseline].reference_model`.
109    pub reference_model: Option<String>,
110    /// Effective local shadow rate (USD per MTok).
111    pub local_shadow_rate: f64,
112    /// Resolved `[[gateway_server.mcp_servers]]` registry snapshot (GL#104) —
113    /// the console's "Tools" section lists these alongside live inventory.
114    pub mcp_servers: Vec<crate::core::config::ResolvedMcpServer>,
115}
116
117/// Builds the admin API router. Mounted behind Bearer auth by `gateway serve`.
118pub 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
137/// `GET /api/admin/evidence?from=&to=` — the signed usage-evidence artifact
138/// (enterprise#36). Download-ready JSON; verify offline with
139/// `lean-ctx gateway evidence verify --file=…`.
140async 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
175/// The GROUP BY over `usage_events` (Doc 08 §3.3). Window bounds are bound
176/// parameters; everything else is static SQL (deterministic, injection-free).
177const 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
228/// Parses the window, defaulting to the last 30 days ending now. Rejects an
229/// inverted window instead of silently returning an empty result.
230pub(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
253/// Runs the cross-join + totals queries and assembles the response.
254///
255/// # Errors
256/// Propagates pool/query errors (the handler maps them to 503).
257pub 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/// Raw window aggregates from the totals query, fed into [`build_totals`].
309#[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
320/// Pure projection math (unit-tested): per-active-person savings × seats,
321/// normalized from the window length to a 30-day month.
322fn 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        // 10 active persons saved $500 over a 15-day window → $100/person/month;
352        // 800 seats → $80k/month.
353        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        // No seats configured → no projection, ever.
373        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        // Seats configured but zero active persons → nothing to extrapolate from.
387        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!((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        // The response shape is a client contract (Doc 08 §3.3) — pin it.
412        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}