Skip to main content

codex_helper_core/
service_status.rs

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}