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    /// Live provider price list (#1179): present when the snapshot is loaded.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub live_pricing: Option<LivePricingStatus>,
70}
71
72/// Freshness of the live model-price table on the status card.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub struct LivePricingStatus {
75    /// Unix seconds of the successful fetch that produced the active table.
76    pub fetched_at: u64,
77    /// Number of resolvable model lookup keys.
78    pub lookup_keys: usize,
79}
80
81pub(super) async fn get_status(State(state): State<Arc<AdminState>>) -> Response {
82    Json(build_status(&state).await).into_response()
83}
84
85/// Assembles the status snapshot. Never fails: a broken store shows up as
86/// `connected: false`, not as an error response (the card must render during
87/// incidents — that is when it matters most).
88pub async fn build_status(state: &AdminState) -> StatusResponse {
89    let store = match probe_store(&state.pool).await {
90        Ok((events_total, last_event_ts)) => StoreStatus {
91            connected: true,
92            events_total: Some(events_total),
93            last_event_ts,
94            dropped_events: crate::proxy::usage_sink::dropped_count(),
95        },
96        Err(e) => {
97            tracing::debug!("admin status store probe failed: {e:#}");
98            StoreStatus {
99                connected: false,
100                events_total: None,
101                last_event_ts: None,
102                dropped_events: crate::proxy::usage_sink::dropped_count(),
103            }
104        }
105    };
106    StatusResponse {
107        version: env!("CARGO_PKG_VERSION").to_string(),
108        uptime_secs: state.started_at.elapsed().as_secs(),
109        org_label: state.org_label.clone(),
110        seats: state.seats,
111        store,
112        providers: state.providers.clone(),
113        routing_enabled: state.routing_enabled,
114        routing_aliases: state.routing_aliases.clone(),
115        reference_model: state.reference_model.clone(),
116        local_shadow_rate_per_mtok: state.local_shadow_rate,
117        live_pricing: crate::core::gain::live_pricing::status().map(|(fetched_at, lookup_keys)| {
118            LivePricingStatus {
119                fetched_at,
120                lookup_keys,
121            }
122        }),
123    }
124}
125
126async fn probe_store(pool: &deadpool_postgres::Pool) -> anyhow::Result<(i64, Option<String>)> {
127    let client = pool.get().await?;
128    let row = client
129        .query_one(
130            "SELECT count(*) AS n, max(ts) AS last FROM usage_events",
131            &[],
132        )
133        .await?;
134    let last: Option<chrono::DateTime<chrono::Utc>> = row.get("last");
135    Ok((row.get("n"), last.map(|t| t.to_rfc3339())))
136}
137
138/// Derives the provider status list from the resolved registry, checking each
139/// injection env var *now* (a rotated-away key shows up immediately).
140#[must_use]
141pub fn provider_statuses(
142    providers: &[crate::core::config::ResolvedProvider],
143) -> Vec<ProviderStatus> {
144    providers
145        .iter()
146        .map(|p| {
147            let credential_present = p.api_key_env.as_deref().is_some_and(|env_name| {
148                std::env::var(env_name).is_ok_and(|v| !v.trim().is_empty())
149            });
150            ProviderStatus {
151                id: p.id.clone(),
152                shape: p.shape.as_str().to_string(),
153                base_url: p.base_url.clone(),
154                injects_credential: p.api_key_env.is_some(),
155                credential_present,
156                local: p.local,
157            }
158        })
159        .collect()
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::core::config::{ResolvedProvider, WireShape};
166
167    #[test]
168    fn provider_status_reflects_env_presence() {
169        let providers = vec![
170            ResolvedProvider {
171                id: "local".into(),
172                shape: WireShape::OpenAi,
173                base_url: "http://127.0.0.1:11434".into(),
174                api_key_env: None,
175                local: true,
176            },
177            ResolvedProvider {
178                id: "foundry".into(),
179                shape: WireShape::OpenAi,
180                base_url: "https://example.services.ai.azure.com/models".into(),
181                api_key_env: Some("LEANCTX_TEST_STATUS_KEY_UNSET".into()),
182                local: false,
183            },
184        ];
185        let statuses = provider_statuses(&providers);
186        assert_eq!(statuses.len(), 2);
187        assert!(!statuses[0].injects_credential);
188        assert!(!statuses[0].credential_present);
189        assert_eq!(statuses[0].shape, "openai");
190        assert!(statuses[0].local, "declared local flag must surface");
191        assert!(statuses[1].injects_credential);
192        assert!(
193            !statuses[1].credential_present,
194            "unset env var must show as missing credential"
195        );
196        assert!(!statuses[1].local);
197    }
198
199    #[test]
200    fn status_response_shape_round_trips() {
201        let resp = StatusResponse {
202            version: "3.8.18".into(),
203            uptime_secs: 42,
204            org_label: Some("Zühlke Engineering AG".into()),
205            seats: Some(800),
206            store: StoreStatus {
207                connected: true,
208                events_total: Some(1234),
209                last_event_ts: Some("2026-07-02T09:00:00+00:00".into()),
210                dropped_events: 0,
211            },
212            providers: vec![],
213            routing_enabled: true,
214            routing_aliases: std::collections::BTreeMap::from([(
215                "zuehlke/fast".to_string(),
216                "foundry:deepseek-v4-flash".to_string(),
217            )]),
218            reference_model: Some("claude-opus-4.5".into()),
219            local_shadow_rate_per_mtok: 0.25,
220            live_pricing: Some(LivePricingStatus {
221                fetched_at: 1_780_000_000,
222                lookup_keys: 340,
223            }),
224        };
225        let json = serde_json::to_value(&resp).expect("serializes");
226        assert_eq!(json["store"]["connected"], true);
227        assert_eq!(json["seats"], 800);
228        assert_eq!(
229            json["routing_aliases"]["zuehlke/fast"],
230            "foundry:deepseek-v4-flash"
231        );
232        assert_eq!(json["live_pricing"]["lookup_keys"], 340);
233        let parsed: StatusResponse = serde_json::from_value(json).expect("round-trips");
234        assert_eq!(parsed, resp);
235    }
236}