Skip to main content

lean_ctx/gateway_server/
admin_status.rs

1//! `GET /api/admin/status` (enterprise#46) — the gateway's live health/config
2//! card for the admin dashboard.
3//!
4//! Everything here is *observed*, not configured wishful thinking: the store
5//! block runs a real query against `usage_events` (connected = the query
6//! succeeded just now), the drop counter is the live fail-open counter from
7//! `proxy::usage_sink`, and the provider list mirrors the resolved registry —
8//! including whether each injection credential is actually present in the
9//! environment.
10
11use std::sync::Arc;
12
13use axum::extract::State;
14use axum::response::{IntoResponse, Json, Response};
15use serde::{Deserialize, Serialize};
16
17use super::admin_api::AdminState;
18
19/// One registry provider as shown on the status card.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct ProviderStatus {
22    pub id: String,
23    /// Wire shape label (`anthropic` | `openai` | `gemini`).
24    pub shape: String,
25    pub base_url: String,
26    /// Whether the gateway injects its own upstream key for this provider.
27    pub injects_credential: bool,
28    /// `injects_credential` and the env var is actually set and non-empty.
29    pub credential_present: bool,
30    /// Billed as local inference (shadow rate) — declared flag or loopback URL.
31    #[serde(default)]
32    pub local: bool,
33}
34
35/// Store (Postgres) health, measured by a live query at request time.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct StoreStatus {
38    pub connected: bool,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub events_total: Option<i64>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub last_event_ts: Option<String>,
43    /// Fail-open drops since process start (`usage_sink` saturation counter).
44    pub dropped_events: u64,
45}
46
47/// Response of `GET /api/admin/status`.
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct StatusResponse {
50    pub version: String,
51    pub uptime_secs: u64,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub org_label: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub seats: Option<u32>,
56    pub store: StoreStatus,
57    pub providers: Vec<ProviderStatus>,
58    pub routing_enabled: bool,
59    /// The curated alias catalog (requested name → `provider:model` target),
60    /// as served to clients via `GET /v1/models` (enterprise#63). Deterministic
61    /// order (BTreeMap, #498).
62    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
63    pub routing_aliases: std::collections::BTreeMap<String, String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub reference_model: Option<String>,
66    pub local_shadow_rate_per_mtok: f64,
67}
68
69pub(super) async fn get_status(State(state): State<Arc<AdminState>>) -> Response {
70    Json(build_status(&state).await).into_response()
71}
72
73/// Assembles the status snapshot. Never fails: a broken store shows up as
74/// `connected: false`, not as an error response (the card must render during
75/// incidents — that is when it matters most).
76pub async fn build_status(state: &AdminState) -> StatusResponse {
77    let store = match probe_store(&state.pool).await {
78        Ok((events_total, last_event_ts)) => StoreStatus {
79            connected: true,
80            events_total: Some(events_total),
81            last_event_ts,
82            dropped_events: crate::proxy::usage_sink::dropped_count(),
83        },
84        Err(e) => {
85            tracing::debug!("admin status store probe failed: {e:#}");
86            StoreStatus {
87                connected: false,
88                events_total: None,
89                last_event_ts: None,
90                dropped_events: crate::proxy::usage_sink::dropped_count(),
91            }
92        }
93    };
94    StatusResponse {
95        version: env!("CARGO_PKG_VERSION").to_string(),
96        uptime_secs: state.started_at.elapsed().as_secs(),
97        org_label: state.org_label.clone(),
98        seats: state.seats,
99        store,
100        providers: state.providers.clone(),
101        routing_enabled: state.routing_enabled,
102        routing_aliases: state.routing_aliases.clone(),
103        reference_model: state.reference_model.clone(),
104        local_shadow_rate_per_mtok: state.local_shadow_rate,
105    }
106}
107
108async fn probe_store(pool: &deadpool_postgres::Pool) -> anyhow::Result<(i64, Option<String>)> {
109    let client = pool.get().await?;
110    let row = client
111        .query_one(
112            "SELECT count(*) AS n, max(ts) AS last FROM usage_events",
113            &[],
114        )
115        .await?;
116    let last: Option<chrono::DateTime<chrono::Utc>> = row.get("last");
117    Ok((row.get("n"), last.map(|t| t.to_rfc3339())))
118}
119
120/// Derives the provider status list from the resolved registry, checking each
121/// injection env var *now* (a rotated-away key shows up immediately).
122#[must_use]
123pub fn provider_statuses(
124    providers: &[crate::core::config::ResolvedProvider],
125) -> Vec<ProviderStatus> {
126    providers
127        .iter()
128        .map(|p| {
129            let credential_present = p.api_key_env.as_deref().is_some_and(|env_name| {
130                std::env::var(env_name).is_ok_and(|v| !v.trim().is_empty())
131            });
132            ProviderStatus {
133                id: p.id.clone(),
134                shape: p.shape.as_str().to_string(),
135                base_url: p.base_url.clone(),
136                injects_credential: p.api_key_env.is_some(),
137                credential_present,
138                local: p.local,
139            }
140        })
141        .collect()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::core::config::{ResolvedProvider, WireShape};
148
149    #[test]
150    fn provider_status_reflects_env_presence() {
151        let providers = vec![
152            ResolvedProvider {
153                id: "local".into(),
154                shape: WireShape::OpenAi,
155                base_url: "http://127.0.0.1:11434".into(),
156                api_key_env: None,
157                local: true,
158            },
159            ResolvedProvider {
160                id: "foundry".into(),
161                shape: WireShape::OpenAi,
162                base_url: "https://example.services.ai.azure.com/models".into(),
163                api_key_env: Some("LEANCTX_TEST_STATUS_KEY_UNSET".into()),
164                local: false,
165            },
166        ];
167        let statuses = provider_statuses(&providers);
168        assert_eq!(statuses.len(), 2);
169        assert!(!statuses[0].injects_credential);
170        assert!(!statuses[0].credential_present);
171        assert_eq!(statuses[0].shape, "openai");
172        assert!(statuses[0].local, "declared local flag must surface");
173        assert!(statuses[1].injects_credential);
174        assert!(
175            !statuses[1].credential_present,
176            "unset env var must show as missing credential"
177        );
178        assert!(!statuses[1].local);
179    }
180
181    #[test]
182    fn status_response_shape_round_trips() {
183        let resp = StatusResponse {
184            version: "3.8.18".into(),
185            uptime_secs: 42,
186            org_label: Some("Zühlke Engineering AG".into()),
187            seats: Some(800),
188            store: StoreStatus {
189                connected: true,
190                events_total: Some(1234),
191                last_event_ts: Some("2026-07-02T09:00:00+00:00".into()),
192                dropped_events: 0,
193            },
194            providers: vec![],
195            routing_enabled: true,
196            routing_aliases: std::collections::BTreeMap::from([(
197                "zuehlke/fast".to_string(),
198                "foundry:deepseek-v4-flash".to_string(),
199            )]),
200            reference_model: Some("claude-opus-4.5".into()),
201            local_shadow_rate_per_mtok: 0.25,
202        };
203        let json = serde_json::to_value(&resp).expect("serializes");
204        assert_eq!(json["store"]["connected"], true);
205        assert_eq!(json["seats"], 800);
206        assert_eq!(
207            json["routing_aliases"]["zuehlke/fast"],
208            "foundry:deepseek-v4-flash"
209        );
210        let parsed: StatusResponse = serde_json::from_value(json).expect("round-trips");
211        assert_eq!(parsed, resp);
212    }
213}