1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct ProviderStatus {
22 pub id: String,
23 pub shape: String,
25 pub base_url: String,
26 pub injects_credential: bool,
28 pub credential_present: bool,
30 #[serde(default)]
32 pub local: bool,
33}
34
35#[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 pub dropped_events: u64,
45}
46
47#[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 #[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
73pub 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#[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}