Skip to main content

forge_primitives/usage/
zai.rs

1//! Z.ai GLM coding plan monitor response shapes.
2//!
3//! Type-only - the HTTP fetcher lives in the forge-gateway Zai
4//! backend. These are the JSON wire shapes; the fetcher deserializes
5//! into them.
6//!
7//! Every Z.ai monitor endpoint answers HTTP 200 regardless of outcome
8//! (a wrong key, a wrong path and a healthy response share the
9//! status), so the verdict lives entirely inside the
10//! `{code, msg, data, success}` envelope and callers key on
11//! `success`/`code`, never on HTTP status.
12
13use serde::{Deserialize, Serialize};
14
15/// `/api/monitor/usage/quota/limit` envelope.
16#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
17pub struct QuotaLimitResponse {
18    pub code: Option<i64>,
19    pub msg: Option<String>,
20    pub data: Option<QuotaLimitData>,
21    #[serde(default)]
22    pub success: bool,
23}
24
25/// The plan tier and its rolling credit windows.
26#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
27pub struct QuotaLimitData {
28    #[serde(default)]
29    pub limits: Vec<QuotaLimitEntry>,
30    /// Plan tier, e.g. `"max"` on GLM Coding Max.
31    #[serde(default)]
32    pub level: Option<String>,
33}
34
35/// One rolling window, in credits. `usage` is the per-window LIMIT
36/// (28000 on a 5h Max window, 140000 weekly), not consumption;
37/// consumption is `usage - remaining`. `unit` 3 counts hours (the
38/// 5-hour window, `number` 5), unit 6 counts weeks (`number` 1).
39///
40/// Deliberately partial: the payload also carries a `percentage` and
41/// a `currentValue` that lags at 0; neither is mapped.
42#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct QuotaLimitEntry {
45    /// Entry kind; only `"CREDIT_LIMIT"` entries carry the windows.
46    #[serde(rename = "type")]
47    pub kind: Option<String>,
48    /// Time unit: 3 = hours, 6 = weeks.
49    pub unit: Option<i64>,
50    /// How many `unit`s the window spans.
51    pub number: Option<i64>,
52    /// The per-window credit limit.
53    pub usage: Option<f64>,
54    /// Credits still available in the window.
55    pub remaining: Option<f64>,
56    /// Epoch milliseconds. Absent on the 5-hour entry until the first
57    /// successful request, which starts the window; the weekly entry
58    /// resets on the purchase anniversary.
59    pub next_reset_time: Option<i64>,
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    /// A fresh Max account: both windows at zero consumption, and the
67    /// 5-hour entry carries no `nextResetTime` yet because no request
68    /// has started its window. The unmapped `percentage` and
69    /// `currentValue` fields are on the payload and must be tolerated.
70    #[test]
71    fn decodes_fresh_account_quota_limit() {
72        let body = br#"{
73            "code": 200,
74            "msg": "success",
75            "data": {
76                "limits": [
77                    {"type":"CREDIT_LIMIT","unit":3,"number":5,"usage":28000,
78                     "remaining":28000,"percentage":0,"currentValue":0},
79                    {"type":"CREDIT_LIMIT","unit":6,"number":1,"usage":140000,
80                     "remaining":140000,"percentage":0,"currentValue":0,
81                     "nextResetTime":1757000000000}
82                ],
83                "level": "max"
84            },
85            "success": true
86        }"#;
87        let envelope: QuotaLimitResponse = serde_json::from_slice(body).expect("decode");
88        assert_eq!(envelope.code, Some(200));
89        assert!(envelope.success);
90        let data = envelope.data.expect("data");
91        assert_eq!(data.level.as_deref(), Some("max"));
92        assert_eq!(data.limits.len(), 2);
93        let five_hour = &data.limits[0];
94        assert_eq!(five_hour.kind.as_deref(), Some("CREDIT_LIMIT"));
95        assert_eq!(five_hour.unit, Some(3));
96        assert_eq!(five_hour.usage, Some(28000.0));
97        assert_eq!(five_hour.remaining, Some(28000.0));
98        assert_eq!(five_hour.next_reset_time, None, "5h entry has no reset until first request");
99        let weekly = &data.limits[1];
100        assert_eq!(weekly.unit, Some(6));
101        assert_eq!(weekly.next_reset_time, Some(1_757_000_000_000));
102    }
103
104    /// After usage: consumption shows in `remaining`, and the 5-hour
105    /// entry has grown a `nextResetTime` now that a request started
106    /// its window.
107    #[test]
108    fn decodes_after_usage_quota_limit() {
109        let body = br#"{
110            "code": 200,
111            "msg": "success",
112            "data": {
113                "limits": [
114                    {"type":"CREDIT_LIMIT","unit":3,"number":5,"usage":28000,
115                     "remaining":27104,"percentage":3.2,"currentValue":0,
116                     "nextResetTime":1757025600000},
117                    {"type":"CREDIT_LIMIT","unit":6,"number":1,"usage":140000,
118                     "remaining":139000,"percentage":0.71,"currentValue":0,
119                     "nextResetTime":1757000000000}
120                ],
121                "level": "max"
122            },
123            "success": true
124        }"#;
125        let data =
126            serde_json::from_slice::<QuotaLimitResponse>(body).expect("decode").data.expect("data");
127        let five_hour = &data.limits[0];
128        assert_eq!(five_hour.remaining, Some(27104.0));
129        assert_eq!(five_hour.next_reset_time, Some(1_757_025_600_000));
130    }
131
132    /// Both auth negatives are body-level on an HTTP 200: the envelope
133    /// must still decode so the failure can be keyed on
134    /// `success`/`code` rather than on transport.
135    #[test]
136    fn decodes_failure_envelopes() {
137        let wrong_key: QuotaLimitResponse = serde_json::from_slice(
138            br#"{"code":401,"msg":"token expired or incorrect","success":false}"#,
139        )
140        .expect("decode");
141        assert!(!wrong_key.success);
142        assert_eq!(wrong_key.code, Some(401));
143        assert_eq!(wrong_key.msg.as_deref(), Some("token expired or incorrect"));
144        assert!(wrong_key.data.is_none());
145
146        let wrong_path: QuotaLimitResponse =
147            serde_json::from_slice(br#"{"code":500,"msg":"404 NOT_FOUND","success":false}"#)
148                .expect("decode");
149        assert!(!wrong_path.success);
150        assert_eq!(wrong_path.code, Some(500));
151    }
152}