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