- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status) - Images: preview stage, purpose-based model choice and reference images (Nano Banana Pro edit refines the chosen preview) - Settings: restore a stored or uploaded backup (staged, applied on restart) - Provider form: test connection; saving or reloading re-checks the status at once - opencode Zen/Go: send x-opencode-session with a stable id per conversation - Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes - Fix: backups made in the same second no longer overwrite each other Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
3.0 KiB
Rust
86 lines
3.0 KiB
Rust
//! Provider status: every 5 minutes each provider's model list is fetched
|
|
//! (free, no tokens) to see whether the host answers.
|
|
|
|
use crate::app::App;
|
|
use serde::Serialize;
|
|
use std::collections::BTreeMap;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct ProviderStatus {
|
|
pub up: bool,
|
|
/// "ok", "auth" (key missing/rejected) or "down" (host does not answer properly).
|
|
pub state: &'static str,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub error: Option<String>,
|
|
pub latency_ms: u64,
|
|
pub checked_at: i64,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct Health {
|
|
status: Mutex<BTreeMap<String, ProviderStatus>>,
|
|
}
|
|
|
|
impl Health {
|
|
pub fn snapshot(&self) -> Vec<(String, bool)> {
|
|
self.status.lock().unwrap().iter().map(|(k, v)| (k.clone(), v.up)).collect()
|
|
}
|
|
|
|
pub fn all(&self) -> BTreeMap<String, ProviderStatus> {
|
|
self.status.lock().unwrap().clone()
|
|
}
|
|
}
|
|
|
|
/// Fetch the model list once and classify the answer.
|
|
pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderStatus, usize) {
|
|
let start = Instant::now();
|
|
let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await;
|
|
let (error, count): (Option<String>, usize) = match res {
|
|
Ok(Ok(m)) => (None, m.len()),
|
|
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
|
|
Err(_) => (Some("timed out".to_string()), 0),
|
|
};
|
|
// The host answered but has no model list: reachable.
|
|
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
|
|
let state = match &error {
|
|
None => "ok",
|
|
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
|
|
Some(_) => "down",
|
|
};
|
|
(ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() }, count)
|
|
}
|
|
|
|
pub async fn check_all(app: &App) {
|
|
let registry = app.registry();
|
|
let mut found = BTreeMap::new();
|
|
let checks = registry.providers.iter().map(|(name, entry)| {
|
|
let provider = entry.provider.clone();
|
|
let name = name.clone();
|
|
async move { (name, probe(provider).await.0) }
|
|
});
|
|
for (name, st) in futures::future::join_all(checks).await {
|
|
if !st.up {
|
|
let msg = if st.state == "auth" { "provider rejected the API key" } else { "provider unreachable" };
|
|
tracing::warn!(provider = %name, error = st.error.as_deref().unwrap_or(""), "{msg}");
|
|
}
|
|
found.insert(name, st);
|
|
}
|
|
*app.health.status.lock().unwrap() = found;
|
|
}
|
|
|
|
fn chrono_now() -> i64 {
|
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
|
|
}
|
|
|
|
pub fn spawn_checker(app: Arc<App>) {
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_secs(10)).await;
|
|
loop {
|
|
check_all(&app).await;
|
|
tokio::time::sleep(Duration::from_secs(300)).await;
|
|
}
|
|
});
|
|
}
|