use std::fmt;
use chrono::{DateTime, FixedOffset, TimeZone, Utc};
use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
use crate::{ZaiResult, client::ZaiClient};
fn parse_next_reset_time(raw: &str) -> Option<DateTime<Utc>> {
if let Ok(timestamp) = raw.parse::<i64>() {
return if timestamp.unsigned_abs() >= 10_000_000_000 {
Utc.timestamp_millis_opt(timestamp).single()
} else {
Utc.timestamp_opt(timestamp, 0).single()
};
}
DateTime::parse_from_rfc3339(raw)
.ok()
.map(|datetime| datetime.with_timezone(&Utc))
}
fn beijing_offset() -> Option<FixedOffset> {
FixedOffset::east_opt(8 * 60 * 60)
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CodingPlanUsageData {
#[serde(
default,
deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
skip_serializing_if = "Option::is_none"
)]
pub level: Option<String>,
#[serde(default)]
pub limits: Vec<CodingPlanQuotaLimit>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CodingPlanUsageDetail {
#[serde(
default,
rename = "modelCode",
alias = "model_code",
deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
skip_serializing_if = "Option::is_none"
)]
pub model_code: Option<String>,
#[serde(default)]
pub usage: u64,
}
impl fmt::Display for CodingPlanUsageDetail {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}: {}",
self.model_code.as_deref().unwrap_or("unknown"),
self.usage
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodingPlanQuotaKind {
TimeLimit,
TokensLimit,
Other(String),
}
impl CodingPlanQuotaKind {
pub fn as_str(&self) -> &str {
match self {
CodingPlanQuotaKind::TimeLimit => "TIME_LIMIT",
CodingPlanQuotaKind::TokensLimit => "TOKENS_LIMIT",
CodingPlanQuotaKind::Other(type_) => type_,
}
}
}
#[derive(Debug, Clone)]
pub struct CodingPlanQuotaSummary {
pub kind: CodingPlanQuotaKind,
pub type_: String,
pub unit: Option<String>,
pub number: u64,
pub reported_usage: Option<u64>,
pub current_value: Option<u64>,
pub reported_remaining: Option<u64>,
pub quota: u64,
pub used: u64,
pub remaining: u64,
pub percentage: f64,
pub next_reset_time: Option<String>,
pub next_reset_at: Option<DateTime<Utc>>,
pub usage_details: Vec<CodingPlanUsageDetail>,
}
fn display_opt<T>(value: Option<T>) -> String
where
T: fmt::Display,
{
value
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string())
}
impl CodingPlanQuotaSummary {
pub fn is_time_limit(&self) -> bool {
self.kind == CodingPlanQuotaKind::TimeLimit
}
pub fn is_tokens_limit(&self) -> bool {
self.kind == CodingPlanQuotaKind::TokensLimit
}
pub fn used_ratio(&self) -> f64 {
if self.quota == 0 {
return 0.0;
}
(self.used as f64 / self.quota as f64).clamp(0.0, 1.0)
}
pub fn usage_for_model(&self, model_code: &str) -> Option<u64> {
self.usage_details
.iter()
.find(|detail| detail.model_code.as_deref() == Some(model_code))
.map(|detail| detail.usage)
}
pub fn next_reset_at_beijing(&self) -> Option<DateTime<FixedOffset>> {
self.next_reset_at
.as_ref()
.and_then(|datetime| beijing_offset().map(|tz| datetime.with_timezone(&tz)))
}
}
impl fmt::Display for CodingPlanQuotaSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}:", self.type_)?;
writeln!(f, " kind: {}", self.kind.as_str())?;
writeln!(f, " unit: {}", display_opt(self.unit.as_deref()))?;
writeln!(f, " number: {}", self.number)?;
writeln!(f, " usage: {}", display_opt(self.reported_usage))?;
writeln!(f, " current_value: {}", display_opt(self.current_value))?;
writeln!(
f,
" reported_remaining: {}",
display_opt(self.reported_remaining)
)?;
writeln!(f, " quota: {}", self.quota)?;
writeln!(f, " used: {}", self.used)?;
writeln!(f, " remaining: {}", self.remaining)?;
writeln!(f, " percentage: {:.1}%", self.percentage)?;
writeln!(
f,
" next_reset_time: {}",
display_opt(self.next_reset_time.as_deref())
)?;
writeln!(
f,
" next_reset_at_utc: {}",
display_opt(self.next_reset_at.as_ref().map(DateTime::to_rfc3339))
)?;
writeln!(
f,
" next_reset_at_beijing: {}",
display_opt(
self.next_reset_at_beijing()
.as_ref()
.map(DateTime::to_rfc3339)
)
)?;
if self.usage_details.is_empty() {
writeln!(f, " usage_details: []")?;
} else {
writeln!(f, " usage_details:")?;
for detail in &self.usage_details {
writeln!(f, " - {detail}")?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct CodingPlanUsageSummary {
pub code: i64,
pub msg: Option<String>,
pub success: bool,
pub level: Option<String>,
pub limits: Vec<CodingPlanQuotaSummary>,
}
impl CodingPlanUsageSummary {
pub fn find_limit(&self, type_: &str) -> Option<&CodingPlanQuotaSummary> {
self.limits
.iter()
.find(|limit| limit.type_.eq_ignore_ascii_case(type_))
}
pub fn time_limit(&self) -> Option<&CodingPlanQuotaSummary> {
self.limits
.iter()
.find(|limit| limit.kind == CodingPlanQuotaKind::TimeLimit)
}
pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaSummary> {
self.limits
.iter()
.find(|limit| limit.kind == CodingPlanQuotaKind::TokensLimit)
}
}
impl fmt::Display for CodingPlanUsageSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Coding Plan Usage")?;
writeln!(f, "code: {}", self.code)?;
writeln!(f, "msg: {}", display_opt(self.msg.as_deref()))?;
writeln!(f, "success: {}", self.success)?;
writeln!(f, "level: {}", display_opt(self.level.as_deref()))?;
if self.limits.is_empty() {
writeln!(f, "limits: []")?;
return Ok(());
}
writeln!(f, "limits:")?;
for (index, limit) in self.limits.iter().enumerate() {
if index > 0 {
writeln!(f)?;
}
write!(f, "{limit}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodingPlanQuotaLimit {
#[serde(rename = "type")]
pub type_: String,
#[serde(
default,
deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
skip_serializing_if = "Option::is_none"
)]
pub unit: Option<String>,
#[serde(default)]
pub number: u64,
#[serde(default)]
pub percentage: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<u64>,
#[serde(
default,
rename = "currentValue",
alias = "current_value",
skip_serializing_if = "Option::is_none"
)]
pub current_value: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remaining: Option<u64>,
#[serde(
default,
rename = "nextResetTime",
alias = "next_reset_time",
deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
skip_serializing_if = "Option::is_none"
)]
pub next_reset_time: Option<String>,
#[serde(
default,
rename = "usageDetails",
alias = "usage_details",
skip_serializing_if = "Vec::is_empty"
)]
pub usage_details: Vec<CodingPlanUsageDetail>,
}
impl CodingPlanQuotaLimit {
pub fn kind(&self) -> CodingPlanQuotaKind {
if self.is_time_limit() {
CodingPlanQuotaKind::TimeLimit
} else if self.is_tokens_limit() {
CodingPlanQuotaKind::TokensLimit
} else {
CodingPlanQuotaKind::Other(self.type_.clone())
}
}
pub fn quota(&self) -> u64 {
self.usage.unwrap_or(self.number)
}
pub fn remaining(&self) -> u64 {
if let Some(remaining) = self.remaining {
return remaining;
}
if let (Some(usage), Some(current_value)) = (self.usage, self.current_value) {
return usage.saturating_sub(current_value);
}
if self.number == 0 {
return 0;
}
let consumed = (self.number as f64) * self.used_fraction();
self.number.saturating_sub(consumed.round() as u64)
}
pub fn remaining_ratio(&self) -> f64 {
if self.number == 0 {
return 0.0;
}
1.0 - self.used_fraction()
}
pub fn consumed(&self) -> u64 {
if let Some(current_value) = self.current_value {
return current_value;
}
if let (Some(usage), Some(remaining)) = (self.usage, self.remaining) {
return usage.saturating_sub(remaining);
}
let consumed = (self.number as f64) * self.used_fraction();
consumed.round() as u64
}
fn used_fraction(&self) -> f64 {
if self.percentage.is_nan() {
0.0
} else {
(self.percentage / 100.0).clamp(0.0, 1.0)
}
}
pub fn next_reset_at(&self) -> Option<DateTime<Utc>> {
self.next_reset_time
.as_deref()
.and_then(parse_next_reset_time)
}
pub fn usage_for_model(&self, model_code: &str) -> Option<u64> {
self.usage_details
.iter()
.find(|detail| detail.model_code.as_deref() == Some(model_code))
.map(|detail| detail.usage)
}
pub fn next_reset_at_beijing(&self) -> Option<DateTime<FixedOffset>> {
self.next_reset_at()
.and_then(|datetime| beijing_offset().map(|tz| datetime.with_timezone(&tz)))
}
pub fn summary(&self) -> CodingPlanQuotaSummary {
CodingPlanQuotaSummary {
kind: self.kind(),
type_: self.type_.clone(),
unit: self.unit.clone(),
number: self.number,
reported_usage: self.usage,
current_value: self.current_value,
reported_remaining: self.remaining,
quota: self.quota(),
used: self.consumed(),
remaining: self.remaining(),
percentage: self.percentage,
next_reset_time: self.next_reset_time.clone(),
next_reset_at: self.next_reset_at(),
usage_details: self.usage_details.clone(),
}
}
pub fn is_time_limit(&self) -> bool {
self.type_.eq_ignore_ascii_case("TIME_LIMIT")
}
pub fn is_tokens_limit(&self) -> bool {
self.type_.eq_ignore_ascii_case("TOKENS_LIMIT")
}
}
impl fmt::Display for CodingPlanQuotaLimit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.summary().fmt(f)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CodingPlanUsageResponse {
#[serde(default)]
pub code: i64,
#[serde(
default,
rename = "msg",
deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
skip_serializing_if = "Option::is_none"
)]
pub msg: Option<String>,
#[serde(default)]
pub success: bool,
#[serde(default)]
pub data: CodingPlanUsageData,
}
#[derive(Deserialize)]
struct CodingPlanUsageResponseWire {
code: Option<i64>,
#[serde(
default,
rename = "msg",
deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string"
)]
msg: Option<String>,
success: Option<bool>,
data: Option<CodingPlanUsageData>,
}
impl<'de> Deserialize<'de> for CodingPlanUsageResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = CodingPlanUsageResponseWire::deserialize(deserializer)?;
if wire.code.is_none()
&& wire.msg.is_none()
&& wire.success.is_none()
&& wire.data.is_none()
{
return Err(D::Error::custom(
"coding-plan usage response contained no documented non-null fields",
));
}
Ok(Self {
code: wire.code.unwrap_or_default(),
msg: wire.msg,
success: wire.success.unwrap_or_default(),
data: wire.data.unwrap_or_default(),
})
}
}
impl CodingPlanUsageResponse {
pub fn limits(&self) -> &[CodingPlanQuotaLimit] {
&self.data.limits
}
pub fn find_limit(&self, type_: &str) -> Option<&CodingPlanQuotaLimit> {
self.data
.limits
.iter()
.find(|l| l.type_.eq_ignore_ascii_case(type_))
}
pub fn time_limit(&self) -> Option<&CodingPlanQuotaLimit> {
self.find_limit("TIME_LIMIT")
}
pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaLimit> {
self.find_limit("TOKENS_LIMIT")
}
pub fn level(&self) -> Option<&str> {
self.data.level.as_deref()
}
pub fn summary(&self) -> CodingPlanUsageSummary {
CodingPlanUsageSummary {
code: self.code,
msg: self.msg.clone(),
success: self.success,
level: self.data.level.clone(),
limits: self
.data
.limits
.iter()
.map(CodingPlanQuotaLimit::summary)
.collect(),
}
}
pub fn time_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
self.time_limit().map(CodingPlanQuotaLimit::summary)
}
pub fn tokens_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
self.tokens_limit().map(CodingPlanQuotaLimit::summary)
}
}
impl fmt::Display for CodingPlanUsageResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.summary().fmt(f)
}
}
pub struct CodingPlanUsageRequest;
impl CodingPlanUsageRequest {
pub fn new() -> Self {
Self
}
pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<CodingPlanUsageResponse> {
let route = crate::client::routes::USAGE_GET;
let url = client.endpoints().resolve_route(route, &[])?;
client
.send_empty::<CodingPlanUsageResponse>(route.method(), url)
.await
}
}
impl Default for CodingPlanUsageRequest {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_typical_envelope() {
let raw = r#"{
"code": 200,
"msg": "ok",
"success": true,
"data": {
"level": "max",
"limits": [
{
"type": "TIME_LIMIT",
"unit": "5h",
"number": 600,
"percentage": 25.0,
"nextResetTime": "2026-06-18T15:00:00Z"
},
{
"type": "TOKENS_LIMIT",
"unit": "week",
"number": 1000000,
"percentage": 50.0,
"nextResetTime": "2026-06-22T00:00:00Z"
}
]
}
}"#;
let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
assert!(resp.success);
assert_eq!(resp.level(), Some("max"));
let t = resp.time_limit().unwrap();
assert!(t.is_time_limit());
assert_eq!(t.consumed(), 150);
assert_eq!(t.remaining(), 450);
assert!((t.remaining_ratio() - 0.75).abs() < 1e-9);
assert_eq!(t.next_reset_time.as_deref(), Some("2026-06-18T15:00:00Z"));
assert_eq!(
t.next_reset_at().unwrap().to_rfc3339(),
"2026-06-18T15:00:00+00:00"
);
let w = resp.tokens_limit().unwrap();
assert!(w.is_tokens_limit());
assert_eq!(w.consumed(), 500_000);
assert_eq!(w.remaining(), 500_000);
}
#[test]
fn deserializes_numeric_plan_level() {
let raw = r#"{
"code": 200,
"msg": "ok",
"success": true,
"data": {
"level": 3,
"limits": [
{
"type": "TIME_LIMIT",
"unit": 5,
"number": 600,
"percentage": 25.0,
"usage": 4000,
"currentValue": 54,
"remaining": 3946,
"nextResetTime": 1781778751996,
"usageDetails": [
{"modelCode": "search-prime", "usage": 40},
{"modelCode": "web-reader", "usage": 14}
]
}
]
}
}"#;
let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
assert_eq!(resp.level(), Some("3"));
let limit = resp.time_limit().unwrap();
assert_eq!(limit.unit.as_deref(), Some("5"));
assert_eq!(limit.quota(), 4000);
assert_eq!(limit.consumed(), 54);
assert_eq!(limit.remaining(), 3946);
assert_eq!(limit.usage_details.len(), 2);
assert_eq!(
limit.usage_details[0].model_code.as_deref(),
Some("search-prime")
);
assert_eq!(limit.usage_for_model("web-reader"), Some(14));
assert_eq!(limit.next_reset_time.as_deref(), Some("1781778751996"));
let summary = resp.summary();
assert_eq!(summary.code, 200);
assert_eq!(summary.msg.as_deref(), Some("ok"));
assert!(summary.success);
assert_eq!(summary.level.as_deref(), Some("3"));
let time_limit = summary.time_limit().unwrap();
assert_eq!(time_limit.kind, CodingPlanQuotaKind::TimeLimit);
assert_eq!(time_limit.number, 600);
assert_eq!(time_limit.reported_usage, Some(4000));
assert_eq!(time_limit.current_value, Some(54));
assert_eq!(time_limit.reported_remaining, Some(3946));
assert_eq!(time_limit.quota, 4000);
assert_eq!(time_limit.used, 54);
assert_eq!(time_limit.remaining, 3946);
assert_eq!(time_limit.usage_for_model("search-prime"), Some(40));
assert_eq!(
time_limit.next_reset_at.as_ref().unwrap().to_rfc3339(),
"2026-06-18T10:32:31.996+00:00"
);
assert_eq!(
time_limit
.next_reset_at_beijing()
.as_ref()
.unwrap()
.to_rfc3339(),
"2026-06-18T18:32:31.996+08:00"
);
let report = summary.to_string();
assert!(report.contains("code: 200"));
assert!(report.contains("msg: ok"));
assert!(report.contains("success: true"));
assert!(report.contains("number: 600"));
assert!(report.contains("current_value: 54"));
assert!(report.contains("reported_remaining: 3946"));
assert!(report.contains("next_reset_at_utc: 2026-06-18T10:32:31.996+00:00"));
assert!(report.contains("next_reset_at_beijing: 2026-06-18T18:32:31.996+08:00"));
assert!(report.contains("usage_details:"));
assert!(report.contains("search-prime: 40"));
}
#[test]
fn handles_missing_optional_fields() {
let raw = r#"{"code":0,"success":true,"data":{"limits":[]}}"#;
let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
assert!(resp.limits().is_empty());
assert!(resp.level().is_none());
assert!(resp.time_limit().is_none());
}
#[test]
fn rejects_empty_success_but_preserves_flexible_string_fields() {
assert!(serde_json::from_str::<CodingPlanUsageResponse>("{}").is_err());
assert!(
serde_json::from_str::<CodingPlanUsageResponse>(
r#"{"code":null,"msg":null,"success":null,"data":null}"#
)
.is_err()
);
let response: CodingPlanUsageResponse = serde_json::from_str(r#"{"msg":42}"#).unwrap();
assert_eq!(response.msg.as_deref(), Some("42"));
}
#[test]
fn remaining_is_zero_when_exhausted() {
let limit = CodingPlanQuotaLimit {
type_: "TIME_LIMIT".to_string(),
unit: Some("5h".to_string()),
number: 100,
percentage: 100.0,
usage: None,
current_value: None,
remaining: None,
next_reset_time: None,
usage_details: Vec::new(),
};
assert_eq!(limit.remaining(), 0);
assert_eq!(limit.consumed(), 100);
}
#[test]
fn monitor_family_resolves_official_endpoint() {
let ec = crate::client::endpoint::EndpointConfig::defaults().unwrap();
let url = ec
.resolve(
crate::client::ApiFamily::Monitor,
&["usage", "quota", "limit"],
)
.unwrap();
assert_eq!(
url,
"https://open.bigmodel.cn/api/monitor/usage/quota/limit"
);
}
#[test]
fn monitor_family_resolves_custom_base_via_builder() {
use crate::client::endpoint::EndpointConfig;
let ec = EndpointConfig::builder()
.monitor("https://api.z.ai/api/monitor")
.build(false)
.unwrap();
let url = ec
.resolve(
crate::client::ApiFamily::Monitor,
&["usage", "quota", "limit"],
)
.unwrap();
assert_eq!(url, "https://api.z.ai/api/monitor/usage/quota/limit");
}
}