Skip to main content

ai_usagebar/modelstudio/
fetch.rs

1//! Fetch Model Studio Token Plan usage from the console gateway, with the
2//! `bl` CLI's own console session (`creds.rs`). Cache/stale/error-fallback
3//! shape mirrors `grokbot::fetch`: fresh cache short-circuits; on failure,
4//! fall back to cache + mark stale. The cache is scoped by a fingerprint of
5//! the access token — a re-login selects another console session, so a cache
6//! written for one must never be shown for another, and the token itself is
7//! never persisted.
8
9use std::time::Duration;
10
11use crate::cache::{Cache, acquire_lock_async};
12use crate::error::{AppError, Result};
13use crate::usage::{ModelStudioSnapshot, UsageWindow};
14use crate::vendor::{MAX_BODY_BYTES, read_body_capped};
15
16use super::creds::Credentials;
17use super::types::{
18    ConsoleRegion, ConsoleSite, FIVE_HOUR_WINDOW, WEEKLY_WINDOW, form_body, gateway_for,
19    parse_response, usage_path,
20};
21
22const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
23const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
24
25/// Where the gateway lives for one region×site cell: `https://{host}` plus
26/// the shared `/cli/api.json` path. The `base` is the test seam — production
27/// builds it from [`Self::for_gateway`], tests point it at mockito.
28#[derive(Debug, Clone)]
29pub struct Endpoints {
30    pub base: String,
31}
32
33impl Endpoints {
34    pub fn for_gateway(region: ConsoleRegion, site: ConsoleSite) -> Self {
35        Self {
36            base: format!("https://{}", gateway_for(region, site).host),
37        }
38    }
39
40    fn usage_url(&self, action: &str) -> String {
41        format!("{}{}", self.base, usage_path(action))
42    }
43}
44
45impl Default for Endpoints {
46    fn default() -> Self {
47        Self::for_gateway(ConsoleRegion::CnBeijing, ConsoleSite::Domestic)
48    }
49}
50
51/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
52/// specialised to its snapshot.
53pub type FetchOutcome = crate::outcome::Outcome<ModelStudioSnapshot>;
54
55/// Production entry: resolve the CLI's session (region and site included),
56/// then fetch through the matching gateway.
57pub async fn fetch_snapshot(
58    client: &reqwest::Client,
59    cfg: &crate::config::ModelStudioConfig,
60    cache: &Cache,
61    cache_ttl: Duration,
62) -> Result<FetchOutcome> {
63    let creds = super::resolve_credentials(cfg)?;
64    let endpoints = Endpoints::for_gateway(creds.region, creds.site);
65    fetch_snapshot_with(client, &creds, cache, &endpoints, cache_ttl).await
66}
67
68/// Fetch with the credential and endpoints resolved by the caller.
69pub async fn fetch_snapshot_with(
70    client: &reqwest::Client,
71    creds: &Credentials,
72    cache: &Cache,
73    endpoints: &Endpoints,
74    cache_ttl: Duration,
75) -> Result<FetchOutcome> {
76    cache.ensure_dir()?;
77    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
78
79    // The cache is scoped to the login: a re-login must not keep serving the
80    // previous session's figures.
81    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
82        && let Ok(outcome) = reuse_cache(&bytes, cache, false, &creds.fingerprint)
83    {
84        return Ok(outcome);
85    }
86
87    match usage_call(client, creds, endpoints).await {
88        Ok(snap) => {
89            let bytes = serde_json::to_vec(&snap_to_json(&snap, &creds.fingerprint))?;
90            cache.write_payload(&bytes)?;
91            Ok(crate::outcome::Outcome::fresh(snap))
92        }
93        Err(e) if e.is_transient() => fallback_silent(cache, &creds.fingerprint, e),
94        Err(e) => {
95            cache.mark_stale();
96            if let Some((code, msg)) = error_to_pair(&e) {
97                cache.write_last_error(code, &msg);
98            }
99            fallback_with_error(cache, &creds.fingerprint, e)
100        }
101    }
102}
103
104fn fallback_silent(cache: &Cache, fingerprint: &str, original: AppError) -> Result<FetchOutcome> {
105    crate::outcome::fallback(cache, None, original, |bytes| {
106        parse_cache_at(bytes, fingerprint)
107    })
108}
109
110fn fallback_with_error(
111    cache: &Cache,
112    fingerprint: &str,
113    original: AppError,
114) -> Result<FetchOutcome> {
115    let last_error = error_to_pair(&original);
116    crate::outcome::fallback(cache, last_error, original, |bytes| {
117        parse_cache_at(bytes, fingerprint)
118    })
119}
120
121/// Never surface upstream bodies for auth failures: a 401/403 from a
122/// Bearer-token endpoint is not guaranteed not to echo something
123/// account-identifying back. Mirrors `grokbot::fetch::error_to_pair`.
124fn error_to_pair(e: &AppError) -> Option<(u16, String)> {
125    match e {
126        AppError::Http { status, .. } if matches!(status, 401 | 403) => {
127            Some((*status, "Model Studio authentication failed".into()))
128        }
129        AppError::Http { status, body } => Some((*status, body.clone())),
130        AppError::Credentials(msg) => Some((0, msg.clone())),
131        e => Some((0, e.to_string())),
132    }
133}
134
135fn reuse_cache(
136    bytes: &[u8],
137    cache: &Cache,
138    stale: bool,
139    fingerprint: &str,
140) -> Result<FetchOutcome> {
141    let snap = parse_cache_at(bytes, fingerprint)?;
142    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
143}
144
145async fn usage_call(
146    client: &reqwest::Client,
147    creds: &Credentials,
148    endpoints: &Endpoints,
149) -> Result<ModelStudioSnapshot> {
150    let action = gateway_for(creds.region, creds.site).action;
151    let resp = tokio::time::timeout(
152        HTTP_TIMEOUT,
153        client
154            .post(endpoints.usage_url(action))
155            .header("Authorization", format!("Bearer {}", creds.access_token))
156            .header("Content-Type", "application/x-www-form-urlencoded")
157            .body(form_body(creds.region))
158            .send(),
159    )
160    .await
161    .map_err(|_| AppError::Transport("modelstudio timeout: console gateway".into()))?
162    .map_err(|e| AppError::Transport(format!("modelstudio transport: {e}")))?;
163
164    let status = resp.status();
165    let bytes = read_body_capped(resp, MAX_BODY_BYTES).await?;
166    if !status.is_success() {
167        // Never surface upstream/proxy bodies: they can contain credentials
168        // or arbitrary markup. The cache records the redacted form centrally.
169        let body = if matches!(status.as_u16(), 401 | 403) {
170            "Model Studio authentication failed".into()
171        } else {
172            format!("Model Studio gateway returned HTTP {}", status.as_u16())
173        };
174        return Err(AppError::Http {
175            status: status.as_u16(),
176            body,
177        });
178    }
179    parse_response(&bytes)?.to_snapshot()
180}
181
182fn snap_to_json(snap: &ModelStudioSnapshot, fingerprint: &str) -> serde_json::Value {
183    let window = |w: &Option<UsageWindow>| match w {
184        Some(w) => serde_json::json!({
185            "pct": w.utilization_pct,
186            "reset_ms": w.resets_at.map(|t| t.timestamp_millis()),
187        }),
188        None => serde_json::Value::Null,
189    };
190    serde_json::json!({
191        "account": fingerprint,
192        "session": window(&snap.session),
193        "weekly": window(&snap.weekly),
194    })
195}
196
197fn parse_cache_at(bytes: &[u8], fingerprint: &str) -> Result<ModelStudioSnapshot> {
198    let v: serde_json::Value = serde_json::from_slice(bytes)?;
199    if v.get("account").and_then(serde_json::Value::as_str) != Some(fingerprint) {
200        return Err(AppError::Schema(
201            "modelstudio cache belongs to a different login; refetching".into(),
202        ));
203    }
204    let invalid = |field: &str| AppError::Schema(format!("modelstudio cache: invalid {field}"));
205    let window = |field: &str, duration| -> Result<Option<UsageWindow>> {
206        match v.get(field) {
207            None | Some(serde_json::Value::Null) => Ok(None),
208            Some(w) => {
209                let pct = w["pct"]
210                    .as_i64()
211                    .filter(|pct| (0..=100).contains(pct))
212                    .ok_or_else(|| invalid(field))? as i32;
213                let resets_at = match w.get("reset_ms") {
214                    None | Some(serde_json::Value::Null) => None,
215                    Some(value) => {
216                        let ms = value
217                            .as_i64()
218                            .filter(|ms| *ms >= 0)
219                            .ok_or_else(|| invalid(field))?;
220                        Some(
221                            chrono::DateTime::from_timestamp_millis(ms)
222                                .ok_or_else(|| invalid(field))?,
223                        )
224                    }
225                };
226                Ok(Some(UsageWindow {
227                    utilization_pct: pct,
228                    resets_at,
229                    window_duration: duration,
230                }))
231            }
232        }
233    };
234    Ok(ModelStudioSnapshot {
235        session: window("session", FIVE_HOUR_WINDOW)?,
236        weekly: window("weekly", WEEKLY_WINDOW)?,
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use tempfile::TempDir;
244
245    fn cache_fixture() -> (TempDir, Cache) {
246        let td = TempDir::new().unwrap();
247        let cache = Cache::at(td.path().join("modelstudio"));
248        cache.ensure_dir().unwrap();
249        (td, cache)
250    }
251
252    fn creds(region: ConsoleRegion, site: ConsoleSite) -> Credentials {
253        Credentials {
254            access_token: "tok-test".into(),
255            site,
256            region,
257            fingerprint: super::super::creds::fingerprint_of("tok-test"),
258        }
259    }
260
261    /// The mock gateway for one cell, matching every part of the request the
262    /// contract specifies: method, the `/cli/api.json` path with this cell's
263    /// `action` and the slash-encoded `api` param, the Bearer token, the form
264    /// content type, and the exact two-field form body (region + cornerstone
265    /// params).
266    fn gateway_mock(server: &mut mockito::ServerGuard, creds: &Credentials) -> mockito::Mock {
267        let action = gateway_for(creds.region, creds.site).action;
268        server
269            .mock("POST", usage_path(action).as_str())
270            .match_header("authorization", "Bearer tok-test")
271            .match_header("content-type", "application/x-www-form-urlencoded")
272            .match_body(mockito::Matcher::Exact(form_body(creds.region)))
273    }
274
275    fn usage_body() -> String {
276        serde_json::json!({
277            "success": true,
278            "DataV2": { "data": { "data": {
279                "per5HourPercentage": 0.4217,
280                "per5HourResetTime": 1789200000000_i64,
281                "per1WeekPercentage": 0.7356,
282                "per1WeekResetTime": 1789600000000_i64,
283            }}}
284        })
285        .to_string()
286    }
287
288    /// All four region×site cells, end to end through the real request
289    /// builder: each sends its own action, its own `region` form value, and
290    /// the api param slash-encoded in every one of them.
291    #[tokio::test]
292    async fn all_four_gateway_cells_build_the_url_and_body() {
293        for (region, site) in [
294            (ConsoleRegion::CnBeijing, ConsoleSite::Domestic),
295            (ConsoleRegion::CnBeijing, ConsoleSite::International),
296            (ConsoleRegion::ApSoutheast1, ConsoleSite::Domestic),
297            (ConsoleRegion::ApSoutheast1, ConsoleSite::International),
298        ] {
299            let mut server = mockito::Server::new_async().await;
300            let c = creds(region, site);
301            let m = gateway_mock(&mut server, &c)
302                .with_status(200)
303                .with_body(usage_body())
304                .create_async()
305                .await;
306
307            let (_td, cache) = cache_fixture();
308            let out = fetch_snapshot_with(
309                &reqwest::Client::new(),
310                &c,
311                &cache,
312                &Endpoints { base: server.url() },
313                Duration::ZERO,
314            )
315            .await
316            .unwrap();
317
318            m.assert_async().await;
319            assert_eq!(
320                out.snapshot.session.as_ref().unwrap().utilization_pct,
321                42,
322                "{region:?}/{site:?}"
323            );
324            assert_eq!(
325                out.snapshot.weekly.as_ref().unwrap().utilization_pct,
326                74,
327                "{region:?}/{site:?}"
328            );
329            assert!(!out.stale);
330        }
331    }
332
333    #[tokio::test]
334    async fn live_fetch_round_trips_the_epoch_ms_resets() {
335        let mut server = mockito::Server::new_async().await;
336        let c = creds(ConsoleRegion::CnBeijing, ConsoleSite::Domestic);
337        gateway_mock(&mut server, &c)
338            .with_status(200)
339            .with_body(usage_body())
340            .create_async()
341            .await;
342
343        let (_td, cache) = cache_fixture();
344        let out = fetch_snapshot_with(
345            &reqwest::Client::new(),
346            &c,
347            &cache,
348            &Endpoints { base: server.url() },
349            Duration::ZERO,
350        )
351        .await
352        .unwrap();
353        assert_eq!(
354            out.snapshot
355                .session
356                .as_ref()
357                .unwrap()
358                .resets_at
359                .unwrap()
360                .timestamp_millis(),
361            1_789_200_000_000
362        );
363        assert_eq!(
364            out.snapshot
365                .weekly
366                .as_ref()
367                .unwrap()
368                .resets_at
369                .unwrap()
370                .timestamp_millis(),
371            1_789_600_000_000
372        );
373
374        // What was written is what the cache serves back.
375        let stored = std::fs::read(cache.payload_path()).unwrap();
376        assert!(!String::from_utf8_lossy(&stored).contains("tok-test"));
377        assert_eq!(
378            parse_cache_at(&stored, &c.fingerprint).unwrap(),
379            out.snapshot
380        );
381    }
382
383    /// `NotLogined` from the gateway is the Credentials re-auth error, and
384    /// with a warm cache the last good figures survive beside it.
385    #[tokio::test]
386    async fn not_logined_is_a_credentials_error_naming_the_fix() {
387        let mut server = mockito::Server::new_async().await;
388        let c = creds(ConsoleRegion::CnBeijing, ConsoleSite::Domestic);
389        gateway_mock(&mut server, &c)
390            .with_status(200)
391            .with_body(r#"{"success":false,"errorCode":"NotLogined"}"#)
392            .create_async()
393            .await;
394
395        let (_td, cache) = cache_fixture();
396        let err = fetch_snapshot_with(
397            &reqwest::Client::new(),
398            &c,
399            &cache,
400            &Endpoints { base: server.url() },
401            Duration::ZERO,
402        )
403        .await
404        .unwrap_err();
405        assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
406        assert!(err.to_string().contains("bl auth login --console"), "{err}");
407    }
408
409    /// A failed re-auth with a warm cache: stale figures plus the redacted
410    /// Credentials diagnostic — never the upstream body.
411    #[tokio::test]
412    async fn not_logined_with_a_warm_cache_serves_it_stale_with_the_fix() {
413        let mut server = mockito::Server::new_async().await;
414        let c = creds(ConsoleRegion::CnBeijing, ConsoleSite::Domestic);
415        gateway_mock(&mut server, &c)
416            .with_status(200)
417            .with_body(r#"{"success":false,"errorCode":"NotLogined","token":"tok-test"}"#)
418            .create_async()
419            .await;
420
421        let (_td, cache) = cache_fixture();
422        let snap = ModelStudioSnapshot {
423            session: Some(UsageWindow {
424                utilization_pct: 42,
425                resets_at: None,
426                window_duration: FIVE_HOUR_WINDOW,
427            }),
428            weekly: None,
429        };
430        cache
431            .write_payload(&serde_json::to_vec(&snap_to_json(&snap, &c.fingerprint)).unwrap())
432            .unwrap();
433
434        let out = fetch_snapshot_with(
435            &reqwest::Client::new(),
436            &c,
437            &cache,
438            &Endpoints { base: server.url() },
439            Duration::ZERO,
440        )
441        .await
442        .unwrap();
443        assert!(out.stale);
444        assert_eq!(out.snapshot, snap);
445        let (code, body) = out.last_error.unwrap();
446        assert_eq!(code, 0);
447        assert!(!body.contains("tok-test"), "{body}");
448        assert!(body.contains("bl auth login --console"), "{body}");
449    }
450
451    /// Any other `success:false` code is a schema failure carrying its own
452    /// name — never a confident zero that could overwrite a good cache.
453    #[tokio::test]
454    async fn other_gateway_failures_are_schema_errors() {
455        let mut server = mockito::Server::new_async().await;
456        let c = creds(ConsoleRegion::CnBeijing, ConsoleSite::Domestic);
457        gateway_mock(&mut server, &c)
458            .with_status(200)
459            .with_body(r#"{"success":false,"errorCode":"SystemExeptionError"}"#)
460            .create_async()
461            .await;
462
463        let (_td, cache) = cache_fixture();
464        let err = fetch_snapshot_with(
465            &reqwest::Client::new(),
466            &c,
467            &cache,
468            &Endpoints { base: server.url() },
469            Duration::ZERO,
470        )
471        .await
472        .unwrap_err();
473        assert!(matches!(err, AppError::Schema(_)), "{err:?}");
474        assert!(err.to_string().contains("SystemExeptionError"), "{err}");
475    }
476
477    /// HTTP 401 bodies are redacted before they reach the cache.
478    #[tokio::test]
479    async fn an_http_401_is_redacted_and_falls_back_to_the_cache() {
480        let mut server = mockito::Server::new_async().await;
481        let c = creds(ConsoleRegion::CnBeijing, ConsoleSite::Domestic);
482        server
483            .mock("POST", usage_path("BroadScopeAspnGateway").as_str())
484            .with_status(401)
485            .with_body(r#"{"message":"Bearer tok-test echoed"}"#)
486            .create_async()
487            .await;
488
489        let (_td, cache) = cache_fixture();
490        let snap = ModelStudioSnapshot {
491            session: None,
492            weekly: Some(UsageWindow {
493                utilization_pct: 74,
494                resets_at: None,
495                window_duration: WEEKLY_WINDOW,
496            }),
497        };
498        cache
499            .write_payload(&serde_json::to_vec(&snap_to_json(&snap, &c.fingerprint)).unwrap())
500            .unwrap();
501
502        let out = fetch_snapshot_with(
503            &reqwest::Client::new(),
504            &c,
505            &cache,
506            &Endpoints { base: server.url() },
507            Duration::ZERO,
508        )
509        .await
510        .unwrap();
511        assert!(out.stale);
512        assert_eq!(out.snapshot.weekly.as_ref().unwrap().utilization_pct, 74);
513        let (code, body) = out.last_error.unwrap();
514        assert_eq!(code, 401);
515        assert_eq!(body, "Model Studio authentication failed");
516    }
517
518    #[tokio::test]
519    async fn a_fresh_cache_is_served_without_a_network_call() {
520        let (_td, cache) = cache_fixture();
521        let c = creds(ConsoleRegion::CnBeijing, ConsoleSite::Domestic);
522        let snap = ModelStudioSnapshot {
523            session: None,
524            weekly: Some(UsageWindow {
525                utilization_pct: 74,
526                resets_at: chrono::DateTime::from_timestamp_millis(1_789_600_000_000),
527                window_duration: WEEKLY_WINDOW,
528            }),
529        };
530        cache
531            .write_payload(&serde_json::to_vec(&snap_to_json(&snap, &c.fingerprint)).unwrap())
532            .unwrap();
533
534        // No mock server at all: a cache hit must never reach the network.
535        let out = fetch_snapshot_with(
536            &reqwest::Client::new(),
537            &c,
538            &cache,
539            &Endpoints {
540                base: "http://127.0.0.1:1".into(),
541            },
542            Duration::from_secs(60),
543        )
544        .await
545        .unwrap();
546
547        assert!(!out.stale);
548        assert_eq!(out.snapshot, snap);
549    }
550
551    /// A cache from a previous login must not be shown for a new one.
552    #[tokio::test]
553    async fn a_cache_from_a_previous_login_is_not_reused() {
554        let mut server = mockito::Server::new_async().await;
555        let c = creds(ConsoleRegion::CnBeijing, ConsoleSite::Domestic);
556        let m = gateway_mock(&mut server, &c)
557            .with_status(200)
558            .with_body(usage_body())
559            .create_async()
560            .await;
561
562        let (_td, cache) = cache_fixture();
563        let stale_login = ModelStudioSnapshot {
564            session: Some(UsageWindow {
565                utilization_pct: 99,
566                resets_at: None,
567                window_duration: FIVE_HOUR_WINDOW,
568            }),
569            weekly: None,
570        };
571        cache
572            .write_payload(
573                &serde_json::to_vec(&snap_to_json(&stale_login, "someone-elses-fingerprint"))
574                    .unwrap(),
575            )
576            .unwrap();
577
578        // A long TTL: only the fingerprint mismatch can explain a refetch.
579        let out = fetch_snapshot_with(
580            &reqwest::Client::new(),
581            &c,
582            &cache,
583            &Endpoints { base: server.url() },
584            Duration::from_secs(3600),
585        )
586        .await
587        .unwrap();
588        m.assert_async().await;
589        assert!(!out.stale);
590        assert_eq!(
591            out.snapshot.session.as_ref().unwrap().utilization_pct,
592            42,
593            "refetched, not 99"
594        );
595    }
596
597    #[tokio::test]
598    async fn a_transport_error_with_a_stale_cache_uses_the_cache() {
599        let (_td, cache) = cache_fixture();
600        let c = creds(ConsoleRegion::CnBeijing, ConsoleSite::Domestic);
601        let snap = ModelStudioSnapshot {
602            session: Some(UsageWindow {
603                utilization_pct: 42,
604                resets_at: None,
605                window_duration: FIVE_HOUR_WINDOW,
606            }),
607            weekly: None,
608        };
609        cache
610            .write_payload(&serde_json::to_vec(&snap_to_json(&snap, &c.fingerprint)).unwrap())
611            .unwrap();
612
613        let out = fetch_snapshot_with(
614            &reqwest::Client::new(),
615            &c,
616            &cache,
617            &Endpoints {
618                base: "http://127.0.0.1:1".into(),
619            },
620            Duration::ZERO,
621        )
622        .await
623        .unwrap();
624
625        assert!(out.stale);
626        assert_eq!(out.snapshot, snap);
627    }
628
629    #[test]
630    fn cache_round_trips_both_windows_and_validates() {
631        let snap = ModelStudioSnapshot {
632            session: Some(UsageWindow {
633                utilization_pct: 42,
634                resets_at: chrono::DateTime::from_timestamp_millis(1_789_200_000_000),
635                window_duration: FIVE_HOUR_WINDOW,
636            }),
637            weekly: Some(UsageWindow {
638                utilization_pct: 74,
639                resets_at: None,
640                window_duration: WEEKLY_WINDOW,
641            }),
642        };
643        let bytes = serde_json::to_vec(&snap_to_json(&snap, "fp")).unwrap();
644        assert_eq!(parse_cache_at(&bytes, "fp").unwrap(), snap);
645
646        // Absent windows round-trip too.
647        let absent = ModelStudioSnapshot {
648            session: None,
649            weekly: None,
650        };
651        let bytes = serde_json::to_vec(&snap_to_json(&absent, "fp")).unwrap();
652        assert_eq!(parse_cache_at(&bytes, "fp").unwrap(), absent);
653
654        // Out-of-range percent, string percent, negative reset, and a foreign
655        // fingerprint are all drift, not figures.
656        for bad in [
657            serde_json::json!({"account":"fp","session":{"pct":150},"weekly":null}),
658            serde_json::json!({"account":"fp","session":{"pct":"42"},"weekly":null}),
659            serde_json::json!({"account":"fp","session":{"pct":42,"reset_ms":-5},"weekly":null}),
660            serde_json::json!({"account":"other","session":null,"weekly":null}),
661            serde_json::json!({"session":null,"weekly":null}),
662        ] {
663            assert!(
664                parse_cache_at(bad.to_string().as_bytes(), "fp").is_err(),
665                "{bad} must not parse"
666            );
667        }
668    }
669
670    /// Endpoints default to the CLI's default row and follow the matrix.
671    #[test]
672    fn endpoints_follow_the_gateway_matrix() {
673        assert_eq!(
674            Endpoints::default().base,
675            "https://bailian-cs.console.aliyun.com"
676        );
677        assert_eq!(
678            Endpoints::for_gateway(ConsoleRegion::ApSoutheast1, ConsoleSite::International).base,
679            "https://bailian-singapore-cs.alibabacloud.com"
680        );
681        assert_eq!(
682            Endpoints::default().usage_url("BroadScopeAspnGateway"),
683            "https://bailian-cs.console.aliyun.com/cli/api.json?\
684             action=BroadScopeAspnGateway&product=sfm_bailian\
685             &api=zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fusage"
686        );
687    }
688}