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, sync::Arc};
12
13use chrono::{DateTime, FixedOffset, TimeZone, Utc};
14use serde::{Deserialize, Deserializer, Serialize};
15
16use crate::{
17    ZaiResult,
18    client::{
19        endpoints::{ApiBase, EndpointConfig, paths},
20        http::{HttpClient, HttpClientConfig, parse_typed_response},
21    },
22};
23
24fn de_opt_string_from_string_or_number<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
25where
26    D: Deserializer<'de>,
27{
28    let value = serde_json::Value::deserialize(deserializer)?;
29    match value {
30        serde_json::Value::Null => Ok(None),
31        serde_json::Value::String(value) => Ok(Some(value)),
32        serde_json::Value::Number(value) => Ok(Some(value.to_string())),
33        other => Err(serde::de::Error::custom(format!(
34            "expected string or number, got {other}"
35        ))),
36    }
37}
38
39fn parse_next_reset_time(raw: &str) -> Option<DateTime<Utc>> {
40    if let Ok(timestamp) = raw.parse::<i64>() {
41        return if timestamp.abs() >= 10_000_000_000 {
42            Utc.timestamp_millis_opt(timestamp).single()
43        } else {
44            Utc.timestamp_opt(timestamp, 0).single()
45        };
46    }
47
48    DateTime::parse_from_rfc3339(raw)
49        .ok()
50        .map(|datetime| datetime.with_timezone(&Utc))
51}
52
53fn beijing_offset() -> FixedOffset {
54    FixedOffset::east_opt(8 * 60 * 60).expect("UTC+08:00 is a valid fixed offset")
55}
56
57/// `data` payload returned by the quota-limit endpoint.
58///
59/// `level` is the subscribed plan tier. Depending on the upstream deployment it
60/// may be returned as a human-readable tier (`"max"` / `"pro"` / `"lite"`) or
61/// a numeric tier code, which is normalized to a string here. `limits` lists
62/// each quota window currently in effect for the account.
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct CodingPlanUsageData {
65    /// Subscribed plan level identifier.
66    #[serde(
67        default,
68        deserialize_with = "de_opt_string_from_string_or_number",
69        skip_serializing_if = "Option::is_none"
70    )]
71    pub level: Option<String>,
72    /// Active quota windows (5-hour token limit, weekly token limit, …).
73    #[serde(default)]
74    pub limits: Vec<CodingPlanQuotaLimit>,
75}
76
77/// Per-model usage breakdown returned for some quota windows.
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct CodingPlanUsageDetail {
80    /// Model identifier reported by the monitor API.
81    #[serde(
82        default,
83        alias = "modelCode",
84        deserialize_with = "de_opt_string_from_string_or_number",
85        skip_serializing_if = "Option::is_none"
86    )]
87    pub model_code: Option<String>,
88    /// Amount attributed to this model.
89    #[serde(default)]
90    pub usage: u64,
91}
92
93impl fmt::Display for CodingPlanUsageDetail {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(
96            f,
97            "{}: {}",
98            self.model_code.as_deref().unwrap_or("unknown"),
99            self.usage
100        )
101    }
102}
103
104/// Normalized quota-window kind.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum CodingPlanQuotaKind {
107    /// Per-5-hour Coding Plan quota window.
108    TimeLimit,
109    /// Weekly token quota window.
110    TokensLimit,
111    /// Any future or deployment-specific quota window.
112    Other(String),
113}
114
115impl CodingPlanQuotaKind {
116    /// Raw API identifier for this quota kind.
117    pub fn as_str(&self) -> &str {
118        match self {
119            CodingPlanQuotaKind::TimeLimit => "TIME_LIMIT",
120            CodingPlanQuotaKind::TokensLimit => "TOKENS_LIMIT",
121            CodingPlanQuotaKind::Other(type_) => type_,
122        }
123    }
124}
125
126/// Easy-to-use quota-window view derived from [`CodingPlanQuotaLimit`].
127///
128/// This hides wire-format quirks such as `currentValue`, numeric `unit`, and
129/// millisecond `nextResetTime`, while preserving the raw strings for display or
130/// debugging when needed.
131#[derive(Debug, Clone)]
132pub struct CodingPlanQuotaSummary {
133    /// Normalized quota kind.
134    pub kind: CodingPlanQuotaKind,
135    /// Raw API type string.
136    pub type_: String,
137    /// Raw API unit normalized to a string.
138    pub unit: Option<String>,
139    /// Raw `number` value reported by the API.
140    pub number: u64,
141    /// Raw `usage` value reported by the API, when present.
142    pub reported_usage: Option<u64>,
143    /// Raw `currentValue` value reported by the API, when present.
144    pub current_value: Option<u64>,
145    /// Raw `remaining` value reported by the API, when present.
146    pub reported_remaining: Option<u64>,
147    /// Total quota amount.
148    pub quota: u64,
149    /// Consumed amount in the current window.
150    pub used: u64,
151    /// Remaining amount in the current window.
152    pub remaining: u64,
153    /// Consumed percentage reported by the API.
154    pub percentage: f64,
155    /// Raw reset value normalized to a string.
156    pub next_reset_time: Option<String>,
157    /// Parsed reset time in UTC when the raw value is a Unix timestamp or
158    /// RFC3339.
159    pub next_reset_at: Option<DateTime<Utc>>,
160    /// Optional per-model breakdown.
161    pub usage_details: Vec<CodingPlanUsageDetail>,
162}
163
164fn display_opt<T>(value: Option<T>) -> String
165where
166    T: fmt::Display,
167{
168    value
169        .map(|value| value.to_string())
170        .unwrap_or_else(|| "-".to_string())
171}
172
173impl CodingPlanQuotaSummary {
174    /// True when this is the per-5-hour window.
175    pub fn is_time_limit(&self) -> bool {
176        self.kind == CodingPlanQuotaKind::TimeLimit
177    }
178
179    /// True when this is the weekly tokens window.
180    pub fn is_tokens_limit(&self) -> bool {
181        self.kind == CodingPlanQuotaKind::TokensLimit
182    }
183
184    /// Consumed fraction in `[0.0, 1.0]`.
185    pub fn used_ratio(&self) -> f64 {
186        if self.quota == 0 {
187            return 0.0;
188        }
189
190        (self.used as f64 / self.quota as f64).clamp(0.0, 1.0)
191    }
192
193    /// Return the usage attributed to one model code, if present.
194    pub fn usage_for_model(&self, model_code: &str) -> Option<u64> {
195        self.usage_details
196            .iter()
197            .find(|detail| detail.model_code.as_deref() == Some(model_code))
198            .map(|detail| detail.usage)
199    }
200
201    /// Parsed reset time in UTC+08:00 (Beijing / Shanghai fixed offset).
202    pub fn next_reset_at_beijing(&self) -> Option<DateTime<FixedOffset>> {
203        self.next_reset_at
204            .as_ref()
205            .map(|datetime| datetime.with_timezone(&beijing_offset()))
206    }
207}
208
209impl fmt::Display for CodingPlanQuotaSummary {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        writeln!(f, "{}:", self.type_)?;
212        writeln!(f, "  kind: {}", self.kind.as_str())?;
213        writeln!(f, "  unit: {}", display_opt(self.unit.as_deref()))?;
214        writeln!(f, "  number: {}", self.number)?;
215        writeln!(f, "  usage: {}", display_opt(self.reported_usage))?;
216        writeln!(f, "  current_value: {}", display_opt(self.current_value))?;
217        writeln!(
218            f,
219            "  reported_remaining: {}",
220            display_opt(self.reported_remaining)
221        )?;
222        writeln!(f, "  quota: {}", self.quota)?;
223        writeln!(f, "  used: {}", self.used)?;
224        writeln!(f, "  remaining: {}", self.remaining)?;
225        writeln!(f, "  percentage: {:.1}%", self.percentage)?;
226        writeln!(
227            f,
228            "  next_reset_time: {}",
229            display_opt(self.next_reset_time.as_deref())
230        )?;
231        writeln!(
232            f,
233            "  next_reset_at_utc: {}",
234            display_opt(self.next_reset_at.as_ref().map(DateTime::to_rfc3339))
235        )?;
236        writeln!(
237            f,
238            "  next_reset_at_beijing: {}",
239            display_opt(
240                self.next_reset_at_beijing()
241                    .as_ref()
242                    .map(DateTime::to_rfc3339)
243            )
244        )?;
245
246        if self.usage_details.is_empty() {
247            writeln!(f, "  usage_details: []")?;
248        } else {
249            writeln!(f, "  usage_details:")?;
250            for detail in &self.usage_details {
251                writeln!(f, "    - {detail}")?;
252            }
253        }
254
255        Ok(())
256    }
257}
258
259/// Easy-to-use Coding Plan usage view.
260#[derive(Debug, Clone, Default)]
261pub struct CodingPlanUsageSummary {
262    /// Business status code (0 / 200 indicate success).
263    pub code: i64,
264    /// Human-readable status message.
265    pub msg: Option<String>,
266    /// Whether the request succeeded.
267    pub success: bool,
268    /// Subscribed plan level, if reported by the server.
269    pub level: Option<String>,
270    /// All quota windows as normalized summaries.
271    pub limits: Vec<CodingPlanQuotaSummary>,
272}
273
274impl CodingPlanUsageSummary {
275    /// Find the first quota window matching a raw type string.
276    pub fn find_limit(&self, type_: &str) -> Option<&CodingPlanQuotaSummary> {
277        self.limits
278            .iter()
279            .find(|limit| limit.type_.eq_ignore_ascii_case(type_))
280    }
281
282    /// The per-5-hour time window, if present.
283    pub fn time_limit(&self) -> Option<&CodingPlanQuotaSummary> {
284        self.limits
285            .iter()
286            .find(|limit| limit.kind == CodingPlanQuotaKind::TimeLimit)
287    }
288
289    /// The weekly tokens window, if present.
290    pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaSummary> {
291        self.limits
292            .iter()
293            .find(|limit| limit.kind == CodingPlanQuotaKind::TokensLimit)
294    }
295}
296
297impl fmt::Display for CodingPlanUsageSummary {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        writeln!(f, "Coding Plan Usage")?;
300        writeln!(f, "code: {}", self.code)?;
301        writeln!(f, "msg: {}", display_opt(self.msg.as_deref()))?;
302        writeln!(f, "success: {}", self.success)?;
303        writeln!(f, "level: {}", display_opt(self.level.as_deref()))?;
304
305        if self.limits.is_empty() {
306            writeln!(f, "limits: []")?;
307            return Ok(());
308        }
309
310        writeln!(f, "limits:")?;
311        for (index, limit) in self.limits.iter().enumerate() {
312            if index > 0 {
313                writeln!(f)?;
314            }
315            write!(f, "{limit}")?;
316        }
317
318        Ok(())
319    }
320}
321
322/// One quota window reported by the monitor endpoint.
323///
324/// The Coding Plan applies two kinds of windows, surfaced as `TIME_LIMIT`
325/// (per-5-hour) and `TOKENS_LIMIT` (weekly tokens). Depending on the upstream
326/// deployment, each window may include explicit `currentValue` / `remaining` /
327/// `usage` counters, or only a `number` plus consumed `percentage`.
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct CodingPlanQuotaLimit {
330    /// Limit kind: `TIME_LIMIT` (5-hour window) or `TOKENS_LIMIT` (weekly
331    /// tokens).
332    #[serde(rename = "type")]
333    pub type_: String,
334    /// Window unit (e.g. `"5h"`, `"week"`).
335    #[serde(
336        default,
337        deserialize_with = "de_opt_string_from_string_or_number",
338        skip_serializing_if = "Option::is_none"
339    )]
340    pub unit: Option<String>,
341    /// Configured cap for this window (tokens or prompt count).
342    #[serde(default)]
343    pub number: u64,
344    /// Consumed percentage within the current window (0–100).
345    #[serde(default)]
346    pub percentage: f64,
347    /// Total quota amount reported by the server, when present.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub usage: Option<u64>,
350    /// Amount already consumed in the current window, when present.
351    #[serde(
352        default,
353        alias = "currentValue",
354        skip_serializing_if = "Option::is_none"
355    )]
356    pub current_value: Option<u64>,
357    /// Remaining amount reported by the server, when present.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub remaining: Option<u64>,
360    /// When this window resets (server timestamp / ISO string).
361    #[serde(
362        default,
363        alias = "nextResetTime",
364        deserialize_with = "de_opt_string_from_string_or_number",
365        skip_serializing_if = "Option::is_none"
366    )]
367    pub next_reset_time: Option<String>,
368    /// Optional per-model breakdown for this quota window.
369    #[serde(default, alias = "usageDetails", skip_serializing_if = "Vec::is_empty")]
370    pub usage_details: Vec<CodingPlanUsageDetail>,
371}
372
373impl CodingPlanQuotaLimit {
374    /// Normalized quota kind.
375    pub fn kind(&self) -> CodingPlanQuotaKind {
376        if self.is_time_limit() {
377            CodingPlanQuotaKind::TimeLimit
378        } else if self.is_tokens_limit() {
379            CodingPlanQuotaKind::TokensLimit
380        } else {
381            CodingPlanQuotaKind::Other(self.type_.clone())
382        }
383    }
384
385    /// Total quota amount when the server reports it, falling back to `number`.
386    pub fn quota(&self) -> u64 {
387        self.usage.unwrap_or(self.number)
388    }
389
390    /// Remaining amount in this window.
391    ///
392    /// Uses the server-provided `remaining` field when present. Otherwise it
393    /// falls back to `number * (1 - percentage/100)`.
394    pub fn remaining(&self) -> u64 {
395        if let Some(remaining) = self.remaining {
396            return remaining;
397        }
398        if let (Some(usage), Some(current_value)) = (self.usage, self.current_value) {
399            return usage.saturating_sub(current_value);
400        }
401        if self.number == 0 {
402            return 0;
403        }
404        let consumed = (self.number as f64) * (self.percentage / 100.0);
405        self.number.saturating_sub(consumed.round() as u64)
406    }
407
408    /// Remaining fraction in `[0.0, 1.0]`.
409    pub fn remaining_ratio(&self) -> f64 {
410        if self.number == 0 {
411            return 0.0;
412        }
413        1.0 - (self.percentage / 100.0).clamp(0.0, 1.0)
414    }
415
416    /// Consumed amount in this window.
417    pub fn consumed(&self) -> u64 {
418        if let Some(current_value) = self.current_value {
419            return current_value;
420        }
421        if let (Some(usage), Some(remaining)) = (self.usage, self.remaining) {
422            return usage.saturating_sub(remaining);
423        }
424        let consumed = (self.number as f64) * (self.percentage / 100.0);
425        consumed.round() as u64
426    }
427
428    /// Alias for [`CodingPlanQuotaLimit::consumed`].
429    pub fn used(&self) -> u64 {
430        self.consumed()
431    }
432
433    /// Parsed reset time in UTC when `next_reset_time` is a Unix timestamp or
434    /// RFC3339 datetime.
435    pub fn next_reset_at(&self) -> Option<DateTime<Utc>> {
436        self.next_reset_time
437            .as_deref()
438            .and_then(parse_next_reset_time)
439    }
440
441    /// Return the usage attributed to one model code, if present.
442    pub fn usage_for_model(&self, model_code: &str) -> Option<u64> {
443        self.usage_details
444            .iter()
445            .find(|detail| detail.model_code.as_deref() == Some(model_code))
446            .map(|detail| detail.usage)
447    }
448
449    /// Parsed reset time in UTC+08:00 (Beijing / Shanghai fixed offset).
450    pub fn next_reset_at_beijing(&self) -> Option<DateTime<FixedOffset>> {
451        self.next_reset_at()
452            .map(|datetime| datetime.with_timezone(&beijing_offset()))
453    }
454
455    /// Build an easy-to-use normalized view for this quota window.
456    pub fn summary(&self) -> CodingPlanQuotaSummary {
457        CodingPlanQuotaSummary {
458            kind: self.kind(),
459            type_: self.type_.clone(),
460            unit: self.unit.clone(),
461            number: self.number,
462            reported_usage: self.usage,
463            current_value: self.current_value,
464            reported_remaining: self.remaining,
465            quota: self.quota(),
466            used: self.used(),
467            remaining: self.remaining(),
468            percentage: self.percentage,
469            next_reset_time: self.next_reset_time.clone(),
470            next_reset_at: self.next_reset_at(),
471            usage_details: self.usage_details.clone(),
472        }
473    }
474
475    /// True when this is the per-5-hour time window.
476    pub fn is_time_limit(&self) -> bool {
477        self.type_.eq_ignore_ascii_case("TIME_LIMIT")
478    }
479
480    /// True when this is the weekly tokens window.
481    pub fn is_tokens_limit(&self) -> bool {
482        self.type_.eq_ignore_ascii_case("TOKENS_LIMIT")
483    }
484}
485
486impl fmt::Display for CodingPlanQuotaLimit {
487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488        self.summary().fmt(f)
489    }
490}
491
492/// Standard `{code, msg, success, data}` envelope used by the monitor API.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct CodingPlanUsageResponse {
495    /// Business status code (0 / 200 indicate success).
496    #[serde(default)]
497    pub code: i64,
498    /// Human-readable status message.
499    #[serde(
500        default,
501        rename = "msg",
502        deserialize_with = "de_opt_string_from_string_or_number",
503        skip_serializing_if = "Option::is_none"
504    )]
505    pub msg: Option<String>,
506    /// Whether the request succeeded.
507    #[serde(default)]
508    pub success: bool,
509    /// Quota payload.
510    #[serde(default)]
511    pub data: CodingPlanUsageData,
512}
513
514impl CodingPlanUsageResponse {
515    /// The active quota windows.
516    pub fn limits(&self) -> &[CodingPlanQuotaLimit] {
517        &self.data.limits
518    }
519
520    /// Find the first window matching a limit type (case-insensitive).
521    pub fn find_limit(&self, type_: &str) -> Option<&CodingPlanQuotaLimit> {
522        self.data
523            .limits
524            .iter()
525            .find(|l| l.type_.eq_ignore_ascii_case(type_))
526    }
527
528    /// The per-5-hour time window, if present.
529    pub fn time_limit(&self) -> Option<&CodingPlanQuotaLimit> {
530        self.find_limit("TIME_LIMIT")
531    }
532
533    /// The weekly tokens window, if present.
534    pub fn tokens_limit(&self) -> Option<&CodingPlanQuotaLimit> {
535        self.find_limit("TOKENS_LIMIT")
536    }
537
538    /// The subscribed plan level, if reported by the server.
539    pub fn level(&self) -> Option<&str> {
540        self.data.level.as_deref()
541    }
542
543    /// Build an easy-to-use normalized view of the quota response.
544    pub fn summary(&self) -> CodingPlanUsageSummary {
545        CodingPlanUsageSummary {
546            code: self.code,
547            msg: self.msg.clone(),
548            success: self.success,
549            level: self.data.level.clone(),
550            limits: self
551                .data
552                .limits
553                .iter()
554                .map(CodingPlanQuotaLimit::summary)
555                .collect(),
556        }
557    }
558
559    /// The per-5-hour window as a normalized summary, if present.
560    pub fn time_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
561        self.time_limit().map(CodingPlanQuotaLimit::summary)
562    }
563
564    /// The weekly tokens window as a normalized summary, if present.
565    pub fn tokens_limit_summary(&self) -> Option<CodingPlanQuotaSummary> {
566        self.tokens_limit().map(CodingPlanQuotaLimit::summary)
567    }
568}
569
570impl fmt::Display for CodingPlanUsageResponse {
571    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572        self.summary().fmt(f)
573    }
574}
575
576/// Coding Plan usage / quota query request
577/// (`GET /api/monitor/usage/quota/limit`).
578///
579/// Construct with [`CodingPlanUsageRequest::new`] and execute with
580/// [`CodingPlanUsageRequest::send`]. Implements [`HttpClient`] so it flows
581/// through the shared retry / connection-pool / masking pipeline.
582///
583/// ```rust,no_run
584/// use zai_rs::usage::CodingPlanUsageRequest;
585///
586/// # async fn go(key: String) -> zai_rs::ZaiResult<()> {
587/// let resp = CodingPlanUsageRequest::new(key).send().await?;
588/// if let Some(five_hour) = resp.time_limit() {
589///     tracing::info!("5h window: {}% used", five_hour.percentage);
590/// }
591/// # Ok(())
592/// # }
593/// ```
594pub struct CodingPlanUsageRequest {
595    /// Bearer API key.
596    pub key: String,
597    url: String,
598    endpoint_config: EndpointConfig,
599    api_base: ApiBase,
600    http_config: Arc<HttpClientConfig>,
601    _body: (),
602}
603
604impl CodingPlanUsageRequest {
605    /// Build a quota query using the official monitor base URL.
606    pub fn new(key: String) -> Self {
607        let endpoint_config = EndpointConfig::default();
608        let api_base = ApiBase::Monitor;
609        let url = endpoint_config.url(&api_base, paths::MONITOR_USAGE_QUOTA_LIMIT);
610        Self {
611            key,
612            url,
613            endpoint_config,
614            api_base,
615            http_config: Arc::new(HttpClientConfig::default()),
616            _body: (),
617        }
618    }
619
620    fn rebuild_url(&mut self) {
621        self.url = self
622            .endpoint_config
623            .url(&self.api_base, paths::MONITOR_USAGE_QUOTA_LIMIT);
624    }
625
626    /// Override the base URL (e.g. `https://api.z.ai/api/monitor` for the
627    /// international endpoint).
628    pub fn with_base_url(mut self, base: impl Into<String>) -> Self {
629        self.api_base = ApiBase::Custom(base.into());
630        self.rebuild_url();
631        self
632    }
633
634    /// Replace the full endpoint configuration.
635    pub fn with_endpoint_config(mut self, endpoint_config: EndpointConfig) -> Self {
636        self.endpoint_config = endpoint_config;
637        self.rebuild_url();
638        self
639    }
640
641    /// Override the monitor base URL only.
642    pub fn with_monitor_base(mut self, base: impl Into<String>) -> Self {
643        self.endpoint_config = self.endpoint_config.with_monitor_base(base);
644        self.rebuild_url();
645        self
646    }
647
648    /// Customize the HTTP transport (timeout, retries, masking, …).
649    pub fn with_http_config(mut self, config: HttpClientConfig) -> Self {
650        self.http_config = Arc::new(config);
651        self
652    }
653
654    /// Resolve the configured URL for this request.
655    pub fn url(&self) -> &str {
656        &self.url
657    }
658
659    /// Send the quota query and parse the typed envelope.
660    pub async fn send(&self) -> ZaiResult<CodingPlanUsageResponse> {
661        let resp = self.get().await?;
662        let parsed = parse_typed_response::<CodingPlanUsageResponse>(resp).await?;
663        Ok(parsed)
664    }
665}
666
667impl HttpClient for CodingPlanUsageRequest {
668    type Body = ();
669    type ApiUrl = String;
670    type ApiKey = String;
671
672    fn api_url(&self) -> &Self::ApiUrl {
673        &self.url
674    }
675    fn api_key(&self) -> &Self::ApiKey {
676        &self.key
677    }
678    fn body(&self) -> &Self::Body {
679        &self._body
680    }
681
682    fn http_config(&self) -> Arc<HttpClientConfig> {
683        Arc::clone(&self.http_config)
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn deserializes_typical_envelope() {
693        let raw = r#"{
694            "code": 200,
695            "msg": "ok",
696            "success": true,
697            "data": {
698                "level": "max",
699                "limits": [
700                    {
701                        "type": "TIME_LIMIT",
702                        "unit": "5h",
703                        "number": 600,
704                        "percentage": 25.0,
705                        "nextResetTime": "2026-06-18T15:00:00Z"
706                    },
707                    {
708                        "type": "TOKENS_LIMIT",
709                        "unit": "week",
710                        "number": 1000000,
711                        "percentage": 50.0,
712                        "nextResetTime": "2026-06-22T00:00:00Z"
713                    }
714                ]
715            }
716        }"#;
717        let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
718        assert!(resp.success);
719        assert_eq!(resp.level(), Some("max"));
720        let t = resp.time_limit().unwrap();
721        assert!(t.is_time_limit());
722        assert_eq!(t.consumed(), 150);
723        assert_eq!(t.remaining(), 450);
724        assert!((t.remaining_ratio() - 0.75).abs() < 1e-9);
725        assert_eq!(t.next_reset_time.as_deref(), Some("2026-06-18T15:00:00Z"));
726        assert_eq!(
727            t.next_reset_at().unwrap().to_rfc3339(),
728            "2026-06-18T15:00:00+00:00"
729        );
730        let w = resp.tokens_limit().unwrap();
731        assert!(w.is_tokens_limit());
732        assert_eq!(w.consumed(), 500_000);
733        assert_eq!(w.remaining(), 500_000);
734    }
735
736    #[test]
737    fn deserializes_numeric_plan_level() {
738        let raw = r#"{
739            "code": 200,
740            "msg": "ok",
741            "success": true,
742            "data": {
743                "level": 3,
744                "limits": [
745                    {
746                        "type": "TIME_LIMIT",
747                        "unit": 5,
748                        "number": 600,
749                        "percentage": 25.0,
750                        "usage": 4000,
751                        "currentValue": 54,
752                        "remaining": 3946,
753                        "nextResetTime": 1781778751996,
754                        "usageDetails": [
755                            {"modelCode": "search-prime", "usage": 40},
756                            {"modelCode": "web-reader", "usage": 14}
757                        ]
758                    }
759                ]
760            }
761        }"#;
762
763        let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
764
765        assert_eq!(resp.level(), Some("3"));
766        let limit = resp.time_limit().unwrap();
767        assert_eq!(limit.unit.as_deref(), Some("5"));
768        assert_eq!(limit.quota(), 4000);
769        assert_eq!(limit.consumed(), 54);
770        assert_eq!(limit.remaining(), 3946);
771        assert_eq!(limit.usage_details.len(), 2);
772        assert_eq!(
773            limit.usage_details[0].model_code.as_deref(),
774            Some("search-prime")
775        );
776        assert_eq!(limit.usage_for_model("web-reader"), Some(14));
777        assert_eq!(limit.next_reset_time.as_deref(), Some("1781778751996"));
778
779        let summary = resp.summary();
780        assert_eq!(summary.code, 200);
781        assert_eq!(summary.msg.as_deref(), Some("ok"));
782        assert!(summary.success);
783        assert_eq!(summary.level.as_deref(), Some("3"));
784        let time_limit = summary.time_limit().unwrap();
785        assert_eq!(time_limit.kind, CodingPlanQuotaKind::TimeLimit);
786        assert_eq!(time_limit.number, 600);
787        assert_eq!(time_limit.reported_usage, Some(4000));
788        assert_eq!(time_limit.current_value, Some(54));
789        assert_eq!(time_limit.reported_remaining, Some(3946));
790        assert_eq!(time_limit.quota, 4000);
791        assert_eq!(time_limit.used, 54);
792        assert_eq!(time_limit.remaining, 3946);
793        assert_eq!(time_limit.usage_for_model("search-prime"), Some(40));
794        assert_eq!(
795            time_limit.next_reset_at.as_ref().unwrap().to_rfc3339(),
796            "2026-06-18T10:32:31.996+00:00"
797        );
798        assert_eq!(
799            time_limit
800                .next_reset_at_beijing()
801                .as_ref()
802                .unwrap()
803                .to_rfc3339(),
804            "2026-06-18T18:32:31.996+08:00"
805        );
806
807        let report = summary.to_string();
808        assert!(report.contains("code: 200"));
809        assert!(report.contains("msg: ok"));
810        assert!(report.contains("success: true"));
811        assert!(report.contains("number: 600"));
812        assert!(report.contains("current_value: 54"));
813        assert!(report.contains("reported_remaining: 3946"));
814        assert!(report.contains("next_reset_at_utc: 2026-06-18T10:32:31.996+00:00"));
815        assert!(report.contains("next_reset_at_beijing: 2026-06-18T18:32:31.996+08:00"));
816        assert!(report.contains("usage_details:"));
817        assert!(report.contains("search-prime: 40"));
818    }
819
820    #[test]
821    fn handles_missing_optional_fields() {
822        let raw = r#"{"code":0,"success":true,"data":{"limits":[]}}"#;
823        let resp: CodingPlanUsageResponse = serde_json::from_str(raw).unwrap();
824        assert!(resp.limits().is_empty());
825        assert!(resp.level().is_none());
826        assert!(resp.time_limit().is_none());
827    }
828
829    #[test]
830    fn remaining_is_zero_when_exhausted() {
831        let limit = CodingPlanQuotaLimit {
832            type_: "TIME_LIMIT".to_string(),
833            unit: Some("5h".to_string()),
834            number: 100,
835            percentage: 100.0,
836            usage: None,
837            current_value: None,
838            remaining: None,
839            next_reset_time: None,
840            usage_details: Vec::new(),
841        };
842        assert_eq!(limit.remaining(), 0);
843        assert_eq!(limit.consumed(), 100);
844    }
845
846    #[test]
847    fn request_targets_official_monitor_endpoint() {
848        let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string());
849        assert_eq!(
850            req.url(),
851            "https://open.bigmodel.cn/api/monitor/usage/quota/limit"
852        );
853    }
854
855    #[test]
856    fn request_custom_base_overrides_url() {
857        let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string())
858            .with_base_url("https://api.z.ai/api/monitor");
859        assert_eq!(req.url(), "https://api.z.ai/api/monitor/usage/quota/limit");
860    }
861
862    #[test]
863    fn request_monitor_base_overrides_url() {
864        let req = CodingPlanUsageRequest::new("abcdefghij.0123456789abcdef".to_string())
865            .with_monitor_base("https://api.z.ai/api/monitor");
866        assert_eq!(req.url(), "https://api.z.ai/api/monitor/usage/quota/limit");
867    }
868}