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 #[serde(skip_serializing_if = "Option::is_none")]
69 pub live_pricing: Option<LivePricingStatus>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub struct LivePricingStatus {
75 pub fetched_at: u64,
77 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
85pub 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#[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.gateway_credential_present();
148 ProviderStatus {
149 id: p.id.clone(),
150 shape: p.shape.as_str().to_string(),
151 base_url: p.base_url.clone(),
152 injects_credential: p.injects_gateway_credential(),
153 credential_present,
154 local: p.local,
155 }
156 })
157 .collect()
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::core::config::{ResolvedProvider, WireShape};
164
165 #[test]
166 fn provider_status_reflects_env_presence() {
167 let providers = vec![
168 ResolvedProvider {
169 id: "local".into(),
170 shape: WireShape::OpenAi,
171 base_url: "http://127.0.0.1:11434".into(),
172 api_key_env: None,
173 aws_region: None,
174 local: true,
175 },
176 ResolvedProvider {
177 id: "foundry".into(),
178 shape: WireShape::OpenAi,
179 base_url: "https://example.services.ai.azure.com/models".into(),
180 api_key_env: Some("LEANCTX_TEST_STATUS_KEY_UNSET".into()),
181 aws_region: None,
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}