use crate::context::AppContext;
use crate::providers;
use serde::Serialize;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::timeout;
const USAGE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Serialize)]
pub struct ProviderUsage {
pub provider: String,
pub supported: bool,
pub configured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
fn unsupported(name: &str, configured: bool) -> ProviderUsage {
ProviderUsage {
provider: name.to_string(),
supported: false,
configured,
data: None,
error: None,
}
}
async fn get_json(
ctx: &AppContext,
url: &str,
bearer: Option<&str>,
header: Option<(&str, &str)>,
) -> Result<serde_json::Value, String> {
let mut req = ctx.client.get(url);
if let Some(token) = bearer {
req = req.bearer_auth(token);
}
if let Some((k, v)) = header {
req = req.header(k, v);
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
let excerpt: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
let excerpt: String = excerpt.chars().take(160).collect();
return Err(format!("HTTP {status}: {excerpt}"));
}
resp.json::<serde_json::Value>().await.map_err(|e| e.to_string())
}
async fn serpapi_usage(ctx: &AppContext, key: String) -> Result<serde_json::Value, String> {
let url = format!("https://serpapi.com/account.json?api_key={key}");
let v = get_json(ctx, &url, None, None).await?;
Ok(serde_json::json!({
"credits_remaining": v.get("total_searches_left").or(v.get("plan_searches_left")).cloned(),
"plan_searches_left": v.get("plan_searches_left").cloned(),
"this_month_usage": v.get("this_month_usage").cloned(),
"searches_per_month": v.get("searches_per_month").cloned(),
"unit": "searches",
}))
}
async fn brave_usage(ctx: &AppContext, key: String) -> Result<serde_json::Value, String> {
let resp = ctx
.client
.get("https://api.search.brave.com/res/v1/web/search?q=a&count=1")
.header("X-Subscription-Token", key)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| e.to_string())?;
let remaining = resp
.headers()
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let status = resp.status();
if !status.is_success() {
return Err(format!("HTTP {status}"));
}
let monthly = remaining
.as_deref()
.and_then(|s| s.split(',').nth(1))
.and_then(|s| s.trim().parse::<u64>().ok());
Ok(serde_json::json!({
"credits_remaining": monthly,
"raw_rate_limit_remaining": remaining,
"unit": "requests (this month)",
"note": "read from X-RateLimit-Remaining headers; this check consumed one metered request",
}))
}
async fn xai_usage(ctx: &AppContext, _key: String) -> Result<serde_json::Value, String> {
let mgmt_key = std::env::var("XAI_MANAGEMENT_API_KEY").unwrap_or_default();
let team_id = std::env::var("XAI_TEAM_ID").unwrap_or_default();
if mgmt_key.is_empty() || team_id.is_empty() {
return Err(
"set XAI_MANAGEMENT_API_KEY and XAI_TEAM_ID (management key differs from the xai- inference key)"
.to_string(),
);
}
let url = format!("https://management-api.x.ai/v1/billing/teams/{team_id}/prepaid/balance");
let v = get_json(ctx, &url, Some(&mgmt_key), None).await?;
let cents = v
.get("total")
.and_then(|t| t.get("val"))
.and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.parse().ok())));
Ok(serde_json::json!({
"credits_remaining": cents.map(|c| c / 100.0),
"unit": "USD",
}))
}
async fn firecrawl_usage(ctx: &AppContext, key: String) -> Result<serde_json::Value, String> {
let v = match get_json(
ctx,
"https://api.firecrawl.dev/v2/team/credit-usage",
Some(&key),
None,
)
.await
{
Ok(v) => v,
Err(_) => {
get_json(
ctx,
"https://api.firecrawl.dev/v1/team/credit-usage",
Some(&key),
None,
)
.await?
}
};
let data = v.get("data").unwrap_or(&v);
Ok(serde_json::json!({
"credits_remaining": data.get("remainingCredits").or(data.get("remaining_credits")).cloned(),
"plan_credits": data.get("planCredits").or(data.get("plan_credits")).cloned(),
"unit": "credits",
}))
}
async fn tavily_usage(ctx: &AppContext, key: String) -> Result<serde_json::Value, String> {
let v = get_json(ctx, "https://api.tavily.com/usage", Some(&key), None).await?;
let account = v.get("account").unwrap_or(&v);
let plan_usage = account.get("plan_usage").and_then(|x| x.as_f64());
let plan_limit = account.get("plan_limit").and_then(|x| x.as_f64());
let remaining = match (plan_usage, plan_limit) {
(Some(u), Some(l)) => Some(l - u),
_ => None,
};
Ok(serde_json::json!({
"credits_remaining": remaining,
"plan_usage": plan_usage,
"plan_limit": plan_limit,
"current_plan": account.get("current_plan").cloned(),
"unit": "credits",
}))
}
pub async fn collect(ctx: Arc<AppContext>) -> Vec<ProviderUsage> {
type UsageFetch = fn(
&AppContext,
String,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send + '_>,
>;
fn fetcher(name: &str) -> Option<UsageFetch> {
match name {
"serpapi" => Some(|ctx, key| Box::pin(serpapi_usage(ctx, key))),
"firecrawl" => Some(|ctx, key| Box::pin(firecrawl_usage(ctx, key))),
"brave" => Some(|ctx, key| Box::pin(brave_usage(ctx, key))),
"xai" => Some(|ctx, key| Box::pin(xai_usage(ctx, key))),
"tavily" => Some(|ctx, key| Box::pin(tavily_usage(ctx, key))),
_ => None,
}
}
let all = providers::build_providers(&ctx);
let mut out = Vec::new();
let mut set: JoinSet<ProviderUsage> = JoinSet::new();
for p in &all {
let name = p.name();
let configured = p.is_configured();
match fetcher(name) {
None => out.push(unsupported(name, configured)),
Some(fetch) => {
if !configured {
out.push(ProviderUsage {
provider: name.to_string(),
supported: true,
configured: false,
data: None,
error: None,
});
continue;
}
let key = resolve_provider_key(&ctx, name);
let ctx2 = ctx.clone();
let name = name.to_string();
set.spawn(async move {
let result = timeout(USAGE_TIMEOUT, fetch(&ctx2, key)).await;
match result {
Ok(Ok(data)) => ProviderUsage {
provider: name,
supported: true,
configured: true,
data: Some(data),
error: None,
},
Ok(Err(e)) => ProviderUsage {
provider: name,
supported: true,
configured: true,
data: None,
error: Some(e),
},
Err(_) => ProviderUsage {
provider: name,
supported: true,
configured: true,
data: None,
error: Some("timed out".to_string()),
},
}
});
}
}
}
while let Some(Ok(u)) = set.join_next().await {
out.push(u);
}
out.sort_by(|a, b| a.provider.cmp(&b.provider));
out
}
fn resolve_provider_key(ctx: &AppContext, name: &str) -> String {
let k = &ctx.config.keys;
match name {
"serpapi" => providers::resolve_key(&k.serpapi, "SERPAPI_API_KEY"),
"firecrawl" => providers::resolve_key(&k.firecrawl, "FIRECRAWL_API_KEY"),
"exa" => providers::resolve_key(&k.exa, "EXA_API_KEY"),
"jina" => providers::resolve_key(&k.jina, "JINA_API_KEY"),
"tavily" => providers::resolve_key(&k.tavily, "TAVILY_API_KEY"),
"brave" => providers::resolve_key(&k.brave, "BRAVE_API_KEY"),
"serper" => providers::resolve_key(&k.serper, "SERPER_API_KEY"),
"perplexity" => providers::resolve_key(&k.perplexity, "PERPLEXITY_API_KEY"),
"browserless" => providers::resolve_key(&k.browserless, "BROWSERLESS_API_KEY"),
"parallel" => providers::resolve_key(&k.parallel, "PARALLEL_API_KEY"),
"xai" => providers::resolve_key(&k.xai, "XAI_API_KEY"),
_ => String::new(),
}
}