use serde_json::Value;
use super::{StatRow, StatRowKind, ThirdPartyError, ThirdPartyStats, UsageBar, api_origin};
const CANDIDATE_PATHS: &[&str] = &[
"/api/monitor/usage/quota/limit", "/api/oauth/usage", "/user/balance", "/v1/credits", "/api/credits",
"/api/usage",
"/usage",
];
pub(super) fn fetch(
base_url: &str,
api_key: &str,
hint: Option<&str>,
) -> Result<ThirdPartyStats, ThirdPartyError> {
let origin = api_origin(base_url).ok_or(ThirdPartyError::Network)?;
let mut ordered: Vec<&str> = Vec::with_capacity(CANDIDATE_PATHS.len() + 1);
if let Some(h) = hint.filter(|h| !h.is_empty()) {
ordered.push(h);
}
for p in CANDIDATE_PATHS {
if !ordered.contains(p) {
ordered.push(p);
}
}
for path in ordered {
let url = format!("{origin}{path}");
let text = match super::get_json(&url, api_key) {
Ok(t) => t,
Err(ThirdPartyError::RateLimited { retry_after }) => {
return Err(ThirdPartyError::RateLimited { retry_after });
}
Err(_) => continue, };
let Ok(value) = serde_json::from_str::<Value>(&text) else {
continue;
};
if is_error_envelope(&value) {
continue;
}
let (plan, bars, rows) = scan(&value);
if bars.is_empty() && rows.is_empty() && plan.is_none() {
continue; }
return Ok(ThirdPartyStats {
is_available: true,
rows,
bars,
plan,
endpoint: Some(path.to_string()),
best_effort: true,
});
}
Err(ThirdPartyError::Status)
}
fn is_error_envelope(value: &Value) -> bool {
let Some(obj) = value.as_object() else {
return false;
};
if obj.get("success").and_then(Value::as_bool) == Some(false) {
return true;
}
obj.get("code")
.and_then(Value::as_i64)
.is_some_and(|c| c >= 400)
|| obj
.get("status")
.and_then(Value::as_i64)
.is_some_and(|s| s >= 400)
}
fn scan(value: &Value) -> (Option<String>, Vec<UsageBar>, Vec<StatRow>) {
let mut plan = None;
let mut bars: Vec<UsageBar> = Vec::new();
scan_inner(value, &mut plan, &mut bars);
let rows = if bars.is_empty() {
let mut rows = Vec::new();
harvest_scalars(value, &mut rows);
dedup_rows(&mut rows);
rows
} else {
Vec::new()
};
(plan, bars, rows)
}
fn scan_inner(value: &Value, plan: &mut Option<String>, bars: &mut Vec<UsageBar>) {
match value {
Value::Object(obj) => {
if plan.is_none()
&& let Some(p) = find_plan(obj)
{
*plan = Some(p);
}
if let Some(bar) = extract_bar(obj) {
bars.push(bar);
}
for v in obj.values() {
scan_inner(v, plan, bars);
}
}
Value::Array(arr) => {
for v in arr {
scan_inner(v, plan, bars);
}
}
_ => {}
}
}
fn find_plan(obj: &serde_json::Map<String, Value>) -> Option<String> {
obj.iter().find_map(|(k, v)| {
if is_plan_key(k)
&& let Some(s) = v.as_str()
{
let s = s.trim();
(!s.is_empty() && s.len() <= 32).then(|| s.to_string())
} else {
None
}
})
}
fn extract_bar(obj: &serde_json::Map<String, Value>) -> Option<UsageBar> {
let pct = obj.iter().find_map(|(k, v)| {
is_pct_key(k)
.then(|| v.as_f64())
.flatten()
.filter(|&p| (0.0..=100.0).contains(&p))
})?;
let resets_at = obj
.iter()
.find_map(|(k, v)| is_reset_key(k).then(|| parse_reset(v)).flatten());
let label = obj
.iter()
.find_map(|(k, v)| {
is_label_key(k)
.then(|| v.as_str())
.flatten()
.map(humanize_label)
})
.unwrap_or_else(|| "usage".to_string());
let used = num_field(obj, is_used_key);
let total = num_field(obj, is_total_key)
.or_else(|| used.and_then(|u| num_field(obj, is_remaining_key).map(|r| u + r)));
Some(UsageBar {
label,
pct,
resets_at,
used,
total,
})
}
fn num_field(obj: &serde_json::Map<String, Value>, pred: fn(&str) -> bool) -> Option<f64> {
obj.iter()
.find_map(|(k, v)| pred(k).then(|| as_f64_loose(v)).flatten())
}
fn as_f64_loose(v: &Value) -> Option<f64> {
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.trim().parse::<f64>().ok()))
}
fn harvest_scalars(value: &Value, rows: &mut Vec<StatRow>) {
match value {
Value::Object(obj) => {
for (k, v) in obj.iter() {
if let Some(num) = scalar_value(k, v) {
rows.push(StatRow {
label: humanize_label(k),
value: num,
kind: StatRowKind::Body,
});
}
}
for v in obj.values() {
harvest_scalars(v, rows);
}
}
Value::Array(arr) => {
for v in arr {
harvest_scalars(v, rows);
}
}
_ => {}
}
}
fn scalar_value(key: &str, value: &Value) -> Option<String> {
if !is_scalar_key(key) {
return None;
}
if let Some(n) = value.as_f64() {
return Some(format_number(n));
}
if let Some(s) = value.as_str()
&& let Ok(n) = s.trim().parse::<f64>()
{
return Some(format_number(n));
}
None
}
fn dedup_rows(rows: &mut Vec<StatRow>) {
let mut seen: Vec<(String, String)> = Vec::with_capacity(rows.len());
rows.retain(|r| {
let key = (r.label.clone(), r.value.clone());
if seen.contains(&key) {
false
} else {
seen.push(key);
true
}
});
}
fn parse_reset(value: &Value) -> Option<String> {
if let Some(n) = value.as_i64() {
let secs = if n > 1_000_000_000_000 { n / 1000 } else { n };
return Some(crate::usage::epoch_secs_to_iso(secs));
}
if let Some(s) = value.as_str()
&& let Some(secs) = crate::usage::iso_to_epoch_secs(s)
{
return Some(crate::usage::epoch_secs_to_iso(secs));
}
None
}
fn format_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{n:.0}")
} else {
format!("{n:.2}")
}
}
fn humanize_label(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev_lower = false;
for ch in s.chars() {
if ch == '_' || ch == '-' || ch == ' ' {
out.push(' ');
prev_lower = false;
continue;
}
let is_lower = ch.is_lowercase();
if ch.is_uppercase() && prev_lower {
out.push(' ');
}
out.extend(ch.to_lowercase());
prev_lower = is_lower || ch.is_ascii_digit();
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn is_plan_key(k: &str) -> bool {
matches!(
k.to_ascii_lowercase().as_str(),
"level"
| "plan"
| "tier"
| "subscription"
| "subscription_type"
| "organization_type"
| "account_type"
)
}
fn is_pct_key(k: &str) -> bool {
matches!(
k.to_ascii_lowercase().as_str(),
"percentage"
| "percent"
| "pct"
| "utilization"
| "utilization_pct"
| "used_percent"
| "usedpercent"
| "used_pct"
)
}
fn is_reset_key(k: &str) -> bool {
matches!(
k.to_ascii_lowercase().as_str(),
"nextresettime"
| "resets_at"
| "reset_at"
| "resettime"
| "reset_time"
| "reset_date"
| "expiry"
| "expires"
| "expires_at"
| "expirestime"
)
}
fn is_label_key(k: &str) -> bool {
matches!(
k.to_ascii_lowercase().as_str(),
"type"
| "name"
| "modelcode"
| "model"
| "label"
| "title"
| "scope"
| "period"
| "category"
)
}
fn is_scalar_key(k: &str) -> bool {
matches!(
k.to_ascii_lowercase().as_str(),
"balance"
| "balances"
| "total"
| "total_balance"
| "granted_balance"
| "topped_up_balance"
| "credit"
| "credits"
| "remaining"
| "left"
| "usage"
| "used"
| "currentvalue"
| "tokens"
| "total_tokens"
| "cost"
| "amount"
| "quota"
)
}
fn is_used_key(k: &str) -> bool {
matches!(
k.to_ascii_lowercase().as_str(),
"currentvalue" | "current_value" | "used" | "consumed" | "spent" | "usedamount"
)
}
fn is_total_key(k: &str) -> bool {
matches!(
k.to_ascii_lowercase().as_str(),
"total" | "limit" | "quota" | "max" | "capacity" | "budget" | "allowance" | "ceiling"
)
}
fn is_remaining_key(k: &str) -> bool {
matches!(k.to_ascii_lowercase().as_str(), "remaining" | "left")
}
#[cfg(test)]
#[path = "../../tests/inline/providers_generic.rs"]
mod tests;