1use std::collections::HashMap;
2use std::hash::{Hash, Hasher};
3use std::sync::{Mutex, OnceLock};
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result};
7use futures_util::stream::{FuturesUnordered, StreamExt};
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10
11use crate::config::{
12 ProxyConfig, ServiceConfigManager, ServiceStatusConfig, ServiceStatusProbeConfig, UpstreamAuth,
13};
14
15static SERVICE_STATUS_CACHE: OnceLock<Mutex<ServiceStatusCache>> = OnceLock::new();
16
17#[derive(Debug, Default)]
18struct ServiceStatusCache {
19 config_key: Option<u64>,
20 last_refresh: Option<Instant>,
21 snapshot: Option<ServiceStatusSnapshot>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct ServiceStatusSnapshot {
26 pub generated_at_ms: u64,
27 pub configured: bool,
28 pub enabled: bool,
29 pub refresh_interval_secs: u64,
30 pub history_cells: usize,
31 #[serde(default)]
32 pub probes: Vec<ServiceStatusProbeSnapshot>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub error: Option<String>,
35}
36
37impl ServiceStatusSnapshot {
38 pub fn disabled(config: &ServiceStatusConfig) -> Self {
39 Self {
40 generated_at_ms: unix_now_ms(),
41 configured: config.has_probes(),
42 enabled: config.enabled,
43 refresh_interval_secs: config.refresh_interval_secs,
44 history_cells: config.history_cells,
45 probes: Vec::new(),
46 error: None,
47 }
48 }
49
50 pub fn status_counts(&self) -> ServiceStatusCounts {
51 let mut counts = ServiceStatusCounts::default();
52 for probe in &self.probes {
53 for service in &probe.services {
54 counts.record(service.latest_kind);
55 }
56 }
57 counts
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct ServiceStatusProbeSnapshot {
63 pub id: String,
64 pub url: String,
65 pub fetched_at_ms: u64,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub generated_at_ms: Option<u64>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub all_ok: Option<bool>,
70 #[serde(default)]
71 pub services: Vec<ServiceStatusServiceSnapshot>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub error: Option<String>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct ServiceStatusServiceSnapshot {
78 pub model: String,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub uptime_pct: Option<String>,
81 pub latest_kind: ServiceStatusKind,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub latest: Option<ServiceStatusProbeSample>,
84 #[serde(default)]
85 pub history: Vec<ServiceStatusCellSnapshot>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub struct ServiceStatusCellSnapshot {
90 pub kind: ServiceStatusKind,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub probe: Option<ServiceStatusProbeSample>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct ServiceStatusProbeSample {
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub ts_ms: Option<u64>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub ok: Option<bool>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub latency_ms: Option<u64>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub error: Option<String>,
105}
106
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
108#[serde(rename_all = "snake_case")]
109pub enum ServiceStatusKind {
110 Ok,
111 Slow,
112 Failed,
113 #[default]
114 Unknown,
115}
116
117impl ServiceStatusKind {
118 pub fn as_str(self) -> &'static str {
119 match self {
120 ServiceStatusKind::Ok => "ok",
121 ServiceStatusKind::Slow => "slow",
122 ServiceStatusKind::Failed => "failed",
123 ServiceStatusKind::Unknown => "unknown",
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
129pub struct ServiceStatusCounts {
130 pub ok: usize,
131 pub slow: usize,
132 pub failed: usize,
133 pub unknown: usize,
134}
135
136impl ServiceStatusCounts {
137 pub fn record(&mut self, kind: ServiceStatusKind) {
138 match kind {
139 ServiceStatusKind::Ok => self.ok += 1,
140 ServiceStatusKind::Slow => self.slow += 1,
141 ServiceStatusKind::Failed => self.failed += 1,
142 ServiceStatusKind::Unknown => self.unknown += 1,
143 }
144 }
145}
146
147#[derive(Debug, Deserialize)]
148struct RawServiceStatusResponse {
149 #[serde(default, alias = "allOK")]
150 all_ok: Option<bool>,
151 #[serde(default, alias = "generatedAt")]
152 generated_at: Option<serde_json::Value>,
153 #[serde(default)]
154 services: Vec<RawServiceStatusService>,
155}
156
157#[derive(Debug, Deserialize)]
158struct RawServiceStatusService {
159 model: String,
160 #[serde(default, alias = "uptimePct")]
161 uptime_pct: Option<serde_json::Value>,
162 #[serde(default)]
163 last: Option<RawServiceStatusProbe>,
164 #[serde(default)]
165 history: Vec<RawServiceStatusProbe>,
166}
167
168#[derive(Debug, Clone, Deserialize)]
169struct RawServiceStatusProbe {
170 #[serde(default)]
171 ts: Option<serde_json::Value>,
172 #[serde(default)]
173 ok: Option<bool>,
174 #[serde(default, alias = "latencyMS")]
175 latency_ms: Option<serde_json::Value>,
176 #[serde(default)]
177 error: Option<String>,
178}
179
180#[derive(Debug, Clone)]
181struct ProviderProbeTarget {
182 provider_id: String,
183 endpoint_id: String,
184 base_url: String,
185 auth: UpstreamAuth,
186 supported_models: HashMap<String, bool>,
187 model_mapping: HashMap<String, String>,
188}
189
190pub async fn refresh_service_status_snapshot(
191 config: &ServiceStatusConfig,
192 runtime_config: Option<&ProxyConfig>,
193 service_name: &str,
194) -> ServiceStatusSnapshot {
195 if !config.is_active() {
196 return ServiceStatusSnapshot::disabled(config);
197 }
198
199 let config_key = service_status_cache_key(config, runtime_config, service_name);
200 let interval = Duration::from_secs(config.refresh_interval_secs.max(1));
201 if let Some(cached) = cached_service_status_snapshot(config_key, interval) {
202 return cached;
203 }
204
205 let snapshot = fetch_service_status_snapshot(config, runtime_config, service_name).await;
206 store_service_status_snapshot(config_key, snapshot.clone());
207 snapshot
208}
209
210fn cached_service_status_snapshot(
211 config_key: u64,
212 interval: Duration,
213) -> Option<ServiceStatusSnapshot> {
214 let cache = SERVICE_STATUS_CACHE.get_or_init(|| Mutex::new(ServiceStatusCache::default()));
215 let guard = cache.lock().ok()?;
216 if guard.config_key != Some(config_key) {
217 return None;
218 }
219 let last_refresh = guard.last_refresh?;
220 if last_refresh.elapsed() < interval {
221 return guard.snapshot.clone();
222 }
223 None
224}
225
226fn store_service_status_snapshot(config_key: u64, snapshot: ServiceStatusSnapshot) {
227 let cache = SERVICE_STATUS_CACHE.get_or_init(|| Mutex::new(ServiceStatusCache::default()));
228 if let Ok(mut guard) = cache.lock() {
229 guard.config_key = Some(config_key);
230 guard.last_refresh = Some(Instant::now());
231 guard.snapshot = Some(snapshot);
232 }
233}
234
235fn service_status_cache_key(
236 config: &ServiceStatusConfig,
237 runtime_config: Option<&ProxyConfig>,
238 service_name: &str,
239) -> u64 {
240 let mut hasher = std::collections::hash_map::DefaultHasher::new();
241 service_name.hash(&mut hasher);
242 config.enabled.hash(&mut hasher);
243 config.refresh_interval_secs.hash(&mut hasher);
244 config.timeout_ms.hash(&mut hasher);
245 config.high_latency_ms.hash(&mut hasher);
246 config.history_cells.hash(&mut hasher);
247 for probe in &config.probes {
248 probe.id.hash(&mut hasher);
249 probe.provider.hash(&mut hasher);
250 probe.endpoint.hash(&mut hasher);
251 probe.url.hash(&mut hasher);
252 probe.models.hash(&mut hasher);
253 probe.timeout_ms.hash(&mut hasher);
254 probe.high_latency_ms.hash(&mut hasher);
255 probe.headers.hash(&mut hasher);
256 }
257 if let Some(runtime_config) = runtime_config
258 && let Some(mgr) = service_manager(runtime_config, service_name)
259 {
260 let mut providers = mgr
261 .stations()
262 .values()
263 .flat_map(|station| station.upstreams.iter())
264 .filter_map(|upstream| {
265 let provider_id = upstream.tags.get("provider_id")?;
266 let endpoint_id = upstream
267 .tags
268 .get("endpoint_id")
269 .map(String::as_str)
270 .unwrap_or("default");
271 Some((provider_id, endpoint_id, upstream))
272 })
273 .collect::<Vec<_>>();
274 providers.sort_by(|left, right| {
275 left.0
276 .cmp(right.0)
277 .then_with(|| left.1.cmp(right.1))
278 .then_with(|| left.2.base_url.cmp(&right.2.base_url))
279 });
280 for (provider_id, endpoint_id, upstream) in providers {
281 provider_id.hash(&mut hasher);
282 endpoint_id.hash(&mut hasher);
283 upstream.base_url.hash(&mut hasher);
284 let mut supported_models = upstream.supported_models.iter().collect::<Vec<_>>();
285 supported_models.sort_by(|left, right| left.0.cmp(right.0));
286 supported_models.hash(&mut hasher);
287 let mut model_mapping = upstream.model_mapping.iter().collect::<Vec<_>>();
288 model_mapping.sort_by(|left, right| left.0.cmp(right.0));
289 model_mapping.hash(&mut hasher);
290 upstream.auth.auth_token.hash(&mut hasher);
291 upstream.auth.auth_token_env.hash(&mut hasher);
292 upstream.auth.api_key.hash(&mut hasher);
293 upstream.auth.api_key_env.hash(&mut hasher);
294 }
295 }
296 hasher.finish()
297}
298
299async fn fetch_service_status_snapshot(
300 config: &ServiceStatusConfig,
301 runtime_config: Option<&ProxyConfig>,
302 service_name: &str,
303) -> ServiceStatusSnapshot {
304 let client = match Client::builder()
305 .timeout(Duration::from_millis(config.timeout_ms.max(1)))
306 .connect_timeout(Duration::from_millis(config.timeout_ms.max(1)))
307 .build()
308 {
309 Ok(client) => client,
310 Err(err) => {
311 return ServiceStatusSnapshot {
312 generated_at_ms: unix_now_ms(),
313 configured: config.is_active(),
314 enabled: config.enabled,
315 refresh_interval_secs: config.refresh_interval_secs,
316 history_cells: config.history_cells,
317 probes: Vec::new(),
318 error: Some(format!("service status client setup failed: {err}")),
319 };
320 }
321 };
322
323 let mut futures = config
324 .probes
325 .iter()
326 .filter(|probe| probe_has_target(probe))
327 .map(|probe| fetch_probe(&client, config, runtime_config, service_name, probe))
328 .collect::<FuturesUnordered<_>>();
329
330 let mut probes = Vec::new();
331 while let Some(probe) = futures.next().await {
332 probes.push(probe);
333 }
334 probes.sort_by(|a, b| a.id.cmp(&b.id));
335
336 ServiceStatusSnapshot {
337 generated_at_ms: unix_now_ms(),
338 configured: true,
339 enabled: config.enabled,
340 refresh_interval_secs: config.refresh_interval_secs,
341 history_cells: config.history_cells,
342 probes,
343 error: None,
344 }
345}
346
347async fn fetch_probe(
348 client: &Client,
349 config: &ServiceStatusConfig,
350 runtime_config: Option<&ProxyConfig>,
351 service_name: &str,
352 probe: &ServiceStatusProbeConfig,
353) -> ServiceStatusProbeSnapshot {
354 let fetched_at_ms = unix_now_ms();
355 let id = probe_id(probe);
356 match fetch_probe_inner(client, config, runtime_config, service_name, probe).await {
357 Ok(mut snapshot) => {
358 snapshot.id = id;
359 snapshot.fetched_at_ms = fetched_at_ms;
360 snapshot
361 }
362 Err(err) => ServiceStatusProbeSnapshot {
363 id,
364 url: probe_target_label(probe),
365 fetched_at_ms,
366 generated_at_ms: None,
367 all_ok: None,
368 services: probe
369 .models
370 .iter()
371 .filter(|model| !model.trim().is_empty())
372 .map(|model| missing_service_row(model, config.history_cells))
373 .collect(),
374 error: Some(err.to_string()),
375 },
376 }
377}
378
379async fn fetch_probe_inner(
380 client: &Client,
381 config: &ServiceStatusConfig,
382 runtime_config: Option<&ProxyConfig>,
383 service_name: &str,
384 probe: &ServiceStatusProbeConfig,
385) -> Result<ServiceStatusProbeSnapshot> {
386 if has_provider_probe_target(probe) {
387 return fetch_provider_probe(client, config, runtime_config, service_name, probe).await;
388 }
389 let Some(url) = probe
390 .url
391 .as_deref()
392 .map(str::trim)
393 .filter(|value| !value.is_empty())
394 else {
395 anyhow::bail!("service status probe needs provider or url");
396 };
397 let timeout_ms = probe.timeout_ms.unwrap_or(config.timeout_ms).max(1);
398 let mut request = client.get(url).timeout(Duration::from_millis(timeout_ms));
399 for (name, value) in &probe.headers {
400 request = request.header(name, value);
401 }
402 let response = request
403 .send()
404 .await
405 .with_context(|| format!("request failed for {url}"))?;
406 let status = response.status();
407 let body = response
408 .text()
409 .await
410 .with_context(|| format!("read response body from {url}"))?;
411 if !status.is_success() {
412 anyhow::bail!("status API returned HTTP {status}");
413 }
414 snapshot_from_status_json(
415 probe,
416 config.history_cells,
417 probe.high_latency_ms.unwrap_or(config.high_latency_ms),
418 &body,
419 )
420 .with_context(|| format!("decode service status response from {url}"))
421}
422
423async fn fetch_provider_probe(
424 client: &Client,
425 config: &ServiceStatusConfig,
426 runtime_config: Option<&ProxyConfig>,
427 service_name: &str,
428 probe: &ServiceStatusProbeConfig,
429) -> Result<ServiceStatusProbeSnapshot> {
430 let target = provider_probe_target(runtime_config, service_name, probe)?;
431 let timeout_ms = probe.timeout_ms.unwrap_or(config.timeout_ms).max(1);
432 let high_latency_ms = probe.high_latency_ms.unwrap_or(config.high_latency_ms);
433 let models = provider_probe_models(probe, &target);
434 if models.is_empty() {
435 anyhow::bail!(
436 "provider probe has no model; configure models for {}",
437 target.provider_id
438 );
439 }
440
441 let mut services = Vec::with_capacity(models.len());
442 for model in models {
443 services.push(
444 fetch_provider_model_probe(
445 client,
446 &target,
447 &model,
448 timeout_ms,
449 high_latency_ms,
450 config.history_cells,
451 )
452 .await,
453 );
454 }
455
456 let all_ok = Some(
457 !services.is_empty()
458 && services.iter().all(|service| {
459 matches!(
460 service.latest_kind,
461 ServiceStatusKind::Ok | ServiceStatusKind::Slow
462 )
463 }),
464 );
465
466 Ok(ServiceStatusProbeSnapshot {
467 id: probe_id(probe),
468 url: provider_target_label(&target),
469 fetched_at_ms: 0,
470 generated_at_ms: Some(unix_now_ms()),
471 all_ok,
472 services,
473 error: None,
474 })
475}
476
477async fn fetch_provider_model_probe(
478 client: &Client,
479 target: &ProviderProbeTarget,
480 requested_model: &str,
481 timeout_ms: u64,
482 high_latency_ms: u64,
483 history_cells: usize,
484) -> ServiceStatusServiceSnapshot {
485 let started = Instant::now();
486 let model = crate::model_routing::effective_model(&target.model_mapping, requested_model);
487 let url = chat_completions_url(&target.base_url);
488 let result = send_provider_probe_request(client, target, &url, &model, timeout_ms).await;
489 let latency_ms = started.elapsed().as_millis() as u64;
490 let sample = match result {
491 Ok(()) => ServiceStatusProbeSample {
492 ts_ms: Some(unix_now_ms()),
493 ok: Some(true),
494 latency_ms: Some(latency_ms),
495 error: None,
496 },
497 Err(err) => ServiceStatusProbeSample {
498 ts_ms: Some(unix_now_ms()),
499 ok: Some(false),
500 latency_ms: Some(latency_ms),
501 error: Some(err.to_string()),
502 },
503 };
504 let kind = classify_probe(Some(&sample), high_latency_ms);
505 let missing_count = history_cells.saturating_sub(1);
506 let mut history = Vec::with_capacity(history_cells.max(1));
507 history.extend((0..missing_count).map(|_| ServiceStatusCellSnapshot {
508 kind: ServiceStatusKind::Unknown,
509 probe: None,
510 }));
511 history.push(ServiceStatusCellSnapshot {
512 kind,
513 probe: Some(sample.clone()),
514 });
515
516 ServiceStatusServiceSnapshot {
517 model: requested_model.to_string(),
518 uptime_pct: None,
519 latest_kind: kind,
520 latest: Some(sample),
521 history,
522 }
523}
524
525async fn send_provider_probe_request(
526 client: &Client,
527 target: &ProviderProbeTarget,
528 url: &str,
529 model: &str,
530 timeout_ms: u64,
531) -> Result<()> {
532 let mut request = client
533 .post(url)
534 .timeout(Duration::from_millis(timeout_ms))
535 .json(&serde_json::json!({
536 "model": model,
537 "messages": [
538 { "role": "user", "content": "ping" }
539 ],
540 "max_tokens": 1,
541 "temperature": 0,
542 "stream": false
543 }));
544
545 if let Some(token) = target.auth.resolve_auth_token() {
546 request = request.bearer_auth(token);
547 }
548 if let Some(key) = target.auth.resolve_api_key() {
549 request = request.header("x-api-key", key);
550 }
551
552 let response = request
553 .send()
554 .await
555 .with_context(|| format!("probe request failed for {}", target.provider_id))?;
556 let status = response.status();
557 if !status.is_success() {
558 let body = response.text().await.unwrap_or_default();
559 anyhow::bail!("provider probe HTTP {status}: {}", concise_body(&body));
560 }
561 Ok(())
562}
563
564fn provider_probe_target(
565 runtime_config: Option<&ProxyConfig>,
566 service_name: &str,
567 probe: &ServiceStatusProbeConfig,
568) -> Result<ProviderProbeTarget> {
569 let provider = probe
570 .provider
571 .as_deref()
572 .map(str::trim)
573 .filter(|value| !value.is_empty())
574 .context("provider probe missing provider")?;
575 let endpoint = probe
576 .endpoint
577 .as_deref()
578 .map(str::trim)
579 .filter(|value| !value.is_empty());
580 let cfg = runtime_config.context("provider probes require runtime config")?;
581 let mgr = service_manager(cfg, service_name)
582 .with_context(|| format!("unknown service for provider probe: {service_name}"))?;
583
584 provider_probe_targets(mgr, provider, endpoint)
585 .into_iter()
586 .next()
587 .with_context(|| {
588 endpoint
589 .map(|endpoint| {
590 format!("provider probe target not found: {service_name}/{provider}/{endpoint}")
591 })
592 .unwrap_or_else(|| {
593 format!("provider probe target not found: {service_name}/{provider}")
594 })
595 })
596}
597
598fn provider_probe_targets(
599 mgr: &ServiceConfigManager,
600 provider: &str,
601 endpoint: Option<&str>,
602) -> Vec<ProviderProbeTarget> {
603 let mut targets = Vec::new();
604 for station in mgr.stations().values() {
605 for upstream in &station.upstreams {
606 let upstream_provider = upstream
607 .tags
608 .get("provider_id")
609 .map(String::as_str)
610 .unwrap_or(station.name.as_str());
611 if upstream_provider != provider {
612 continue;
613 }
614 let upstream_endpoint = upstream
615 .tags
616 .get("endpoint_id")
617 .map(String::as_str)
618 .unwrap_or("default");
619 if endpoint.is_some_and(|endpoint| endpoint != upstream_endpoint) {
620 continue;
621 }
622 targets.push(ProviderProbeTarget {
623 provider_id: upstream_provider.to_string(),
624 endpoint_id: upstream_endpoint.to_string(),
625 base_url: upstream.base_url.clone(),
626 auth: upstream.auth.clone(),
627 supported_models: upstream.supported_models.clone(),
628 model_mapping: upstream.model_mapping.clone(),
629 });
630 }
631 }
632 targets.sort_by(|left, right| {
633 left.provider_id
634 .cmp(&right.provider_id)
635 .then_with(|| left.endpoint_id.cmp(&right.endpoint_id))
636 .then_with(|| left.base_url.cmp(&right.base_url))
637 });
638 targets
639}
640
641fn provider_probe_models(
642 probe: &ServiceStatusProbeConfig,
643 target: &ProviderProbeTarget,
644) -> Vec<String> {
645 let mut models = if probe.models.is_empty() {
646 target
647 .supported_models
648 .iter()
649 .filter(|(model, supported)| **supported && !model.contains('*'))
650 .map(|(model, _)| model.clone())
651 .collect::<Vec<_>>()
652 } else {
653 probe.models.clone()
654 };
655 if models.is_empty() {
656 models = target
657 .model_mapping
658 .keys()
659 .filter(|model| !model.contains('*'))
660 .cloned()
661 .collect();
662 }
663 models.retain(|model| !model.trim().is_empty());
664 models.sort();
665 models.dedup();
666 models
667}
668
669fn service_manager<'a>(
670 cfg: &'a ProxyConfig,
671 service_name: &str,
672) -> Option<&'a ServiceConfigManager> {
673 match service_name {
674 "claude" => Some(&cfg.claude),
675 "codex" => Some(&cfg.codex),
676 _ => None,
677 }
678}
679
680fn chat_completions_url(base_url: &str) -> String {
681 let base = base_url.trim().trim_end_matches('/');
682 if base.ends_with("/chat/completions") {
683 base.to_string()
684 } else {
685 format!("{base}/chat/completions")
686 }
687}
688
689fn provider_target_label(target: &ProviderProbeTarget) -> String {
690 format!(
691 "{}/{} {}",
692 target.provider_id, target.endpoint_id, target.base_url
693 )
694}
695
696fn probe_has_target(probe: &ServiceStatusProbeConfig) -> bool {
697 has_provider_probe_target(probe)
698 || probe
699 .url
700 .as_deref()
701 .is_some_and(|value| !value.trim().is_empty())
702}
703
704fn has_provider_probe_target(probe: &ServiceStatusProbeConfig) -> bool {
705 probe
706 .provider
707 .as_deref()
708 .is_some_and(|value| !value.trim().is_empty())
709}
710
711fn probe_target_label(probe: &ServiceStatusProbeConfig) -> String {
712 if let Some(provider) = probe
713 .provider
714 .as_deref()
715 .map(str::trim)
716 .filter(|value| !value.is_empty())
717 {
718 return match probe
719 .endpoint
720 .as_deref()
721 .map(str::trim)
722 .filter(|value| !value.is_empty())
723 {
724 Some(endpoint) => format!("{provider}/{endpoint}"),
725 None => provider.to_string(),
726 };
727 }
728 probe
729 .url
730 .as_deref()
731 .map(str::trim)
732 .filter(|value| !value.is_empty())
733 .unwrap_or("-")
734 .to_string()
735}
736
737fn concise_body(body: &str) -> String {
738 let body = body.trim();
739 if body.is_empty() {
740 return "-".to_string();
741 }
742 if body.chars().count() <= 180 {
743 body.to_string()
744 } else {
745 format!("{}...", body.chars().take(180).collect::<String>())
746 }
747}
748
749fn snapshot_from_status_json(
750 probe: &ServiceStatusProbeConfig,
751 history_cells: usize,
752 high_latency_ms: u64,
753 body: &str,
754) -> Result<ServiceStatusProbeSnapshot> {
755 let raw: RawServiceStatusResponse = serde_json::from_str(body)?;
756 let services_by_model = raw
757 .services
758 .into_iter()
759 .map(|service| (service.model.clone(), service))
760 .collect::<HashMap<_, _>>();
761 let models = if probe.models.is_empty() {
762 let mut models = services_by_model.keys().cloned().collect::<Vec<_>>();
763 models.sort();
764 models
765 } else {
766 probe.models.clone()
767 };
768
769 let services = models
770 .into_iter()
771 .filter(|model| !model.trim().is_empty())
772 .map(|model| {
773 services_by_model
774 .get(model.as_str())
775 .map(|service| service_snapshot(service, history_cells, high_latency_ms))
776 .unwrap_or_else(|| missing_service_row(model.as_str(), history_cells))
777 })
778 .collect::<Vec<_>>();
779
780 Ok(ServiceStatusProbeSnapshot {
781 id: probe_id(probe),
782 url: probe_target_label(probe),
783 fetched_at_ms: 0,
784 generated_at_ms: raw.generated_at.as_ref().and_then(ms_from_json),
785 all_ok: raw.all_ok,
786 services,
787 error: None,
788 })
789}
790
791fn service_snapshot(
792 raw: &RawServiceStatusService,
793 history_cells: usize,
794 high_latency_ms: u64,
795) -> ServiceStatusServiceSnapshot {
796 let latest = raw.last.as_ref().map(sample_from_raw);
797 let latest_kind = classify_probe(latest.as_ref(), high_latency_ms);
798 let mut history = raw
799 .history
800 .iter()
801 .rev()
802 .take(history_cells)
803 .map(sample_from_raw)
804 .collect::<Vec<_>>();
805 history.reverse();
806 let missing_count = history_cells.saturating_sub(history.len());
807 let mut cells = Vec::with_capacity(history_cells);
808 cells.extend((0..missing_count).map(|_| ServiceStatusCellSnapshot {
809 kind: ServiceStatusKind::Unknown,
810 probe: None,
811 }));
812 cells.extend(history.into_iter().map(|sample| ServiceStatusCellSnapshot {
813 kind: classify_probe(Some(&sample), high_latency_ms),
814 probe: Some(sample),
815 }));
816
817 ServiceStatusServiceSnapshot {
818 model: raw.model.clone(),
819 uptime_pct: raw.uptime_pct.as_ref().and_then(decimal_string_from_json),
820 latest_kind,
821 latest,
822 history: cells,
823 }
824}
825
826fn missing_service_row(model: &str, history_cells: usize) -> ServiceStatusServiceSnapshot {
827 ServiceStatusServiceSnapshot {
828 model: model.to_string(),
829 uptime_pct: None,
830 latest_kind: ServiceStatusKind::Unknown,
831 latest: None,
832 history: (0..history_cells)
833 .map(|_| ServiceStatusCellSnapshot {
834 kind: ServiceStatusKind::Unknown,
835 probe: None,
836 })
837 .collect(),
838 }
839}
840
841fn classify_probe(
842 sample: Option<&ServiceStatusProbeSample>,
843 high_latency_ms: u64,
844) -> ServiceStatusKind {
845 let Some(sample) = sample else {
846 return ServiceStatusKind::Unknown;
847 };
848 match sample.ok {
849 Some(false) => ServiceStatusKind::Failed,
850 Some(true) => match sample.latency_ms {
851 Some(latency) if latency >= high_latency_ms => ServiceStatusKind::Slow,
852 Some(_) => ServiceStatusKind::Ok,
853 None => ServiceStatusKind::Unknown,
854 },
855 None => ServiceStatusKind::Unknown,
856 }
857}
858
859fn sample_from_raw(raw: &RawServiceStatusProbe) -> ServiceStatusProbeSample {
860 ServiceStatusProbeSample {
861 ts_ms: raw.ts.as_ref().and_then(ms_from_json),
862 ok: raw.ok,
863 latency_ms: raw.latency_ms.as_ref().and_then(u64_from_json),
864 error: raw
865 .error
866 .as_deref()
867 .map(str::trim)
868 .filter(|value| !value.is_empty())
869 .map(str::to_string),
870 }
871}
872
873fn probe_id(probe: &ServiceStatusProbeConfig) -> String {
874 probe
875 .id
876 .as_deref()
877 .map(str::trim)
878 .filter(|value| !value.is_empty())
879 .map(str::to_string)
880 .unwrap_or_else(|| probe_target_label(probe))
881}
882
883fn unix_now_ms() -> u64 {
884 std::time::SystemTime::now()
885 .duration_since(std::time::UNIX_EPOCH)
886 .map(|d| d.as_millis() as u64)
887 .unwrap_or(0)
888}
889
890fn ms_from_json(value: &serde_json::Value) -> Option<u64> {
891 let raw = match value {
892 serde_json::Value::Number(number) => number.as_f64()?,
893 serde_json::Value::String(text) => text.trim().parse::<f64>().ok()?,
894 _ => return None,
895 };
896 if raw <= 0.0 {
897 return None;
898 }
899 if raw < 10_000_000_000.0 {
900 Some((raw * 1_000.0) as u64)
901 } else {
902 Some(raw as u64)
903 }
904}
905
906fn u64_from_json(value: &serde_json::Value) -> Option<u64> {
907 match value {
908 serde_json::Value::Number(number) => number.as_f64().map(|value| value.max(0.0) as u64),
909 serde_json::Value::String(text) => text
910 .trim()
911 .parse::<f64>()
912 .ok()
913 .map(|value| value.max(0.0) as u64),
914 _ => None,
915 }
916}
917
918fn decimal_string_from_json(value: &serde_json::Value) -> Option<String> {
919 match value {
920 serde_json::Value::Number(number) => Some(number.to_string()),
921 serde_json::Value::String(text) => {
922 let text = text.trim();
923 (!text.is_empty()).then(|| text.to_string())
924 }
925 _ => None,
926 }
927}
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932 use std::sync::Arc;
933
934 use axum::extract::State;
935 use axum::http::HeaderMap;
936 use axum::routing::post;
937 use axum::{Json, Router};
938
939 use crate::config::{ServiceConfig, UpstreamConfig};
940
941 #[derive(Debug, Clone, Default)]
942 struct CapturedProviderProbeRequest {
943 body: serde_json::Value,
944 authorization: Option<String>,
945 api_key: Option<String>,
946 }
947
948 fn probe(models: Vec<&str>) -> ServiceStatusProbeConfig {
949 ServiceStatusProbeConfig {
950 id: Some("openai".to_string()),
951 provider: None,
952 endpoint: None,
953 url: Some("https://status.example.com/api/status".to_string()),
954 models: models.into_iter().map(str::to_string).collect(),
955 timeout_ms: None,
956 high_latency_ms: None,
957 headers: Default::default(),
958 }
959 }
960
961 #[test]
962 fn service_status_json_decodes_cells_and_missing_models() {
963 let snapshot = snapshot_from_status_json(
964 &probe(vec!["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]),
965 4,
966 3_000,
967 r#"{
968 "all_ok": true,
969 "generated_at": 1778762578,
970 "services": [
971 {
972 "model": "gpt-5.5",
973 "uptime_pct": "81.67",
974 "last": { "ts": 1778762557, "ok": true, "latency_ms": 1111, "error": null },
975 "history": [
976 { "ts": 1, "ok": true, "latency_ms": 1000, "error": null },
977 { "ts": 2, "ok": true, "latency_ms": 3000, "error": null },
978 { "ts": 3, "ok": false, "latency_ms": null, "error": "timeout" }
979 ]
980 },
981 {
982 "model": "gpt-5.4-mini",
983 "uptime_pct": 65,
984 "last": { "ts": 4, "ok": true, "latency_ms": 3001, "error": null },
985 "history": [
986 { "ts": 4, "ok": true, "latency_ms": 3001, "error": null }
987 ]
988 }
989 ]
990 }"#,
991 )
992 .expect("snapshot");
993
994 assert_eq!(snapshot.id, "openai");
995 assert_eq!(snapshot.generated_at_ms, Some(1_778_762_578_000));
996 assert_eq!(snapshot.services.len(), 3);
997 assert_eq!(snapshot.services[0].latest_kind, ServiceStatusKind::Ok);
998 assert_eq!(snapshot.services[0].history.len(), 4);
999 assert_eq!(
1000 snapshot.services[0]
1001 .history
1002 .iter()
1003 .map(|cell| cell.kind)
1004 .collect::<Vec<_>>(),
1005 vec![
1006 ServiceStatusKind::Unknown,
1007 ServiceStatusKind::Ok,
1008 ServiceStatusKind::Slow,
1009 ServiceStatusKind::Failed,
1010 ]
1011 );
1012 assert_eq!(snapshot.services[1].latest_kind, ServiceStatusKind::Unknown);
1013 assert_eq!(snapshot.services[2].latest_kind, ServiceStatusKind::Slow);
1014 }
1015
1016 #[tokio::test]
1017 async fn provider_probe_sends_minimal_chat_completion_request() {
1018 async fn handler(
1019 State(captured): State<Arc<Mutex<Option<CapturedProviderProbeRequest>>>>,
1020 headers: HeaderMap,
1021 Json(body): Json<serde_json::Value>,
1022 ) -> Json<serde_json::Value> {
1023 let authorization = headers
1024 .get(axum::http::header::AUTHORIZATION)
1025 .and_then(|value| value.to_str().ok())
1026 .map(str::to_string);
1027 let api_key = headers
1028 .get("x-api-key")
1029 .and_then(|value| value.to_str().ok())
1030 .map(str::to_string);
1031 *captured.lock().expect("captured request lock") = Some(CapturedProviderProbeRequest {
1032 body,
1033 authorization,
1034 api_key,
1035 });
1036 Json(serde_json::json!({
1037 "id": "chatcmpl-probe",
1038 "object": "chat.completion",
1039 "choices": [
1040 { "message": { "role": "assistant", "content": "ok" } }
1041 ]
1042 }))
1043 }
1044
1045 let captured = Arc::new(Mutex::new(None));
1046 let app = Router::new()
1047 .route("/v1/chat/completions", post(handler))
1048 .with_state(captured.clone());
1049 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1050 listener.set_nonblocking(true).expect("set nonblocking");
1051 let addr = listener.local_addr().expect("local addr");
1052 let listener = tokio::net::TcpListener::from_std(listener).expect("to tokio listener");
1053 let server = tokio::spawn(async move {
1054 axum::serve(listener, app)
1055 .await
1056 .expect("serve provider probe test");
1057 });
1058
1059 let mut runtime = ProxyConfig::default();
1060 runtime.codex.configs.insert(
1061 "relay".to_string(),
1062 ServiceConfig {
1063 name: "relay".to_string(),
1064 alias: None,
1065 enabled: true,
1066 level: 1,
1067 upstreams: vec![UpstreamConfig {
1068 base_url: format!("http://{addr}/v1"),
1069 auth: UpstreamAuth {
1070 auth_token: Some("secret-token".to_string()),
1071 auth_token_env: None,
1072 api_key: Some("api-key".to_string()),
1073 api_key_env: None,
1074 },
1075 tags: HashMap::from([
1076 ("provider_id".to_string(), "relay".to_string()),
1077 ("endpoint_id".to_string(), "default".to_string()),
1078 ]),
1079 supported_models: HashMap::from([("gpt-5.5".to_string(), true)]),
1080 model_mapping: HashMap::from([(
1081 "gpt-5.5".to_string(),
1082 "upstream-gpt-5.5".to_string(),
1083 )]),
1084 }],
1085 },
1086 );
1087 let config = ServiceStatusConfig {
1088 enabled: true,
1089 refresh_interval_secs: 60,
1090 timeout_ms: 3_000,
1091 high_latency_ms: 3_000,
1092 history_cells: 4,
1093 probes: vec![ServiceStatusProbeConfig {
1094 id: Some("relay".to_string()),
1095 provider: Some("relay".to_string()),
1096 endpoint: Some("default".to_string()),
1097 url: None,
1098 models: vec!["gpt-5.5".to_string()],
1099 timeout_ms: None,
1100 high_latency_ms: None,
1101 headers: Default::default(),
1102 }],
1103 };
1104
1105 let snapshot = fetch_service_status_snapshot(&config, Some(&runtime), "codex").await;
1106 server.abort();
1107
1108 assert_eq!(snapshot.probes.len(), 1);
1109 let probe = &snapshot.probes[0];
1110 assert_eq!(probe.error, None);
1111 assert_eq!(probe.all_ok, Some(true));
1112 assert_eq!(probe.services.len(), 1);
1113 assert_eq!(probe.services[0].model, "gpt-5.5");
1114 assert_eq!(probe.services[0].latest_kind, ServiceStatusKind::Ok);
1115 assert_eq!(probe.services[0].history.len(), 4);
1116
1117 let captured = captured
1118 .lock()
1119 .expect("captured request lock")
1120 .clone()
1121 .expect("captured provider probe request");
1122 assert_eq!(
1123 captured.authorization.as_deref(),
1124 Some("Bearer secret-token")
1125 );
1126 assert_eq!(captured.api_key.as_deref(), Some("api-key"));
1127 assert_eq!(captured.body["model"], "upstream-gpt-5.5");
1128 assert_eq!(captured.body["max_tokens"], 1);
1129 assert_eq!(captured.body["temperature"], 0);
1130 assert_eq!(captured.body["stream"], false);
1131 assert_eq!(captured.body["messages"][0]["role"], "user");
1132 assert_eq!(captured.body["messages"][0]["content"], "ping");
1133 }
1134}