use std::{fmt, sync::Arc};
use chrono::{DateTime, FixedOffset, TimeZone, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use crate::{
ZaiResult,
client::{
endpoints::{ApiBase, EndpointConfig, paths},
http::{HttpClient, HttpClientConfig, parse_typed_response},
},
};
fn de_opt_string_from_string_or_number<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Null => Ok(None),
serde_json::Value::String(value) => Ok(Some(value)),
serde_json::Value::Number(value) => Ok(Some(value.to_string())),
other => Err(serde::de::Error::custom(format!(
"expected string or number, got {other}"
))),
}
}
fn parse_next_reset_time(raw: &str) -> Option<DateTime<Utc>> {
if let Ok(timestamp) = raw.parse::<i64>() {
return if timestamp.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() -> FixedOffset {
FixedOffset::east_opt(8 * 60 * 60).expect("UTC+08:00 is a valid fixed offset")
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CodingPlanUsageData {
#[serde(
default,
deserialize_with = "de_opt_string_from_string_or_number",
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,
alias = "modelCode",
deserialize_with = "de_opt_string_from_string_or_number",
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()
.map(|datetime| datetime.with_timezone(&beijing_offset()))
}
}
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 = "de_opt_string_from_string_or_number",
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,
alias = "currentValue",
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,
alias = "nextResetTime",
deserialize_with = "de_opt_string_from_string_or_number",
skip_serializing_if = "Option::is_none"
)]
pub next_reset_time: Option<String>,
#[serde(default, alias = "usageDetails", 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.percentage / 100.0);
self.number.saturating_sub(consumed.round() as u64)
}
pub fn remaining_ratio(&self) -> f64 {
if self.number == 0 {
return 0.0;
}
1.0 - (self.percentage / 100.0).clamp(0.0, 1.0)
}
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.percentage / 100.0);
consumed.round() as u64
}
pub fn used(&self) -> u64 {
self.consumed()
}
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()
.map(|datetime| datetime.with_timezone(&beijing_offset()))
}
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.used(),
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, Deserialize)]
pub struct CodingPlanUsageResponse {
#[serde(default)]
pub code: i64,
#[serde(
default,
rename = "msg",
deserialize_with = "de_opt_string_from_string_or_number",
skip_serializing_if = "Option::is_none"
)]
pub msg: Option<String>,
#[serde(default)]
pub success: bool,
#[serde(default)]
pub data: CodingPlanUsageData,
}
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 {
pub key: String,
url: String,
endpoint_config: EndpointConfig,
api_base: ApiBase,
http_config: Arc<HttpClientConfig>,
_body: (),
}
impl CodingPlanUsageRequest {
pub fn new(key: String) -> Self {
let endpoint_config = EndpointConfig::default();
let api_base = ApiBase::Monitor;
let url = endpoint_config.url(&api_base, paths::MONITOR_USAGE_QUOTA_LIMIT);
Self {
key,
url,
endpoint_config,
api_base,
http_config: Arc::new(HttpClientConfig::default()),
_body: (),
}
}
fn rebuild_url(&mut self) {
self.url = self
.endpoint_config
.url(&self.api_base, paths::MONITOR_USAGE_QUOTA_LIMIT);
}
pub fn with_base_url(mut self, base: impl Into<String>) -> Self {
self.api_base = ApiBase::Custom(base.into());
self.rebuild_url();
self
}
pub fn with_endpoint_config(mut self, endpoint_config: EndpointConfig) -> Self {
self.endpoint_config = endpoint_config;
self.rebuild_url();
self
}
pub fn with_monitor_base(mut self, base: impl Into<String>) -> Self {
self.endpoint_config = self.endpoint_config.with_monitor_base(base);
self.rebuild_url();
self
}
pub fn with_http_config(mut self, config: HttpClientConfig) -> Self {
self.http_config = Arc::new(config);
self
}
pub fn url(&self) -> &str {
&self.url
}
pub async fn send(&self) -> ZaiResult<CodingPlanUsageResponse> {
let resp = self.get().await?;
let parsed = parse_typed_response::<CodingPlanUsageResponse>(resp).await?;
Ok(parsed)
}
}
impl HttpClient for CodingPlanUsageRequest {
type Body = ();
type ApiUrl = String;
type ApiKey = String;
fn api_url(&self) -> &Self::ApiUrl {
&self.url
}
fn api_key(&self) -> &Self::ApiKey {
&self.key
}
fn body(&self) -> &Self::Body {
&self._body
}
fn http_config(&self) -> Arc<HttpClientConfig> {
Arc::clone(&self.http_config)
}
}
#[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 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 request_targets_official_monitor_endpoint() {
let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string());
assert_eq!(
req.url(),
"https://open.bigmodel.cn/api/monitor/usage/quota/limit"
);
}
#[test]
fn request_custom_base_overrides_url() {
let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string())
.with_base_url("https://api.z.ai/api/monitor");
assert_eq!(req.url(), "https://api.z.ai/api/monitor/usage/quota/limit");
}
#[test]
fn request_monitor_base_overrides_url() {
let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string())
.with_monitor_base("https://api.z.ai/api/monitor");
assert_eq!(req.url(), "https://api.z.ai/api/monitor/usage/quota/limit");
}
}