#[derive(Debug, Clone)]
pub(super) struct ModelTriageReport {
pub models: Vec<ModelProbeResult>,
pub channels: Vec<ChannelProbeResult>,
}
#[derive(Debug, Clone)]
pub(super) struct ModelProbeResult {
pub model_id: String,
pub provider: String,
pub role: ModelRole,
pub key_status: KeyStatus,
pub reachable: Option<bool>,
pub probe_detail: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ModelRole {
Primary,
Fallback,
Routing,
Embedding,
}
impl std::fmt::Display for ModelRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Primary => write!(f, "primary"),
Self::Fallback => write!(f, "fallback"),
Self::Routing => write!(f, "routing"),
Self::Embedding => write!(f, "embedding"),
}
}
}
#[derive(Debug, Clone)]
pub(super) enum KeyStatus {
NotRequired,
Keystore { key_name: String },
EnvVar { env_name: String },
KeystoreMissing { key_name: String },
KeystoreBlank { key_name: String },
EnvMissing { env_name: String },
EnvBlank { env_name: String },
ProviderNotConfigured,
}
impl KeyStatus {
pub fn is_healthy(&self) -> bool {
matches!(
self,
Self::NotRequired | Self::Keystore { .. } | Self::EnvVar { .. }
)
}
pub fn summary(&self) -> &'static str {
match self {
Self::NotRequired => "local (no key needed)",
Self::Keystore { .. } => "keystore ✓",
Self::EnvVar { .. } => "env var ✓",
Self::KeystoreMissing { .. } => "keystore key MISSING",
Self::KeystoreBlank { .. } => "keystore key BLANK",
Self::EnvMissing { .. } => "env var NOT SET",
Self::EnvBlank { .. } => "env var EMPTY",
Self::ProviderNotConfigured => "provider NOT CONFIGURED",
}
}
pub fn severity(&self) -> &'static str {
match self {
Self::NotRequired | Self::Keystore { .. } | Self::EnvVar { .. } => "ok",
Self::KeystoreMissing { .. }
| Self::KeystoreBlank { .. }
| Self::EnvMissing { .. }
| Self::EnvBlank { .. } => "high",
Self::ProviderNotConfigured => "medium",
}
}
pub fn remediation(&self) -> String {
match self {
Self::NotRequired | Self::Keystore { .. } | Self::EnvVar { .. } => {
String::new()
}
Self::KeystoreMissing { key_name } => {
format!("roboticus keystore set {key_name} \"<YOUR_KEY>\"")
}
Self::KeystoreBlank { key_name } => {
format!("roboticus keystore set {key_name} \"<YOUR_KEY>\" (current value is blank)")
}
Self::EnvMissing { env_name } => {
format!("export {env_name}=\"<YOUR_KEY>\"")
}
Self::EnvBlank { env_name } => {
format!("export {env_name}=\"<YOUR_KEY>\" (current value is blank)")
}
Self::ProviderNotConfigured => {
"Add a [providers.<name>] section in roboticus.toml".to_string()
}
}
}
}
#[derive(Debug, Clone)]
pub(super) struct ChannelProbeResult {
pub channel: String,
pub enabled: bool,
pub key_status: KeyStatus,
pub reachable: Option<bool>,
pub probe_detail: Option<String>,
}
impl ModelTriageReport {
pub fn total_unhealthy(&self) -> usize {
self.models.iter().filter(|m| !m.key_status.is_healthy()).count()
+ self.channels.iter().filter(|c| c.enabled && !c.key_status.is_healthy()).count()
}
pub fn total_unreachable(&self) -> usize {
self.models.iter().filter(|m| m.reachable == Some(false)).count()
+ self.channels.iter().filter(|c| c.reachable == Some(false)).count()
}
}
pub(super) fn run_model_triage(
config: &roboticus_core::RoboticusConfig,
probe: bool,
) -> ModelTriageReport {
let keystore = roboticus_core::Keystore::new(roboticus_core::Keystore::default_path());
let keystore_available = keystore.unlock_machine().is_ok();
let mut models = Vec::new();
let mut model_roles: Vec<(String, ModelRole)> = Vec::new();
model_roles.push((config.models.primary.clone(), ModelRole::Primary));
for fb in &config.models.fallbacks {
model_roles.push((fb.clone(), ModelRole::Fallback));
}
if let Some(ref canary) = config.models.routing.canary_model
&& !canary.trim().is_empty()
{
model_roles.push((canary.clone(), ModelRole::Routing));
}
if let Some((embed_provider, _)) = resolve_embedding_provider(config) {
model_roles.push((embed_provider, ModelRole::Embedding));
}
let mut seen = std::collections::HashSet::new();
model_roles.retain(|(model, _)| seen.insert(model.clone()));
for (model_id, role) in &model_roles {
let provider_name = extract_provider_name(model_id);
let provider_cfg = config.providers.get(&provider_name);
let key_status = match provider_cfg {
None => KeyStatus::ProviderNotConfigured,
Some(p) => resolve_key_status(
&provider_name,
p.is_local.unwrap_or(false),
p.api_key_ref.as_deref(),
p.api_key_env.as_deref(),
p.auth_mode.as_deref(),
&keystore,
keystore_available,
),
};
let (reachable, probe_detail) = if probe && key_status.is_healthy() {
if let Some(p) = provider_cfg {
probe_provider(&provider_name, &p.url, p.is_local.unwrap_or(false), &key_status)
} else {
(None, None)
}
} else {
(None, None)
};
models.push(ModelProbeResult {
model_id: model_id.clone(),
provider: provider_name,
role: *role,
key_status,
reachable,
probe_detail,
});
}
let mut channels = Vec::new();
if let Some(ref tg) = config.channels.telegram {
let key_status = if tg.enabled {
resolve_channel_key_status(
&tg.token_ref,
&tg.token_env,
"telegram_bot_token",
&keystore,
keystore_available,
)
} else {
KeyStatus::NotRequired
};
let (reachable, probe_detail) = if probe && tg.enabled && key_status.is_healthy() {
probe_telegram_token(&key_status, &keystore, &tg.token_ref, &tg.token_env)
} else {
(None, None)
};
channels.push(ChannelProbeResult {
channel: "Telegram".to_string(),
enabled: tg.enabled,
key_status,
reachable,
probe_detail,
});
}
if let Some(ref wa) = config.channels.whatsapp {
let key_status = if wa.enabled {
resolve_channel_key_status(
&wa.token_ref,
&wa.token_env,
"whatsapp_token",
&keystore,
keystore_available,
)
} else {
KeyStatus::NotRequired
};
channels.push(ChannelProbeResult {
channel: "WhatsApp".to_string(),
enabled: wa.enabled,
key_status,
reachable: None,
probe_detail: None,
});
}
if let Some(ref dc) = config.channels.discord {
let key_status = if dc.enabled {
resolve_channel_key_status(
&dc.token_ref,
&dc.token_env,
"discord_bot_token",
&keystore,
keystore_available,
)
} else {
KeyStatus::NotRequired
};
channels.push(ChannelProbeResult {
channel: "Discord".to_string(),
enabled: dc.enabled,
key_status,
reachable: None,
probe_detail: None,
});
}
if let Some(ref sg) = config.channels.signal {
let (reachable, probe_detail) = if probe && sg.enabled {
probe_url_reachable(&sg.daemon_url)
} else {
(None, None)
};
channels.push(ChannelProbeResult {
channel: "Signal".to_string(),
enabled: sg.enabled,
key_status: KeyStatus::NotRequired,
reachable,
probe_detail,
});
}
ModelTriageReport { models, channels }
}
fn extract_provider_name(model_id: &str) -> String {
model_id
.split('/')
.next()
.unwrap_or(model_id)
.to_string()
}
fn resolve_embedding_provider(
config: &roboticus_core::RoboticusConfig,
) -> Option<(String, String)> {
for (name, p) in &config.providers {
if p.embedding_model.is_some() && p.embedding_path.is_some() {
let model_label = format!(
"{}/{}",
name,
p.embedding_model.as_deref().unwrap_or("default")
);
return Some((model_label, name.clone()));
}
}
None
}
fn resolve_key_status(
provider_name: &str,
is_local: bool,
api_key_ref: Option<&str>,
api_key_env: Option<&str>,
auth_mode: Option<&str>,
keystore: &roboticus_core::Keystore,
keystore_available: bool,
) -> KeyStatus {
use roboticus_core::keystore::{self, KeySource};
let source = keystore::resolve_key_source(
provider_name,
is_local,
api_key_ref,
api_key_env,
auth_mode,
keystore,
);
match source {
KeySource::NotRequired => KeyStatus::NotRequired,
KeySource::OAuth => KeyStatus::EnvVar {
env_name: "(oauth)".into(),
},
KeySource::Keystore(_val) => {
let key_name = api_key_ref
.and_then(|r| r.strip_prefix("keystore:"))
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{provider_name}_api_key"));
KeyStatus::Keystore { key_name }
}
KeySource::EnvVar(_val) => {
let env_name = api_key_env
.unwrap_or("")
.to_string();
KeyStatus::EnvVar { env_name }
}
KeySource::Missing => {
if !keystore_available
&& let Some(ks_name) = api_key_ref.and_then(|r| r.strip_prefix("keystore:"))
{
return KeyStatus::KeystoreMissing {
key_name: ks_name.to_string(),
};
}
if let Some(env_name) = api_key_env
&& !env_name.is_empty()
{
return match std::env::var(env_name) {
Ok(val) if val.trim().is_empty() => KeyStatus::EnvBlank {
env_name: env_name.to_string(),
},
_ => KeyStatus::EnvMissing {
env_name: env_name.to_string(),
},
};
}
KeyStatus::EnvMissing {
env_name: format!("{}_API_KEY", provider_name.to_ascii_uppercase()),
}
}
}
}
fn resolve_channel_key_status(
token_ref: &Option<String>,
token_env: &str,
conventional_ks_name: &str,
keystore: &roboticus_core::Keystore,
keystore_available: bool,
) -> KeyStatus {
if let Some(r) = token_ref
&& let Some(ks_name) = r.strip_prefix("keystore:")
{
if !keystore_available {
return KeyStatus::KeystoreMissing {
key_name: ks_name.to_string(),
};
}
return match keystore.get(ks_name) {
Some(val) if val.trim().is_empty() => KeyStatus::KeystoreBlank {
key_name: ks_name.to_string(),
},
Some(_) => KeyStatus::Keystore {
key_name: ks_name.to_string(),
},
None => KeyStatus::KeystoreMissing {
key_name: ks_name.to_string(),
},
};
}
if keystore_available
&& let Some(val) = keystore.get(conventional_ks_name)
{
if val.trim().is_empty() {
return KeyStatus::KeystoreBlank {
key_name: conventional_ks_name.to_string(),
};
}
return KeyStatus::Keystore {
key_name: conventional_ks_name.to_string(),
};
}
if !token_env.is_empty() {
return match std::env::var(token_env) {
Ok(val) if val.trim().is_empty() => KeyStatus::EnvBlank {
env_name: token_env.to_string(),
},
Ok(_) => KeyStatus::EnvVar {
env_name: token_env.to_string(),
},
Err(_) => KeyStatus::EnvMissing {
env_name: token_env.to_string(),
},
};
}
KeyStatus::EnvMissing {
env_name: format!("{}_TOKEN", conventional_ks_name.to_ascii_uppercase()),
}
}
fn probe_provider(
provider_name: &str,
base_url: &str,
is_local: bool,
key_status: &KeyStatus,
) -> (Option<bool>, Option<String>) {
let key_value = match key_status {
KeyStatus::Keystore { key_name } => {
let ks = roboticus_core::Keystore::new(roboticus_core::Keystore::default_path());
let _ = ks.unlock_machine();
ks.get(key_name)
}
KeyStatus::EnvVar { env_name } => std::env::var(env_name).ok(),
KeyStatus::NotRequired => None,
_ => return (None, None),
};
if is_local {
return probe_url_reachable(base_url);
}
let probe_url = match provider_name {
"openai" | "openrouter" | "moonshot" => format!("{base_url}/v1/models"),
"anthropic" => {
return probe_url_reachable(base_url);
}
"google" => format!("{base_url}/v1beta/models"),
_ => return probe_url_reachable(base_url),
};
let Some(key) = key_value else {
return probe_url_reachable(base_url);
};
match ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(8))
.build()
.get(&probe_url)
.set("Authorization", &format!("Bearer {key}"))
.call()
{
Ok(resp) => {
let status = resp.status();
if status == 200 {
(Some(true), Some(format!("{probe_url} → 200 OK")))
} else {
(
Some(false),
Some(format!("{probe_url} → {status} (key may be invalid)")),
)
}
}
Err(ureq::Error::Status(status, _)) => {
if status == 401 || status == 403 {
(
Some(false),
Some(format!(
"{probe_url} → {status} (authentication failed — key is likely invalid)"
)),
)
} else {
(
Some(false),
Some(format!("{probe_url} → HTTP {status}")),
)
}
}
Err(e) => (
Some(false),
Some(format!("{probe_url} → network error: {e}")),
),
}
}
fn probe_telegram_token(
key_status: &KeyStatus,
keystore: &roboticus_core::Keystore,
token_ref: &Option<String>,
token_env: &str,
) -> (Option<bool>, Option<String>) {
let token = match key_status {
KeyStatus::Keystore { key_name } => keystore.get(key_name),
KeyStatus::EnvVar { env_name } => std::env::var(env_name).ok(),
_ => return (None, None),
};
let Some(token) = token else {
return (None, None);
};
let url = format!("https://api.telegram.org/bot{token}/getMe");
match ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(8))
.build()
.get(&url)
.call()
{
Ok(resp) if resp.status() == 200 => {
(Some(true), Some("Telegram getMe → 200 OK (token valid)".to_string()))
}
Ok(resp) => (
Some(false),
Some(format!(
"Telegram getMe → {} (token likely invalid)",
resp.status()
)),
),
Err(ureq::Error::Status(401, _)) => (
Some(false),
Some("Telegram getMe → 401 Unauthorized (token is invalid/revoked)".to_string()),
),
Err(e) => (
Some(false),
Some(format!("Telegram getMe → network error: {e}")),
),
}
}
fn probe_url_reachable(base_url: &str) -> (Option<bool>, Option<String>) {
match ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(5))
.build()
.get(base_url)
.call()
{
Ok(_) => (Some(true), Some(format!("{base_url} → reachable"))),
Err(ureq::Error::Status(status, _)) => {
(Some(true), Some(format!("{base_url} → reachable (HTTP {status})")))
}
Err(e) => (
Some(false),
Some(format!("{base_url} → unreachable: {e}")),
),
}
}