Skip to main content

zai_rs/usage/
data.rs

1//! Coding Plan usage / quota query client.
2//!
3//! Wraps the GLM Coding Plan "余量查询" endpoint
4//! `GET https://open.bigmodel.cn/api/monitor/usage/quota/limit`. The endpoint is
5//! authenticated with a normal Bearer API key and reports the per-5-hour and
6//! weekly quota consumption / remaining amounts for the subscribed Coding Plan.
7//!
8//! See <https://docs.bigmodel.cn/cn/coding-plan/extension/usage-query-plugin>
9//! and the community CLI <https://github.com/JinHanAI/coding-plan-monitor>.
10
11use 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/// `data` payload returned by the quota-limit endpoint.
37///
38/// `level` is the subscribed plan tier. Depending on the upstream deployment it
39/// may be returned as a human-readable tier (`"max"` / `"pro"` / `"lite"`) or
40/// a numeric tier code, which is normalized to a string here. `limits` lists
41/// each quota window currently in effect for the account.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct CodingPlanUsageData {
44    /// Subscribed plan level identifier.
45    #[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    /// Active quota windows (5-hour token limit, weekly token limit, …).
52    #[serde(default)]
53    pub limits: Vec<CodingPlanQuotaLimit>,
54}
55
56/// Per-model usage breakdown returned for some quota windows.
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct CodingPlanUsageDetail {
59    /// Model identifier reported by the monitor API.
60    #[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    /// Amount attributed to this model.
69    #[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/// Normalized quota-window kind.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum CodingPlanQuotaKind {
87    /// Per-5-hour Coding Plan quota window.
88    TimeLimit,
89    /// Weekly token quota window.
90    TokensLimit,
91    /// Any future or deployment-specific quota window.
92    Other(String),
93}
94
95impl CodingPlanQuotaKind {
96    /// Raw API identifier for this quota kind.
97    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/// Easy-to-use quota-window view derived from [`CodingPlanQuotaLimit`].
107///
108/// This hides wire-format quirks such as `currentValue`, numeric `unit`, and
109/// millisecond `nextResetTime`, while preserving the raw strings for display or
110/// debugging when needed.
111#[derive(Debug, Clone)]
112pub struct CodingPlanQuotaSummary {
113    /// Normalized quota kind.
114    pub kind: CodingPlanQuotaKind,
115    /// Raw API type string.
116    pub type_: String,
117    /// Raw API unit normalized to a string.
118    pub unit: Option<String>,
119    /// Raw `number` value reported by the API.
120    pub number: u64,
121    /// Raw `usage` value reported by the API, when present.
122    pub reported_usage: Option<u64>,
123    /// Raw `currentValue` value reported by the API, when present.
124    pub current_value: Option<u64>,
125    /// Raw `remaining` value reported by the API, when present.
126    pub reported_remaining: Option<u64>,
127    /// Total quota amount.
128    pub quota: u64,
129    /// Consumed amount in the current window.
130    pub used: u64,
131    /// Remaining amount in the current window.
132    pub remaining: u64,
133    /// Consumed percentage reported by the API.
134    pub percentage: f64,
135    /// Raw reset value normalized to a string.
136    pub next_reset_time: Option<String>,
137    /// Parsed reset time in UTC when the raw value is a Unix timestamp or
138    /// RFC3339.
139    pub next_reset_at: Option<DateTime<Utc>>,
140    /// Optional per-model breakdown.
141    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    /// True when this is the per-5-hour window.
155    pub fn is_time_limit(&self) -> bool {
156        self.kind == CodingPlanQuotaKind::TimeLimit
157    }
158
159    /// True when this is the weekly tokens window.
160    pub fn is_tokens_limit(&self) -> bool {
161        self.kind == CodingPlanQuotaKind::TokensLimit
162    }
163
164    /// Consumed fraction in `[0.0, 1.0]`.
165    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    /// Return the usage attributed to one model code, if present.
174    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    /// Parsed reset time in UTC+08:00 (Beijing / Shanghai fixed offset).
182    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/// Easy-to-use Coding Plan usage view.
240#[derive(Debug, Clone, Default)]
241pub struct CodingPlanUsageSummary {
242    /// Business status code (0 / 200 indicate success).
243    pub code: i64,
244    /// Human-readable status message.
245    pub msg: Option<String>,
246    /// Whether the request succeeded.
247    pub success: bool,
248    /// Subscribed plan level, if reported by the server.
249    pub level: Option<String>,
250    /// All quota windows as normalized summaries.
251    pub limits: Vec<CodingPlanQuotaSummary>,
252}
253
254impl CodingPlanUsageSummary {
255    /// Find the first quota window matching a raw type string.
256    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    /// The per-5-hour time window, if present.
263    pub fn time_limit(&self) -> Option<&CodingPlanQuotaSummary> {
264        self.limits
265            .iter()
266            .find(|limit| limit.kind == CodingPlanQuotaKind::TimeLimit)
267    }
268
269    /// The weekly tokens window, if present.
270    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/// One quota window reported by the monitor endpoint.
303///
304/// The Coding Plan applies two kinds of windows, surfaced as `TIME_LIMIT`
305/// (per-5-hour) and `TOKENS_LIMIT` (weekly tokens). Depending on the upstream
306/// deployment, each window may include explicit `currentValue` / `remaining` /
307/// `usage` counters, or only a `number` plus consumed `percentage`.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct CodingPlanQuotaLimit {
310    /// Limit kind: `TIME_LIMIT` (5-hour window) or `TOKENS_LIMIT` (weekly
311    /// tokens).
312    #[serde(rename = "type")]
313    pub type_: String,
314    /// Window unit (e.g. `"5h"`, `"week"`).
315    #[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    /// Configured cap for this window (tokens or prompt count).
322    #[serde(default)]
323    pub number: u64,
324    /// Consumed percentage within the current window (0–100).
325    #[serde(default)]
326    pub percentage: f64,
327    /// Total quota amount reported by the server, when present.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub usage: Option<u64>,
330    /// Amount already consumed in the current window, when present.
331    #[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    /// Remaining amount reported by the server, when present.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub remaining: Option<u64>,
341    /// When this window resets (server timestamp / ISO string).
342    #[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    /// Optional per-model breakdown for this quota window.
351    #[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    /// Normalized quota kind.
362    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    /// Total quota amount when the server reports it, falling back to `number`.
373    pub fn quota(&self) -> u64 {
374        self.usage.unwrap_or(self.number)
375    }
376
377    /// Remaining amount in this window.
378    ///
379    /// Uses the server-provided `remaining` field when present. Otherwise it
380    /// falls back to `number * (1 - percentage/100)`.
381    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    /// Remaining fraction in `[0.0, 1.0]`, derived from the reported percentage.
396    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    /// Consumed amount in this window.
404    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    /// Parsed reset time in UTC when `next_reset_time` is a Unix timestamp or
424    /// RFC3339 datetime.
425    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    /// Return the usage attributed to one model code, if present.
432    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    /// Parsed reset time in UTC+08:00 (Beijing / Shanghai fixed offset).
440    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    /// Build an easy-to-use normalized view for this quota window.
446    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    /// True when this is the per-5-hour time window.
466    pub fn is_time_limit(&self) -> bool {
467        self.type_.eq_ignore_ascii_case("TIME_LIMIT")
468    }
469
470    /// True when this is the weekly tokens window.
471    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/// Standard `{code, msg, success, data}` envelope used by the monitor API.
483#[derive(Debug, Clone, Serialize)]
484pub struct CodingPlanUsageResponse {
485    /// Business status code (0 / 200 indicate success).
486    #[serde(default)]
487    pub code: i64,
488    /// Human-readable status message.
489    #[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    /// Whether the request succeeded.
497    #[serde(default)]
498    pub success: bool,
499    /// Quota payload.
500    #[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    /// The active quota windows.
543    pub fn limits(&self) -> &[CodingPlanQuotaLimit] {
544        &self.data.limits
545    }
546
547    /// Find the first window matching a limit type (case-insensitive).
548    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    /// The per-5-hour time window, if present.
556    pub fn time_limit(&self) -> Option<&CodingPlanQuotaLimit> {
557        self.find_limit("TIME_LIMIT")
558    }
559
560    /// The weekly tokens window, if present.
561    pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaLimit> {
562        self.find_limit("TOKENS_LIMIT")
563    }
564
565    /// The subscribed plan level, if reported by the server.
566    pub fn level(&self) -> Option<&str> {
567        self.data.level.as_deref()
568    }
569
570    /// Build an easy-to-use normalized view of the quota response.
571    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    /// The per-5-hour window as a normalized summary, if present.
587    pub fn time_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
588        self.time_limit().map(CodingPlanQuotaLimit::summary)
589    }
590
591    /// The weekly tokens window as a normalized summary, if present.
592    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
603/// Coding Plan usage / quota query request
604/// (`GET /api/monitor/usage/quota/limit`).
605///
606/// Construct with [`CodingPlanUsageRequest::new`] and execute with
607/// [`CodingPlanUsageRequest::send_via`]. Credentials and transport live on the
608/// [`ZaiClient`], passed to `send_via`.
609///
610/// ```rust,no_run
611/// use zai_rs::usage::CodingPlanUsageRequest;
612/// use zai_rs::client::ZaiClient;
613///
614/// # async fn go(client: ZaiClient) -> zai_rs::ZaiResult<()> {
615/// let resp = CodingPlanUsageRequest::new().send_via(&client).await?;
616/// if let Some(five_hour) = resp.time_limit() {
617///     tracing::info!("5h window: {}% used", five_hour.percentage);
618/// }
619/// # Ok(())
620/// # }
621/// ```
622pub struct CodingPlanUsageRequest;
623
624impl CodingPlanUsageRequest {
625    /// Build a quota query. The base URL, credentials and transport are
626    /// supplied by the [`ZaiClient`] passed to [`Self::send_via`].
627    pub fn new() -> Self {
628        Self
629    }
630
631    /// Send the quota query via a [`ZaiClient`] and parse the typed envelope.
632    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}