use serde::Deserialize;
use super::{StatRow, StatRowKind, ThirdPartyError, ThirdPartyStats, UsageBar};
use crate::usage::epoch_secs_to_iso;
pub(super) const DISPLAY_NAME: &str = "Z.ai";
pub(super) const ORIGIN: &str = "https://api.z.ai";
const QUOTA_PATH: &str = "/api/monitor/usage/quota/limit";
const MODEL_USAGE_PATH: &str = "/api/monitor/usage/model-usage";
const MODEL_USAGE_WINDOW_SECS: i64 = 7 * 86_400;
pub(super) fn matches_base_url(url: &str) -> bool {
super::url_matches_host(url, ORIGIN)
}
pub(super) fn fetch(api_key: &str) -> Result<ThirdPartyStats, ThirdPartyError> {
let quota_text = super::get_json(&format!("{ORIGIN}{QUOTA_PATH}"), api_key)?;
let quota: ZaiEnvelope<QuotaData> =
serde_json::from_str("a_text).map_err(|_| ThirdPartyError::Parse)?;
if !quota.success {
return Err(ThirdPartyError::Status);
}
let mut stats = quota_stats("a.data);
let now = crate::usage::now_epoch_secs();
if let Some(rows) = fetch_model_rows(api_key, now - MODEL_USAGE_WINDOW_SECS, now) {
stats.rows.extend(rows);
}
Ok(stats)
}
fn quota_stats(data: &QuotaData) -> ThirdPartyStats {
let mut bars: Vec<UsageBar> = Vec::new();
let mut detail_rows: Vec<StatRow> = Vec::new();
let mut ordered: Vec<&Limit> = data.limits.iter().collect();
ordered.sort_by_key(|l| window_secs(l.unit, l.number).unwrap_or(i64::MAX));
for limit in ordered {
let pct = limit.percentage.clamp(0.0, 100.0);
let used = limit.current_value;
let total = match (limit.current_value, limit.remaining) {
(Some(c), Some(r)) => Some(c + r),
_ => limit.usage,
};
bars.push(UsageBar {
label: limit_label(limit),
pct,
resets_at: limit.next_reset_time.map(ms_to_iso),
used,
total,
});
if limit.usage_details.iter().any(|d| d.usage > 0.0) {
detail_rows.push(StatRow {
label: limit_label(limit),
value: String::new(),
kind: StatRowKind::Heading,
});
for d in &limit.usage_details {
detail_rows.push(StatRow {
label: d.model_code.clone(),
value: fmt_count(d.usage),
kind: StatRowKind::Body,
});
}
}
}
ThirdPartyStats {
is_available: true,
rows: detail_rows,
bars,
plan: data.level.clone(),
endpoint: None,
best_effort: false,
}
}
fn fetch_model_rows(api_key: &str, start_secs: i64, end_secs: i64) -> Option<Vec<StatRow>> {
let url = format!(
"{ORIGIN}{MODEL_USAGE_PATH}?startTime={}&endTime={}",
url_encode(&secs_to_zai_date(start_secs)),
url_encode(&secs_to_zai_date(end_secs)),
);
let text = super::get_json(&url, api_key).ok()?;
let env: ZaiEnvelope<ModelUsageData> = serde_json::from_str(&text).ok()?;
if !env.success {
return None;
}
Some(model_rows(&env.data.total_usage))
}
fn model_rows(total: &ModelTotalUsage) -> Vec<StatRow> {
if total
.model_summary_list
.iter()
.all(|m| m.total_tokens <= 0.0)
{
return Vec::new();
}
let mut rows = vec![StatRow {
label: "7d tokens".to_string(),
value: String::new(),
kind: StatRowKind::Heading,
}];
for m in &total.model_summary_list {
rows.push(StatRow {
label: m.model_name.clone(),
value: fmt_count(m.total_tokens),
kind: StatRowKind::Body,
});
}
rows.push(StatRow {
label: "total".to_string(),
value: format!(
"{} ({} calls)",
fmt_count(total.total_tokens_usage),
fmt_count(total.total_model_call_count)
),
kind: StatRowKind::Faint,
});
rows
}
fn limit_label(limit: &Limit) -> String {
window_label(limit.unit, limit.number).unwrap_or_else(|| type_label(&limit.limit_type))
}
fn type_label(limit_type: &str) -> String {
limit_type.to_ascii_lowercase().replace('_', " ")
}
fn window_secs(unit: Option<i64>, number: Option<i64>) -> Option<i64> {
let n = number.filter(|&n| n > 0)?;
let unit_secs = match unit? {
3 => 3600, 6 => 7 * 86_400, 5 => 30 * 86_400, _ => return None,
};
Some(n * unit_secs)
}
fn window_label(unit: Option<i64>, number: Option<i64>) -> Option<String> {
let secs = window_secs(unit, number)?;
Some(if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
})
}
fn fmt_count(n: f64) -> String {
if !n.is_finite() || n <= 0.0 {
return "0".to_string();
}
if n >= 1e9 {
format!("{:.1}B", n / 1e9)
} else if n >= 1e6 {
format!("{:.1}M", n / 1e6)
} else if n >= 1e3 {
format!("{:.1}k", n / 1e3)
} else {
format!("{n:.0}")
}
}
fn ms_to_iso(ms: i64) -> String {
epoch_secs_to_iso(ms / 1000)
}
fn secs_to_zai_date(secs: i64) -> String {
let iso = epoch_secs_to_iso(secs); let date = iso.get(..19).unwrap_or(&iso);
date.replacen('T', " ", 1)
}
fn url_encode(s: &str) -> String {
s.chars()
.map(|c| match c {
' ' => "%20".to_string(),
':' => "%3A".to_string(),
other => other.to_string(),
})
.collect()
}
#[derive(Debug, Deserialize)]
struct ZaiEnvelope<T: Default> {
#[serde(default)]
success: bool,
#[serde(default)]
data: T,
}
#[derive(Debug, Default, Deserialize)]
struct QuotaData {
#[serde(default)]
limits: Vec<Limit>,
#[serde(default)]
level: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Limit {
#[serde(rename = "type", default)]
limit_type: String,
#[serde(default)]
unit: Option<i64>,
#[serde(default)]
number: Option<i64>,
#[serde(default)]
percentage: f64,
#[serde(rename = "nextResetTime", default)]
next_reset_time: Option<i64>,
#[serde(default)]
usage: Option<f64>,
#[serde(rename = "currentValue", default)]
current_value: Option<f64>,
#[serde(default)]
remaining: Option<f64>,
#[serde(rename = "usageDetails", default)]
usage_details: Vec<UsageDetail>,
}
#[derive(Debug, Deserialize)]
struct UsageDetail {
#[serde(rename = "modelCode", default)]
model_code: String,
#[serde(default)]
usage: f64,
}
#[derive(Debug, Default, Deserialize)]
struct ModelUsageData {
#[serde(rename = "totalUsage", default)]
total_usage: ModelTotalUsage,
}
#[derive(Debug, Default, Deserialize)]
struct ModelTotalUsage {
#[serde(rename = "totalModelCallCount", default)]
total_model_call_count: f64,
#[serde(rename = "totalTokensUsage", default)]
total_tokens_usage: f64,
#[serde(rename = "modelSummaryList", default)]
model_summary_list: Vec<ModelSummary>,
}
#[derive(Debug, Deserialize)]
struct ModelSummary {
#[serde(rename = "modelName", default)]
model_name: String,
#[serde(rename = "totalTokens", default)]
total_tokens: f64,
}
#[cfg(test)]
#[path = "../../tests/inline/providers_zai.rs"]
mod tests;