Skip to main content

ai_usagebar/zai/
fetch.rs

1//! Z.AI fetch. Note the auth-header quirk — the API key is passed as
2//! `Authorization: <KEY>` WITHOUT the `Bearer` prefix. Sending `Bearer …`
3//! returns 401.
4
5use std::time::Duration;
6
7use crate::cache::{Cache, acquire_lock_async};
8use crate::error::{AppError, Result};
9use crate::usage::ZaiSnapshot;
10
11use super::types::Envelope;
12
13pub const QUOTA_URL: &str = "https://api.z.ai/api/monitor/usage/quota/limit";
14const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
15const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
16
17#[derive(Debug, Clone)]
18pub struct Endpoints {
19    pub quota: String,
20}
21
22impl Default for Endpoints {
23    fn default() -> Self {
24        Self {
25            quota: QUOTA_URL.into(),
26        }
27    }
28}
29
30/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
31/// specialised to its snapshot.
32pub type FetchOutcome = crate::outcome::Outcome<ZaiSnapshot>;
33
34pub async fn fetch_snapshot(
35    client: &reqwest::Client,
36    api_key: &str,
37    cache: &Cache,
38    endpoints: &Endpoints,
39    cache_ttl: Duration,
40    config_plan_tier: Option<&str>,
41) -> Result<FetchOutcome> {
42    cache.ensure_dir()?;
43    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
44
45    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
46        && let Ok(outcome) = reuse(bytes, cache, false, config_plan_tier)
47    {
48        return Ok(outcome);
49    }
50    // Corrupt fresh cache: fall through to live fetch rather than return a
51    // fabricated "GLM Coding Unknown" snapshot with empty windows.
52
53    match fetch_live(client, &endpoints.quota, api_key).await {
54        Ok((bytes, env)) => {
55            // Only a validated envelope reaches the cache, so a 200 carrying
56            // `success: false` can never overwrite the last good payload nor
57            // clear the recorded error.
58            cache.write_payload(&bytes)?;
59            Ok(crate::outcome::Outcome::fresh(
60                env.into_snapshot(config_plan_tier),
61            ))
62        }
63        Err(e) if e.is_transient() => fallback_silent(cache, config_plan_tier, e),
64        Err(AppError::Http { status, body }) => {
65            cache.mark_stale();
66            let last_error = Some(cache.write_last_error(status, &body));
67            fallback_with_error(
68                cache,
69                last_error,
70                config_plan_tier,
71                AppError::Http { status, body },
72            )
73        }
74        Err(e) => {
75            cache.mark_stale();
76            let last_error = Some(cache.write_last_error(0, &e.to_string()));
77            fallback_with_error(cache, last_error, config_plan_tier, e)
78        }
79    }
80}
81
82fn reuse(bytes: Vec<u8>, cache: &Cache, stale: bool, tier: Option<&str>) -> Result<FetchOutcome> {
83    Ok(crate::outcome::Outcome::cached(
84        parse_cache(&bytes, tier)?,
85        cache,
86        stale,
87    ))
88}
89
90fn parse_cache(bytes: &[u8], tier: Option<&str>) -> Result<ZaiSnapshot> {
91    let env: Envelope = serde_json::from_slice(bytes)?;
92    // A cached failure envelope is not usage data, even if it parses.
93    env.check_ok()?;
94    Ok(env.into_snapshot(tier))
95}
96
97fn fallback_silent(cache: &Cache, tier: Option<&str>, original: AppError) -> Result<FetchOutcome> {
98    crate::outcome::fallback(cache, None, original, |bytes| parse_cache(bytes, tier))
99}
100
101fn fallback_with_error(
102    cache: &Cache,
103    last_error: Option<(u16, String)>,
104    tier: Option<&str>,
105    original: AppError,
106) -> Result<FetchOutcome> {
107    crate::outcome::fallback(cache, last_error, original, |bytes| {
108        parse_cache(bytes, tier)
109    })
110}
111
112/// Returns the raw bytes (for the cache) alongside the *validated* envelope,
113/// so the caller cannot accidentally cache a body it never checked.
114async fn fetch_live(
115    client: &reqwest::Client,
116    url: &str,
117    api_key: &str,
118) -> Result<(Vec<u8>, Envelope)> {
119    let resp = tokio::time::timeout(
120        HTTP_TIMEOUT,
121        client
122            .get(url)
123            .header("Authorization", api_key) // NO `Bearer ` prefix.
124            .header("Accept-Language", "en-US,en")
125            .header("Content-Type", "application/json")
126            .send(),
127    )
128    .await
129    .map_err(|_| AppError::Transport(format!("zai timeout: {url}")))??;
130
131    let status = resp.status();
132    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
133
134    if !status.is_success() {
135        let body = String::from_utf8_lossy(&bytes).chars().take(200).collect();
136        return Err(AppError::Http {
137            status: status.as_u16(),
138            body,
139        });
140    }
141
142    // Schema drift surfaces here — and so does Z.AI's in-band failure shape,
143    // which arrives as HTTP 200 with `success: false`. Both are errors, so the
144    // caller's fallback path runs and the good cache survives.
145    let env: Envelope = serde_json::from_slice(&bytes)
146        .map_err(|e| AppError::Schema(format!("zai quota response: {e}")))?;
147    env.check_ok()?;
148    Ok((bytes, env))
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use tempfile::TempDir;
155
156    fn cache_fixture() -> (TempDir, Cache) {
157        let td = TempDir::new().unwrap();
158        let cache = Cache::at(td.path().join("zai"));
159        cache.ensure_dir().unwrap();
160        (td, cache)
161    }
162
163    const GOOD_BODY: &str = r#"{"code":200,"msg":"Operation successful","data":{
164        "limits":[{"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42}],
165        "level":"pro"},"success":true}"#;
166
167    #[tokio::test]
168    async fn in_band_failure_on_200_is_rejected_and_keeps_the_good_cache() {
169        // The regression this guards: a 200 carrying `success:false` used to be
170        // written to cache, clearing the recorded error, and rendered as an
171        // unknown plan with empty windows — visually identical to "no usage".
172        let mut server = mockito::Server::new_async().await;
173        server
174            .mock("GET", "/api/monitor/usage/quota/limit")
175            .with_status(200)
176            .with_body(r#"{"code":401,"msg":"Unauthorized","data":null,"success":false}"#)
177            .create_async()
178            .await;
179
180        let (_td, cache) = cache_fixture();
181        cache.write_payload(GOOD_BODY.as_bytes()).unwrap();
182
183        let client = reqwest::Client::new();
184        let endpoints = Endpoints {
185            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
186        };
187        let out = fetch_snapshot(
188            &client,
189            "k",
190            &cache,
191            &endpoints,
192            Duration::from_secs(0),
193            None,
194        )
195        .await
196        .unwrap();
197
198        // The last good figure is still shown, flagged stale...
199        assert!(out.stale);
200        assert_eq!(out.snapshot.plan, "GLM Coding Pro");
201        // ...and the good payload was NOT overwritten by the failure envelope.
202        let cached = String::from_utf8(cache.maybe_payload().unwrap().unwrap()).unwrap();
203        assert!(cached.contains("\"success\":true"), "cache was clobbered");
204    }
205
206    #[tokio::test]
207    async fn in_band_failure_with_no_cache_surfaces_the_error() {
208        let mut server = mockito::Server::new_async().await;
209        server
210            .mock("GET", "/api/monitor/usage/quota/limit")
211            .with_status(200)
212            .with_body(r#"{"code":500,"msg":"boom","data":null,"success":false}"#)
213            .create_async()
214            .await;
215
216        let (_td, cache) = cache_fixture();
217        let client = reqwest::Client::new();
218        let endpoints = Endpoints {
219            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
220        };
221        let out = fetch_snapshot(
222            &client,
223            "k",
224            &cache,
225            &endpoints,
226            Duration::from_secs(0),
227            None,
228        )
229        .await;
230        assert!(out.is_err(), "expected an error, got {out:?}");
231    }
232
233    #[tokio::test]
234    async fn corrupt_fresh_cache_refetches_instead_of_showing_unknown_plan() {
235        let mut server = mockito::Server::new_async().await;
236        server
237            .mock("GET", "/api/monitor/usage/quota/limit")
238            .with_status(200)
239            .with_body(GOOD_BODY)
240            .create_async()
241            .await;
242
243        let (_td, cache) = cache_fixture();
244        cache.write_payload(b"{ truncated").unwrap();
245
246        let client = reqwest::Client::new();
247        let endpoints = Endpoints {
248            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
249        };
250        // Long TTL: the payload IS fresh, it is just unusable.
251        let out = fetch_snapshot(
252            &client,
253            "k",
254            &cache,
255            &endpoints,
256            Duration::from_secs(3600),
257            None,
258        )
259        .await
260        .unwrap();
261        assert_eq!(out.snapshot.plan, "GLM Coding Pro");
262        assert!(!out.stale);
263    }
264
265    #[tokio::test]
266    async fn live_200_parses_real_shape() {
267        let mut server = mockito::Server::new_async().await;
268        server
269            .mock("GET", "/api/monitor/usage/quota/limit")
270            .with_status(200)
271            .with_body(
272                r#"{"code":200,"msg":"Operation successful","data":{
273                    "limits":[
274                        {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":42},
275                        {"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":15,"nextResetTime":1779792169974}
276                    ],"level":"pro"
277                },"success":true}"#,
278            )
279            .create_async()
280            .await;
281
282        let (_td, cache) = cache_fixture();
283        let client = reqwest::Client::new();
284        let endpoints = Endpoints {
285            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
286        };
287        let out = fetch_snapshot(
288            &client,
289            "fake-key",
290            &cache,
291            &endpoints,
292            Duration::from_secs(0),
293            None,
294        )
295        .await
296        .unwrap();
297        assert_eq!(out.snapshot.plan, "GLM Coding Pro");
298        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 42);
299        assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 15);
300    }
301
302    #[tokio::test]
303    async fn http_401_falls_back_to_cache_when_present() {
304        let mut server = mockito::Server::new_async().await;
305        server
306            .mock("GET", "/api/monitor/usage/quota/limit")
307            .with_status(401)
308            .with_body(r#"{"code":401,"msg":"Unauthorized"}"#)
309            .create_async()
310            .await;
311
312        let (_td, cache) = cache_fixture();
313        let seed = r#"{"code":200,"data":{"limits":[
314            {"type":"TOKENS_LIMIT","unit":3,"percentage":10}
315        ],"level":"lite"},"success":true}"#;
316        cache.write_payload(seed.as_bytes()).unwrap();
317
318        let client = reqwest::Client::new();
319        let endpoints = Endpoints {
320            quota: format!("{}/api/monitor/usage/quota/limit", server.url()),
321        };
322        let out = fetch_snapshot(
323            &client,
324            "k",
325            &cache,
326            &endpoints,
327            Duration::from_secs(0),
328            None,
329        )
330        .await
331        .unwrap();
332        assert!(out.stale);
333        assert_eq!(out.snapshot.session.as_ref().unwrap().utilization_pct, 10);
334        assert_eq!(out.last_error.as_ref().map(|(c, _)| *c), Some(401));
335    }
336}