use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use crate::config::AlertsCfg;
pub struct AlertRuntime {
pub enabled: bool,
webhook_url: String,
budget_threshold: f64,
client: reqwest::Client,
alerting: Mutex<HashMap<String, bool>>,
}
impl AlertRuntime {
pub fn build(cfg: &AlertsCfg) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(cfg.timeout_ms.max(1)))
.build()
.unwrap_or_default();
AlertRuntime {
enabled: cfg.enabled && !cfg.webhook_url.trim().is_empty(),
webhook_url: cfg.webhook_url.trim().to_string(),
budget_threshold: cfg.budget_consumed_threshold,
client,
alerting: Mutex::new(HashMap::new()),
}
}
pub fn disabled() -> Self {
Self::build(&AlertsCfg::default())
}
pub fn decide_budget(&self, budget: &str, ratio: f64) -> bool {
if !self.enabled || !ratio.is_finite() {
return false;
}
let over = ratio >= self.budget_threshold;
let mut state = self.alerting.lock().expect("alert state mutex poisoned");
let was_over = state.get(budget).copied().unwrap_or(false);
if over {
if was_over {
false } else {
state.insert(budget.to_string(), true);
true
}
} else {
state.insert(budget.to_string(), false); false
}
}
pub fn fire_budget_alert(&self, budget: &str, ratio: f64) {
if !self.decide_budget(budget, ratio) {
return;
}
let text = format!(
"⚠️ EdgeGuard: LLM budget \"{budget}\" at {:.0}% of its limit (alert threshold {:.0}%).",
ratio * 100.0,
self.budget_threshold * 100.0
);
let body = serde_json::json!({ "text": text });
let client = self.client.clone();
let url = self.webhook_url.clone();
tokio::spawn(async move {
match client.post(&url).json(&body).send().await {
Ok(resp) if resp.status().is_success() => {}
Ok(resp) => tracing::debug!(status = %resp.status(), "alert webhook rejected"),
Err(e) => tracing::debug!(error = %e, "alert webhook failed"),
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rt(threshold: f64) -> AlertRuntime {
AlertRuntime::build(&AlertsCfg {
enabled: true,
webhook_url: "http://127.0.0.1:1/hook".into(),
budget_consumed_threshold: threshold,
..AlertsCfg::default()
})
}
#[test]
fn budget_alert_is_edge_triggered_per_crossing() {
let a = rt(0.9);
assert!(a.decide_budget("monthly", 0.95));
assert!(!a.decide_budget("monthly", 0.96));
assert!(!a.decide_budget("monthly", 1.20));
assert!(!a.decide_budget("monthly", 0.50));
assert!(a.decide_budget("monthly", 0.91));
assert!(a.decide_budget("daily", 0.90)); }
#[test]
fn disabled_or_missing_url_never_fires() {
let off = AlertRuntime::disabled();
assert!(!off.decide_budget("b", 5.0));
let no_url = AlertRuntime::build(&AlertsCfg {
enabled: true,
webhook_url: " ".into(), ..AlertsCfg::default()
});
assert!(!no_url.enabled);
assert!(!no_url.decide_budget("b", 5.0));
}
#[test]
fn non_finite_ratio_never_fires() {
let a = rt(0.9);
assert!(!a.decide_budget("b", f64::NAN));
assert!(!a.decide_budget("b", f64::INFINITY)); }
}