Skip to main content

synaps_cli/runtime/openai/
ping.rs

1//! Model ping / health check.
2//!
3//! Sends a minimal chat completion (`max_tokens: 1`, message `"hi"`) to each
4//! configured model in parallel and classifies the response.
5
6use std::collections::BTreeMap;
7use std::time::{Duration, Instant};
8
9use serde_json::json;
10
11use super::registry;
12use super::types::ProviderConfig;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PingStatus {
16    Online,
17    RateLimited,
18    Unauthorized,
19    NotFound,
20    Error,
21    Timeout,
22}
23
24impl PingStatus {
25    pub fn icon(&self) -> &'static str {
26        match self {
27            PingStatus::Online => "✅",
28            PingStatus::RateLimited => "⏳",
29            PingStatus::Unauthorized => "🔒",
30            PingStatus::NotFound => "❌",
31            PingStatus::Error => "⚠️",
32            PingStatus::Timeout => "⌛",
33        }
34    }
35
36    pub fn label(&self) -> &'static str {
37        match self {
38            PingStatus::Online => "online",
39            PingStatus::RateLimited => "429 rate limited",
40            PingStatus::Unauthorized => "401 unauthorized",
41            PingStatus::NotFound => "404 not found",
42            PingStatus::Error => "error",
43            PingStatus::Timeout => "timeout",
44        }
45    }
46}
47
48#[derive(Debug, Clone)]
49pub struct PingResult {
50    pub provider_key: String,
51    pub model_id: String,
52    pub status: PingStatus,
53    pub latency_ms: u64,
54}
55
56const TIMEOUT: Duration = Duration::from_secs(10);
57
58pub async fn ping_model(
59    client: &reqwest::Client,
60    cfg: &ProviderConfig,
61    provider_key: &str,
62) -> PingResult {
63    let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
64    let body = json!({
65        "model": cfg.model,
66        "messages": [{"role": "user", "content": "hi"}],
67        "max_tokens": 1,
68        "stream": false,
69    });
70
71    let start = Instant::now();
72    let fut = client
73        .post(&url)
74        .bearer_auth(&cfg.api_key)
75        .json(&body)
76        .send();
77
78    let status = match tokio::time::timeout(TIMEOUT, fut).await {
79        Err(_) => PingStatus::Timeout,
80        Ok(Err(_)) => PingStatus::Error,
81        Ok(Ok(resp)) => {
82            let code = resp.status().as_u16();
83            match code {
84                200..=299 => PingStatus::Online,
85                401 | 403 => PingStatus::Unauthorized,
86                404 => PingStatus::NotFound,
87                429 => PingStatus::RateLimited,
88                _ => PingStatus::Error,
89            }
90        }
91    };
92
93    PingResult {
94        provider_key: provider_key.to_string(),
95        model_id: cfg.model.clone(),
96        status,
97        latency_ms: start.elapsed().as_millis() as u64,
98    }
99}
100
101/// Ping every model of every configured provider in parallel.
102/// Results are sent through `tx` as they arrive (not batched).
103pub async fn ping_all_configured(
104    client: &reqwest::Client,
105    overrides: &BTreeMap<String, String>,
106    tx: tokio::sync::mpsc::UnboundedSender<(String, PingStatus, u64)>,
107) {
108    let specs = registry::providers();
109    let mut handles = Vec::new();
110
111    for spec in specs {
112        let Some(base_cfg) = registry::resolve_provider_model(spec.key, spec.default_model, overrides) else {
113            continue;
114        };
115        for (model_id, _label, _tier) in spec.models {
116            let cfg = ProviderConfig {
117                base_url: base_cfg.base_url.clone(),
118                api_key: base_cfg.api_key.clone(),
119                model: (*model_id).to_string(),
120                provider: base_cfg.provider.clone(),
121            };
122            let client = client.clone();
123            let key = spec.key.to_string();
124            let tx = tx.clone();
125            handles.push(tokio::spawn(async move {
126                let result = ping_model(&client, &cfg, &key).await;
127                let full_key = format!("{}/{}", result.provider_key, result.model_id);
128                let _ = tx.send((full_key, result.status, result.latency_ms));
129            }));
130        }
131    }
132
133    for h in handles {
134        let _ = h.await;
135    }
136    // tx drops here — receiver sees None and knows all pings are done
137}