1use std::fmt;
12
13use chrono::{DateTime, FixedOffset, TimeZone, Utc};
14use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
15
16use crate::{ZaiResult, client::ZaiClient};
17
18fn parse_next_reset_time(raw: &str) -> Option<DateTime<Utc>> {
19 if let Ok(timestamp) = raw.parse::<i64>() {
20 return if timestamp.unsigned_abs() >= 10_000_000_000 {
21 Utc.timestamp_millis_opt(timestamp).single()
22 } else {
23 Utc.timestamp_opt(timestamp, 0).single()
24 };
25 }
26
27 DateTime::parse_from_rfc3339(raw)
28 .ok()
29 .map(|datetime| datetime.with_timezone(&Utc))
30}
31
32fn beijing_offset() -> Option<FixedOffset> {
33 FixedOffset::east_opt(8 * 60 * 60)
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct CodingPlanUsageData {
44 #[serde(
46 default,
47 deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
48 skip_serializing_if = "Option::is_none"
49 )]
50 pub level: Option<String>,
51 #[serde(default)]
53 pub limits: Vec<CodingPlanQuotaLimit>,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct CodingPlanUsageDetail {
59 #[serde(
61 default,
62 rename = "modelCode",
63 alias = "model_code",
64 deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
65 skip_serializing_if = "Option::is_none"
66 )]
67 pub model_code: Option<String>,
68 #[serde(default)]
70 pub usage: u64,
71}
72
73impl fmt::Display for CodingPlanUsageDetail {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 write!(
76 f,
77 "{}: {}",
78 self.model_code.as_deref().unwrap_or("unknown"),
79 self.usage
80 )
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum CodingPlanQuotaKind {
87 TimeLimit,
89 TokensLimit,
91 Other(String),
93}
94
95impl CodingPlanQuotaKind {
96 pub fn as_str(&self) -> &str {
98 match self {
99 CodingPlanQuotaKind::TimeLimit => "TIME_LIMIT",
100 CodingPlanQuotaKind::TokensLimit => "TOKENS_LIMIT",
101 CodingPlanQuotaKind::Other(type_) => type_,
102 }
103 }
104}
105
106#[derive(Debug, Clone)]
112pub struct CodingPlanQuotaSummary {
113 pub kind: CodingPlanQuotaKind,
115 pub type_: String,
117 pub unit: Option<String>,
119 pub number: u64,
121 pub reported_usage: Option<u64>,
123 pub current_value: Option<u64>,
125 pub reported_remaining: Option<u64>,
127 pub quota: u64,
129 pub used: u64,
131 pub remaining: u64,
133 pub percentage: f64,
135 pub next_reset_time: Option<String>,
137 pub next_reset_at: Option<DateTime<Utc>>,
140 pub usage_details: Vec<CodingPlanUsageDetail>,
142}
143
144fn display_opt<T>(value: Option<T>) -> String
145where
146 T: fmt::Display,
147{
148 value
149 .map(|value| value.to_string())
150 .unwrap_or_else(|| "-".to_string())
151}
152
153impl CodingPlanQuotaSummary {
154 pub fn is_time_limit(&self) -> bool {
156 self.kind == CodingPlanQuotaKind::TimeLimit
157 }
158
159 pub fn is_tokens_limit(&self) -> bool {
161 self.kind == CodingPlanQuotaKind::TokensLimit
162 }
163
164 pub fn used_ratio(&self) -> f64 {
166 if self.quota == 0 {
167 return 0.0;
168 }
169
170 (self.used as f64 / self.quota as f64).clamp(0.0, 1.0)
171 }
172
173 pub fn usage_for_model(&self, model_code: &str) -> Option<u64> {
175 self.usage_details
176 .iter()
177 .find(|detail| detail.model_code.as_deref() == Some(model_code))
178 .map(|detail| detail.usage)
179 }
180
181 pub fn next_reset_at_beijing(&self) -> Option<DateTime<FixedOffset>> {
183 self.next_reset_at
184 .as_ref()
185 .and_then(|datetime| beijing_offset().map(|tz| datetime.with_timezone(&tz)))
186 }
187}
188
189impl fmt::Display for CodingPlanQuotaSummary {
190 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191 writeln!(f, "{}:", self.type_)?;
192 writeln!(f, " kind: {}", self.kind.as_str())?;
193 writeln!(f, " unit: {}", display_opt(self.unit.as_deref()))?;
194 writeln!(f, " number: {}", self.number)?;
195 writeln!(f, " usage: {}", display_opt(self.reported_usage))?;
196 writeln!(f, " current_value: {}", display_opt(self.current_value))?;
197 writeln!(
198 f,
199 " reported_remaining: {}",
200 display_opt(self.reported_remaining)
201 )?;
202 writeln!(f, " quota: {}", self.quota)?;
203 writeln!(f, " used: {}", self.used)?;
204 writeln!(f, " remaining: {}", self.remaining)?;
205 writeln!(f, " percentage: {:.1}%", self.percentage)?;
206 writeln!(
207 f,
208 " next_reset_time: {}",
209 display_opt(self.next_reset_time.as_deref())
210 )?;
211 writeln!(
212 f,
213 " next_reset_at_utc: {}",
214 display_opt(self.next_reset_at.as_ref().map(DateTime::to_rfc3339))
215 )?;
216 writeln!(
217 f,
218 " next_reset_at_beijing: {}",
219 display_opt(
220 self.next_reset_at_beijing()
221 .as_ref()
222 .map(DateTime::to_rfc3339)
223 )
224 )?;
225
226 if self.usage_details.is_empty() {
227 writeln!(f, " usage_details: []")?;
228 } else {
229 writeln!(f, " usage_details:")?;
230 for detail in &self.usage_details {
231 writeln!(f, " - {detail}")?;
232 }
233 }
234
235 Ok(())
236 }
237}
238
239#[derive(Debug, Clone, Default)]
241pub struct CodingPlanUsageSummary {
242 pub code: i64,
244 pub msg: Option<String>,
246 pub success: bool,
248 pub level: Option<String>,
250 pub limits: Vec<CodingPlanQuotaSummary>,
252}
253
254impl CodingPlanUsageSummary {
255 pub fn find_limit(&self, type_: &str) -> Option<&CodingPlanQuotaSummary> {
257 self.limits
258 .iter()
259 .find(|limit| limit.type_.eq_ignore_ascii_case(type_))
260 }
261
262 pub fn time_limit(&self) -> Option<&CodingPlanQuotaSummary> {
264 self.limits
265 .iter()
266 .find(|limit| limit.kind == CodingPlanQuotaKind::TimeLimit)
267 }
268
269 pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaSummary> {
271 self.limits
272 .iter()
273 .find(|limit| limit.kind == CodingPlanQuotaKind::TokensLimit)
274 }
275}
276
277impl fmt::Display for CodingPlanUsageSummary {
278 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279 writeln!(f, "Coding Plan Usage")?;
280 writeln!(f, "code: {}", self.code)?;
281 writeln!(f, "msg: {}", display_opt(self.msg.as_deref()))?;
282 writeln!(f, "success: {}", self.success)?;
283 writeln!(f, "level: {}", display_opt(self.level.as_deref()))?;
284
285 if self.limits.is_empty() {
286 writeln!(f, "limits: []")?;
287 return Ok(());
288 }
289
290 writeln!(f, "limits:")?;
291 for (index, limit) in self.limits.iter().enumerate() {
292 if index > 0 {
293 writeln!(f)?;
294 }
295 write!(f, "{limit}")?;
296 }
297
298 Ok(())
299 }
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct CodingPlanQuotaLimit {
310 #[serde(rename = "type")]
313 pub type_: String,
314 #[serde(
316 default,
317 deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
318 skip_serializing_if = "Option::is_none"
319 )]
320 pub unit: Option<String>,
321 #[serde(default)]
323 pub number: u64,
324 #[serde(default)]
326 pub percentage: f64,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub usage: Option<u64>,
330 #[serde(
332 default,
333 rename = "currentValue",
334 alias = "current_value",
335 skip_serializing_if = "Option::is_none"
336 )]
337 pub current_value: Option<u64>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub remaining: Option<u64>,
341 #[serde(
343 default,
344 rename = "nextResetTime",
345 alias = "next_reset_time",
346 deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
347 skip_serializing_if = "Option::is_none"
348 )]
349 pub next_reset_time: Option<String>,
350 #[serde(
352 default,
353 rename = "usageDetails",
354 alias = "usage_details",
355 skip_serializing_if = "Vec::is_empty"
356 )]
357 pub usage_details: Vec<CodingPlanUsageDetail>,
358}
359
360impl CodingPlanQuotaLimit {
361 pub fn kind(&self) -> CodingPlanQuotaKind {
363 if self.is_time_limit() {
364 CodingPlanQuotaKind::TimeLimit
365 } else if self.is_tokens_limit() {
366 CodingPlanQuotaKind::TokensLimit
367 } else {
368 CodingPlanQuotaKind::Other(self.type_.clone())
369 }
370 }
371
372 pub fn quota(&self) -> u64 {
374 self.usage.unwrap_or(self.number)
375 }
376
377 pub fn remaining(&self) -> u64 {
382 if let Some(remaining) = self.remaining {
383 return remaining;
384 }
385 if let (Some(usage), Some(current_value)) = (self.usage, self.current_value) {
386 return usage.saturating_sub(current_value);
387 }
388 if self.number == 0 {
389 return 0;
390 }
391 let consumed = (self.number as f64) * self.used_fraction();
392 self.number.saturating_sub(consumed.round() as u64)
393 }
394
395 pub fn remaining_ratio(&self) -> f64 {
397 if self.number == 0 {
398 return 0.0;
399 }
400 1.0 - self.used_fraction()
401 }
402
403 pub fn consumed(&self) -> u64 {
405 if let Some(current_value) = self.current_value {
406 return current_value;
407 }
408 if let (Some(usage), Some(remaining)) = (self.usage, self.remaining) {
409 return usage.saturating_sub(remaining);
410 }
411 let consumed = (self.number as f64) * self.used_fraction();
412 consumed.round() as u64
413 }
414
415 fn used_fraction(&self) -> f64 {
416 if self.percentage.is_nan() {
417 0.0
418 } else {
419 (self.percentage / 100.0).clamp(0.0, 1.0)
420 }
421 }
422
423 pub fn next_reset_at(&self) -> Option<DateTime<Utc>> {
426 self.next_reset_time
427 .as_deref()
428 .and_then(parse_next_reset_time)
429 }
430
431 pub fn usage_for_model(&self, model_code: &str) -> Option<u64> {
433 self.usage_details
434 .iter()
435 .find(|detail| detail.model_code.as_deref() == Some(model_code))
436 .map(|detail| detail.usage)
437 }
438
439 pub fn next_reset_at_beijing(&self) -> Option<DateTime<FixedOffset>> {
441 self.next_reset_at()
442 .and_then(|datetime| beijing_offset().map(|tz| datetime.with_timezone(&tz)))
443 }
444
445 pub fn summary(&self) -> CodingPlanQuotaSummary {
447 CodingPlanQuotaSummary {
448 kind: self.kind(),
449 type_: self.type_.clone(),
450 unit: self.unit.clone(),
451 number: self.number,
452 reported_usage: self.usage,
453 current_value: self.current_value,
454 reported_remaining: self.remaining,
455 quota: self.quota(),
456 used: self.consumed(),
457 remaining: self.remaining(),
458 percentage: self.percentage,
459 next_reset_time: self.next_reset_time.clone(),
460 next_reset_at: self.next_reset_at(),
461 usage_details: self.usage_details.clone(),
462 }
463 }
464
465 pub fn is_time_limit(&self) -> bool {
467 self.type_.eq_ignore_ascii_case("TIME_LIMIT")
468 }
469
470 pub fn is_tokens_limit(&self) -> bool {
472 self.type_.eq_ignore_ascii_case("TOKENS_LIMIT")
473 }
474}
475
476impl fmt::Display for CodingPlanQuotaLimit {
477 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478 self.summary().fmt(f)
479 }
480}
481
482#[derive(Debug, Clone, Serialize)]
484pub struct CodingPlanUsageResponse {
485 #[serde(default)]
487 pub code: i64,
488 #[serde(
490 default,
491 rename = "msg",
492 deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string",
493 skip_serializing_if = "Option::is_none"
494 )]
495 pub msg: Option<String>,
496 #[serde(default)]
498 pub success: bool,
499 #[serde(default)]
501 pub data: CodingPlanUsageData,
502}
503
504#[derive(Deserialize)]
505struct CodingPlanUsageResponseWire {
506 code: Option<i64>,
507 #[serde(
508 default,
509 rename = "msg",
510 deserialize_with = "crate::serde_helpers::optional_string_from_number_or_string"
511 )]
512 msg: Option<String>,
513 success: Option<bool>,
514 data: Option<CodingPlanUsageData>,
515}
516
517impl<'de> Deserialize<'de> for CodingPlanUsageResponse {
518 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
519 where
520 D: Deserializer<'de>,
521 {
522 let wire = CodingPlanUsageResponseWire::deserialize(deserializer)?;
523 if wire.code.is_none()
524 && wire.msg.is_none()
525 && wire.success.is_none()
526 && wire.data.is_none()
527 {
528 return Err(D::Error::custom(
529 "coding-plan usage response contained no documented non-null fields",
530 ));
531 }
532 Ok(Self {
533 code: wire.code.unwrap_or_default(),
534 msg: wire.msg,
535 success: wire.success.unwrap_or_default(),
536 data: wire.data.unwrap_or_default(),
537 })
538 }
539}
540
541impl CodingPlanUsageResponse {
542 pub fn limits(&self) -> &[CodingPlanQuotaLimit] {
544 &self.data.limits
545 }
546
547 pub fn find_limit(&self, type_: &str) -> Option<&CodingPlanQuotaLimit> {
549 self.data
550 .limits
551 .iter()
552 .find(|l| l.type_.eq_ignore_ascii_case(type_))
553 }
554
555 pub fn time_limit(&self) -> Option<&CodingPlanQuotaLimit> {
557 self.find_limit("TIME_LIMIT")
558 }
559
560 pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaLimit> {
562 self.find_limit("TOKENS_LIMIT")
563 }
564
565 pub fn level(&self) -> Option<&str> {
567 self.data.level.as_deref()
568 }
569
570 pub fn summary(&self) -> CodingPlanUsageSummary {
572 CodingPlanUsageSummary {
573 code: self.code,
574 msg: self.msg.clone(),
575 success: self.success,
576 level: self.data.level.clone(),
577 limits: self
578 .data
579 .limits
580 .iter()
581 .map(CodingPlanQuotaLimit::summary)
582 .collect(),
583 }
584 }
585
586 pub fn time_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
588 self.time_limit().map(CodingPlanQuotaLimit::summary)
589 }
590
591 pub fn tokens_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
593 self.tokens_limit().map(CodingPlanQuotaLimit::summary)
594 }
595}
596
597impl fmt::Display for CodingPlanUsageResponse {
598 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
599 self.summary().fmt(f)
600 }
601}
602
603pub struct CodingPlanUsageRequest;
623
624impl CodingPlanUsageRequest {
625 pub fn new() -> Self {
628 Self
629 }
630
631 pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<CodingPlanUsageResponse> {
633 let route = crate::client::routes::USAGE_GET;
634 let url = client.endpoints().resolve_route(route, &[])?;
635 client
636 .send_empty::<CodingPlanUsageResponse>(route.method(), url)
637 .await
638 }
639}
640
641impl Default for CodingPlanUsageRequest {
642 fn default() -> Self {
643 Self::new()
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
652 fn deserializes_typical_envelope() {
653 let raw = r#"{
654 "code": 200,
655 "msg": "ok",
656 "success": true,
657 "data": {
658 "level": "max",
659 "limits": [
660 {
661 "type": "TIME_LIMIT",
662 "unit": "5h",
663 "number": 600,
664 "percentage": 25.0,
665 "nextResetTime": "2026-06-18T15:00:00Z"
666 },
667 {
668 "type": "TOKENS_LIMIT",
669 "unit": "week",
670 "number": 1000000,
671 "percentage": 50.0,
672 "nextResetTime": "2026-06-22T00:00:00Z"
673 }
674 ]
675 }
676 }"#;
677 let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
678 assert!(resp.success);
679 assert_eq!(resp.level(), Some("max"));
680 let t = resp.time_limit().unwrap();
681 assert!(t.is_time_limit());
682 assert_eq!(t.consumed(), 150);
683 assert_eq!(t.remaining(), 450);
684 assert!((t.remaining_ratio() - 0.75).abs() < 1e-9);
685 assert_eq!(t.next_reset_time.as_deref(), Some("2026-06-18T15:00:00Z"));
686 assert_eq!(
687 t.next_reset_at().unwrap().to_rfc3339(),
688 "2026-06-18T15:00:00+00:00"
689 );
690 let w = resp.tokens_limit().unwrap();
691 assert!(w.is_tokens_limit());
692 assert_eq!(w.consumed(), 500_000);
693 assert_eq!(w.remaining(), 500_000);
694 }
695
696 #[test]
697 fn deserializes_numeric_plan_level() {
698 let raw = r#"{
699 "code": 200,
700 "msg": "ok",
701 "success": true,
702 "data": {
703 "level": 3,
704 "limits": [
705 {
706 "type": "TIME_LIMIT",
707 "unit": 5,
708 "number": 600,
709 "percentage": 25.0,
710 "usage": 4000,
711 "currentValue": 54,
712 "remaining": 3946,
713 "nextResetTime": 1781778751996,
714 "usageDetails": [
715 {"modelCode": "search-prime", "usage": 40},
716 {"modelCode": "web-reader", "usage": 14}
717 ]
718 }
719 ]
720 }
721 }"#;
722
723 let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
724
725 assert_eq!(resp.level(), Some("3"));
726 let limit = resp.time_limit().unwrap();
727 assert_eq!(limit.unit.as_deref(), Some("5"));
728 assert_eq!(limit.quota(), 4000);
729 assert_eq!(limit.consumed(), 54);
730 assert_eq!(limit.remaining(), 3946);
731 assert_eq!(limit.usage_details.len(), 2);
732 assert_eq!(
733 limit.usage_details[0].model_code.as_deref(),
734 Some("search-prime")
735 );
736 assert_eq!(limit.usage_for_model("web-reader"), Some(14));
737 assert_eq!(limit.next_reset_time.as_deref(), Some("1781778751996"));
738
739 let summary = resp.summary();
740 assert_eq!(summary.code, 200);
741 assert_eq!(summary.msg.as_deref(), Some("ok"));
742 assert!(summary.success);
743 assert_eq!(summary.level.as_deref(), Some("3"));
744 let time_limit = summary.time_limit().unwrap();
745 assert_eq!(time_limit.kind, CodingPlanQuotaKind::TimeLimit);
746 assert_eq!(time_limit.number, 600);
747 assert_eq!(time_limit.reported_usage, Some(4000));
748 assert_eq!(time_limit.current_value, Some(54));
749 assert_eq!(time_limit.reported_remaining, Some(3946));
750 assert_eq!(time_limit.quota, 4000);
751 assert_eq!(time_limit.used, 54);
752 assert_eq!(time_limit.remaining, 3946);
753 assert_eq!(time_limit.usage_for_model("search-prime"), Some(40));
754 assert_eq!(
755 time_limit.next_reset_at.as_ref().unwrap().to_rfc3339(),
756 "2026-06-18T10:32:31.996+00:00"
757 );
758 assert_eq!(
759 time_limit
760 .next_reset_at_beijing()
761 .as_ref()
762 .unwrap()
763 .to_rfc3339(),
764 "2026-06-18T18:32:31.996+08:00"
765 );
766
767 let report = summary.to_string();
768 assert!(report.contains("code: 200"));
769 assert!(report.contains("msg: ok"));
770 assert!(report.contains("success: true"));
771 assert!(report.contains("number: 600"));
772 assert!(report.contains("current_value: 54"));
773 assert!(report.contains("reported_remaining: 3946"));
774 assert!(report.contains("next_reset_at_utc: 2026-06-18T10:32:31.996+00:00"));
775 assert!(report.contains("next_reset_at_beijing: 2026-06-18T18:32:31.996+08:00"));
776 assert!(report.contains("usage_details:"));
777 assert!(report.contains("search-prime: 40"));
778 }
779
780 #[test]
781 fn handles_missing_optional_fields() {
782 let raw = r#"{"code":0,"success":true,"data":{"limits":[]}}"#;
783 let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
784 assert!(resp.limits().is_empty());
785 assert!(resp.level().is_none());
786 assert!(resp.time_limit().is_none());
787 }
788
789 #[test]
790 fn rejects_empty_success_but_preserves_flexible_string_fields() {
791 assert!(serde_json::from_str::<CodingPlanUsageResponse>("{}").is_err());
792 assert!(
793 serde_json::from_str::<CodingPlanUsageResponse>(
794 r#"{"code":null,"msg":null,"success":null,"data":null}"#
795 )
796 .is_err()
797 );
798
799 let response: CodingPlanUsageResponse = serde_json::from_str(r#"{"msg":42}"#).unwrap();
800 assert_eq!(response.msg.as_deref(), Some("42"));
801 }
802
803 #[test]
804 fn remaining_is_zero_when_exhausted() {
805 let limit = CodingPlanQuotaLimit {
806 type_: "TIME_LIMIT".to_string(),
807 unit: Some("5h".to_string()),
808 number: 100,
809 percentage: 100.0,
810 usage: None,
811 current_value: None,
812 remaining: None,
813 next_reset_time: None,
814 usage_details: Vec::new(),
815 };
816 assert_eq!(limit.remaining(), 0);
817 assert_eq!(limit.consumed(), 100);
818 }
819
820 #[test]
821 fn monitor_family_resolves_official_endpoint() {
822 let ec = crate::client::endpoint::EndpointConfig::defaults().unwrap();
823 let url = ec
824 .resolve(
825 crate::client::ApiFamily::Monitor,
826 &["usage", "quota", "limit"],
827 )
828 .unwrap();
829 assert_eq!(
830 url,
831 "https://open.bigmodel.cn/api/monitor/usage/quota/limit"
832 );
833 }
834
835 #[test]
836 fn monitor_family_resolves_custom_base_via_builder() {
837 use crate::client::endpoint::EndpointConfig;
838 let ec = EndpointConfig::builder()
839 .monitor("https://api.z.ai/api/monitor")
840 .build(false)
841 .unwrap();
842 let url = ec
843 .resolve(
844 crate::client::ApiFamily::Monitor,
845 &["usage", "quota", "limit"],
846 )
847 .unwrap();
848 assert_eq!(url, "https://api.z.ai/api/monitor/usage/quota/limit");
849 }
850}