Skip to main content

ai_usagebar/modelstudio/
types.rs

1//! Wire types for the Alibaba Cloud Model Studio Token Plan console gateway,
2//! reconstructed from the official `bl` CLI (`packages/core/src/console/gateway.ts`).
3//!
4//! Three shape facts drive everything here:
5//!
6//! - The console gateway is a generic dispatcher: the real API name rides in
7//!   the `api=` query param (slash-encoded) and again inside the `params`
8//!   form field, beside a fixed `cornerstoneParam` console context.
9//! - The response wraps the payload in a **double "DataV2" envelope** with
10//!   several tolerated depths; the CLI's unwrap order is reproduced exactly
11//!   in [`unwrap_payload`].
12//! - `per5HourPercentage` / `per1WeekPercentage` / `per1MonthPercentage` are
13//!   **ratios in [0, 1]**, not percents — `0.4217` means 42%. Reset times are
14//!   epoch **milliseconds**.
15
16use chrono::DateTime;
17use serde_json::Value;
18
19use crate::error::{AppError, Result};
20use crate::usage::{ModelStudioSnapshot, UsageWindow};
21
22/// The billing console the credential belongs to. Unknown wire values fall
23/// back to `Domestic`, the CLI's own default row.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ConsoleSite {
26    Domestic,
27    International,
28}
29
30impl ConsoleSite {
31    pub fn parse(raw: &str) -> Self {
32        if raw.eq_ignore_ascii_case("international") {
33            ConsoleSite::International
34        } else {
35            ConsoleSite::Domestic
36        }
37    }
38}
39
40/// The console region. Unknown wire values fall back to `CnBeijing`, the
41/// CLI's own default row.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ConsoleRegion {
44    CnBeijing,
45    ApSoutheast1,
46}
47
48impl ConsoleRegion {
49    pub fn parse(raw: &str) -> Self {
50        if raw.eq_ignore_ascii_case("ap-southeast-1") {
51            ConsoleRegion::ApSoutheast1
52        } else {
53            ConsoleRegion::CnBeijing
54        }
55    }
56
57    fn as_str(self) -> &'static str {
58        match self {
59            ConsoleRegion::CnBeijing => "cn-beijing",
60            ConsoleRegion::ApSoutheast1 => "ap-southeast-1",
61        }
62    }
63}
64
65/// Host + gateway action for one region×site cell, from the CLI's gateway
66/// table.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct Gateway {
69    pub host: &'static str,
70    pub action: &'static str,
71}
72
73pub fn gateway_for(region: ConsoleRegion, site: ConsoleSite) -> Gateway {
74    match (region, site) {
75        (ConsoleRegion::CnBeijing, ConsoleSite::Domestic) => Gateway {
76            host: "bailian-cs.console.aliyun.com",
77            action: "BroadScopeAspnGateway",
78        },
79        (ConsoleRegion::CnBeijing, ConsoleSite::International) => Gateway {
80            host: "bailian-cs.console.alibabacloud.com",
81            action: "BroadScopeAspnGateway",
82        },
83        (ConsoleRegion::ApSoutheast1, ConsoleSite::Domestic) => Gateway {
84            host: "modelstudio-cs.console.aliyun.com",
85            action: "IntlBroadScopeAspnGateway",
86        },
87        (ConsoleRegion::ApSoutheast1, ConsoleSite::International) => Gateway {
88            host: "bailian-singapore-cs.alibabacloud.com",
89            action: "IntlBroadScopeAspnGateway",
90        },
91    }
92}
93
94/// The Token Plan usage API, verbatim. Every `/` is percent-encoded when it
95/// rides in the `api=` query param (`encodeURIComponent` semantics).
96pub const USAGE_API: &str = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
97/// `USAGE_API` with each `/` as `%2F` — exactly what `encodeURIComponent`
98/// produces, and the only spelling the gateway accepts.
99pub const USAGE_API_ENCODED: &str =
100    "zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fusage";
101/// Fixed gateway product marker.
102pub const PRODUCT: &str = "sfm_bailian";
103
104/// `/cli/api.json?action={action}&product=sfm_bailian&api={api encoded}`.
105pub fn usage_path(action: &str) -> String {
106    format!("/cli/api.json?action={action}&product={PRODUCT}&api={USAGE_API_ENCODED}")
107}
108
109/// The `params` form field: the API name again, the version, and the fixed
110/// console context the CLI sends. Identical for every region×site cell — the
111/// region itself travels in the sibling `region` field.
112pub fn params_body() -> String {
113    serde_json::json!({
114        "Api": USAGE_API,
115        "V": "1.0",
116        "Data": {
117            "cornerstoneParam": {
118                "protocol": "V2",
119                "console": "ONE_CONSOLE",
120                "productCode": "p_efm",
121                "switchUserType": 3,
122                "consoleSite": "BAILIAN_ALIYUN",
123            }
124        },
125    })
126    .to_string()
127}
128
129/// Percent-encode a form value with `encodeURIComponent` semantics: every
130/// byte outside `A-Za-z0-9-._~` becomes `%XX`. (JS also leaves `!'()*` bare;
131/// no value here contains any of those.)
132fn encode_form_value(value: &str) -> String {
133    let mut out = String::with_capacity(value.len());
134    for byte in value.as_bytes() {
135        match byte {
136            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
137                out.push(*byte as char)
138            }
139            _ => out.push_str(&format!("%{byte:02X}")),
140        }
141    }
142    out
143}
144
145/// The two-field form body: `region={region}&params={json}`.
146pub fn form_body(region: ConsoleRegion) -> String {
147    format!(
148        "region={}&params={}",
149        encode_form_value(region.as_str()),
150        encode_form_value(&params_body())
151    )
152}
153
154/// The usage fields the Token Plan response carries. All optional: a missing
155/// percentage means the account has no such window (possibly unlimited), and
156/// the renderer must show the window as absent — never as 0%.
157#[derive(Debug, Clone, Copy, PartialEq)]
158pub struct UsageFields {
159    pub per5_hour_percentage: Option<f64>,
160    pub per1_week_percentage: Option<f64>,
161    pub per1_month_percentage: Option<f64>,
162    pub per5_hour_reset_ms: Option<i64>,
163    pub per1_week_reset_ms: Option<i64>,
164    pub per1_month_reset_ms: Option<i64>,
165}
166
167/// The CLI's tolerant unwrap, verbatim:
168/// `data.DataV2?.data?.data ?? data.DataV2?.data ?? data.DataV2 ?? data.data ?? data`.
169///
170/// The live gateway dispatcher always wraps the response in the mandated
171/// outer `data` object — real captures read `{code, data: {DataV2: {data:
172/// {data: {…fields…}}}}}` — so every CLI path starts from that `data` key.
173/// (An unwrap that only searched for a top-level `DataV2` fell through to the
174/// `data` fallback and never reached the fields: the provider rendered "no
175/// usage windows reported" even on healthy sessions.) The bare
176/// `DataV2`-first spellings are kept as a second pass for gateway cells that
177/// answer without the outer wrapper.
178///
179/// JSON `null` counts as absent, like JS `null`/`undefined` under `??`.
180pub fn unwrap_payload(root: &Value) -> &Value {
181    fn present(v: Option<&Value>) -> Option<&Value> {
182        v.filter(|v| !v.is_null())
183    }
184    let datav2 = root.get("data").and_then(|d| d.get("DataV2"));
185    present(
186        datav2
187            .and_then(|d| d.get("data"))
188            .and_then(|d| d.get("data")),
189    )
190    .or_else(|| present(datav2.and_then(|d| d.get("data"))))
191    .or_else(|| present(datav2))
192    .or_else(|| present(root.get("data").and_then(|d| d.get("data"))))
193    .or_else(|| present(root.get("data")))
194    // Older spellings without the outer `data` wrapper.
195    .or_else(|| {
196        let bare = root.get("DataV2");
197        present(bare.and_then(|d| d.get("data")).and_then(|d| d.get("data")))
198            .or_else(|| present(bare.and_then(|d| d.get("data"))))
199            .or_else(|| present(bare))
200    })
201    .unwrap_or(root)
202}
203
204/// Parse a full gateway response into usage fields. Failures are reported the
205/// way the CLI sees them: `success === false` plus an `errorCode`, where any
206/// `NotLogined`-shaped code means the console session died.
207pub fn parse_response(bytes: &[u8]) -> Result<UsageFields> {
208    let root: Value = serde_json::from_slice(bytes)
209        .map_err(|e| AppError::Schema(format!("modelstudio usage response: {e}")))?;
210    if root.get("success").and_then(Value::as_bool) == Some(false) {
211        let code = root
212            .get("errorCode")
213            .and_then(Value::as_str)
214            .unwrap_or("unknown");
215        if code.contains("NotLogined") {
216            return Err(reauth_error());
217        }
218        return Err(AppError::Schema(format!(
219            "modelstudio gateway error: {code}"
220        )));
221    }
222    let payload = unwrap_payload(&root);
223    Ok(UsageFields {
224        per5_hour_percentage: ratio(payload, "per5HourPercentage")?,
225        per1_week_percentage: ratio(payload, "per1WeekPercentage")?,
226        per1_month_percentage: ratio(payload, "per1MonthPercentage")?,
227        per5_hour_reset_ms: epoch_ms(payload, "per5HourResetTime")?,
228        per1_week_reset_ms: epoch_ms(payload, "per1WeekResetTime")?,
229        per1_month_reset_ms: epoch_ms(payload, "per1MonthResetTime")?,
230    })
231}
232
233/// The re-auth error every dead-console-session path funnels into — the CLI's
234/// own fix, naming its own command.
235pub fn reauth_error() -> AppError {
236    AppError::Credentials(
237        "Model Studio: console session expired; run `bl auth login --console` to re-auth".into(),
238    )
239}
240
241/// One optional ratio in [0, 1]. Present-but-invalid (NaN, negative, > 1) is
242/// schema drift — the wire contract changed — never a silent 0.
243fn ratio(payload: &Value, field: &str) -> Result<Option<f64>> {
244    match payload.get(field) {
245        None | Some(Value::Null) => Ok(None),
246        Some(v) => {
247            let raw = v.as_f64().ok_or_else(|| drift(field, "is not a number"))?;
248            if !raw.is_finite() {
249                return Err(drift(field, "is not a finite number"));
250            }
251            if !(0.0..=1.0).contains(&raw) {
252                return Err(drift(field, "is outside [0,1]"));
253            }
254            Ok(Some(raw))
255        }
256    }
257}
258
259/// One optional epoch-milliseconds timestamp. Present-but-invalid is drift.
260fn epoch_ms(payload: &Value, field: &str) -> Result<Option<i64>> {
261    match payload.get(field) {
262        None | Some(Value::Null) => Ok(None),
263        Some(v) => {
264            let raw = v
265                .as_i64()
266                .ok_or_else(|| drift(field, "is not an integer"))?;
267            if raw < 0 {
268                return Err(drift(field, "is negative"));
269            }
270            if chrono::DateTime::from_timestamp_millis(raw).is_none() {
271                return Err(drift(field, "is out of range"));
272            }
273            Ok(Some(raw))
274        }
275    }
276}
277
278fn drift(field: &str, why: &str) -> AppError {
279    AppError::Schema(format!("modelstudio `{field}` {why}"))
280}
281
282/// The 5h rolling window's length — advertised by the field name, like Kimi's.
283pub const FIVE_HOUR_WINDOW: chrono::Duration = chrono::Duration::hours(5);
284/// The weekly window's length.
285pub const WEEKLY_WINDOW: chrono::Duration = chrono::Duration::days(7);
286/// The monthly window's *nominal* length. The real cycle is plan-dependent
287/// (anywhere from 28 to 31 days — the reset travels on `resets_at`), so like
288/// Ollama's calendar month and OpenCode Go's monthly, this drives nothing that
289/// would be wrong when the cycle runs short or long: bars, triggers and resets
290/// all come from the reported reset, and pacing is deliberately omitted.
291pub const MONTHLY_WINDOW: chrono::Duration = chrono::Duration::days(30);
292
293impl UsageFields {
294    pub fn to_snapshot(&self) -> Result<ModelStudioSnapshot> {
295        let window =
296            |ratio: Option<f64>, reset_ms: Option<i64>, duration| -> Result<Option<UsageWindow>> {
297                ratio
298                    .map(|r| {
299                        Ok(UsageWindow {
300                            utilization_pct: ratio_to_percent(r)?,
301                            resets_at: match reset_ms {
302                                Some(ms) => Some(
303                                    DateTime::from_timestamp_millis(ms)
304                                        .ok_or_else(|| drift("reset time", "is out of range"))?,
305                                ),
306                                None => None,
307                            },
308                            window_duration: duration,
309                        })
310                    })
311                    .transpose()
312            };
313        Ok(ModelStudioSnapshot {
314            session: window(
315                self.per5_hour_percentage,
316                self.per5_hour_reset_ms,
317                FIVE_HOUR_WINDOW,
318            )?,
319            weekly: window(
320                self.per1_week_percentage,
321                self.per1_week_reset_ms,
322                WEEKLY_WINDOW,
323            )?,
324            monthly: window(
325                self.per1_month_percentage,
326                self.per1_month_reset_ms,
327                MONTHLY_WINDOW,
328            )?,
329        })
330    }
331}
332
333/// ×100, rounded: `0.4217` → 42. The parse gate already rejected NaN and
334/// out-of-range values, so this cannot invent a figure.
335fn ratio_to_percent(ratio: f64) -> Result<i32> {
336    let pct = (ratio * 100.0).round();
337    if !(0.0..=100.0).contains(&pct) {
338        return Err(AppError::Schema(format!(
339            "modelstudio ratio {ratio} did not project onto 0..=100"
340        )));
341    }
342    Ok(pct as i32)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn full_envelope() -> String {
350        serde_json::json!({
351            "success": true,
352            "DataV2": {
353                "success": true,
354                "data": {
355                    "data": {
356                        "per5HourPercentage": 0.4217,
357                        "per5HourResetTime": 1789200000000_i64,
358                        "per1WeekPercentage": 0.7356,
359                        "per1WeekResetTime": 1789600000000_i64,
360                        "per1MonthPercentage": 1.0,
361                        "per1MonthResetTime": 1794067200000_i64,
362                    }
363                }
364            }
365        })
366        .to_string()
367    }
368
369    #[test]
370    fn gateway_matrix_covers_every_region_site_cell() {
371        assert_eq!(
372            gateway_for(ConsoleRegion::CnBeijing, ConsoleSite::Domestic),
373            Gateway {
374                host: "bailian-cs.console.aliyun.com",
375                action: "BroadScopeAspnGateway"
376            }
377        );
378        assert_eq!(
379            gateway_for(ConsoleRegion::CnBeijing, ConsoleSite::International),
380            Gateway {
381                host: "bailian-cs.console.alibabacloud.com",
382                action: "BroadScopeAspnGateway"
383            }
384        );
385        assert_eq!(
386            gateway_for(ConsoleRegion::ApSoutheast1, ConsoleSite::Domestic),
387            Gateway {
388                host: "modelstudio-cs.console.aliyun.com",
389                action: "IntlBroadScopeAspnGateway"
390            }
391        );
392        assert_eq!(
393            gateway_for(ConsoleRegion::ApSoutheast1, ConsoleSite::International),
394            Gateway {
395                host: "bailian-singapore-cs.alibabacloud.com",
396                action: "IntlBroadScopeAspnGateway"
397            }
398        );
399    }
400
401    /// Unknown wire values fall back to the CLI's default row rather than
402    /// failing — the config file is the CLI's, not ours.
403    #[test]
404    fn unknown_site_and_region_fall_back_to_the_default_row() {
405        assert_eq!(ConsoleSite::parse("weird"), ConsoleSite::Domestic);
406        assert_eq!(ConsoleSite::parse(""), ConsoleSite::Domestic);
407        assert_eq!(
408            ConsoleSite::parse("INTERNATIONAL"),
409            ConsoleSite::International
410        );
411        assert_eq!(
412            ConsoleRegion::parse("eu-central-1"),
413            ConsoleRegion::CnBeijing
414        );
415        assert_eq!(
416            ConsoleRegion::parse("AP-SOUTHEAST-1"),
417            ConsoleRegion::ApSoutheast1
418        );
419    }
420
421    /// Every `/` in the api param must arrive as `%2F`, and the action and
422    /// product ride beside it — the URL contract the gateway dispatches on.
423    #[test]
424    fn usage_path_encodes_every_slash_in_the_api_param() {
425        let path = usage_path("BroadScopeAspnGateway");
426        assert_eq!(
427            path,
428            "/cli/api.json?action=BroadScopeAspnGateway&product=sfm_bailian\
429             &api=zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fusage"
430        );
431        assert_eq!(path.matches("%2F").count(), 5, "{path}");
432        assert!(!USAGE_API_ENCODED.contains('/'));
433    }
434
435    #[test]
436    fn params_body_carries_the_api_version_and_cornerstone_context() {
437        let params: Value = serde_json::from_str(&params_body()).unwrap();
438        assert_eq!(params["Api"], USAGE_API);
439        assert_eq!(params["V"], "1.0");
440        assert_eq!(params["Data"]["cornerstoneParam"]["protocol"], "V2");
441        assert_eq!(params["Data"]["cornerstoneParam"]["console"], "ONE_CONSOLE");
442        assert_eq!(params["Data"]["cornerstoneParam"]["productCode"], "p_efm");
443        assert_eq!(params["Data"]["cornerstoneParam"]["switchUserType"], 3);
444        assert_eq!(
445            params["Data"]["cornerstoneParam"]["consoleSite"],
446            "BAILIAN_ALIYUN"
447        );
448    }
449
450    #[test]
451    fn form_body_encodes_the_region_and_params_fields() {
452        let body = form_body(ConsoleRegion::CnBeijing);
453        assert!(body.starts_with("region=cn-beijing&params=%7B"), "{body}");
454        assert!(
455            body.contains("%22Api%22%3A%22zeldaHttp.apikeyMgr.%2Ftokenplan"),
456            "{body}"
457        );
458        assert!(body.ends_with("%7D"), "{body}");
459        assert!(
460            form_body(ConsoleRegion::ApSoutheast1).starts_with("region=ap-southeast-1&"),
461            "the region form field carries the wire spelling"
462        );
463    }
464
465    /// The full double envelope, end to end: ratios in, percents out.
466    #[test]
467    fn parses_the_full_double_envelope_into_percent_windows() {
468        let fields = parse_response(full_envelope().as_bytes()).unwrap();
469        assert_eq!(fields.per5_hour_percentage, Some(0.4217));
470        assert_eq!(fields.per1_week_percentage, Some(0.7356));
471        assert_eq!(fields.per1_month_percentage, Some(1.0));
472        assert_eq!(fields.per5_hour_reset_ms, Some(1_789_200_000_000));
473        assert_eq!(fields.per1_week_reset_ms, Some(1_789_600_000_000));
474        assert_eq!(fields.per1_month_reset_ms, Some(1_794_067_200_000));
475
476        let snap = fields.to_snapshot().unwrap();
477        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 42);
478        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 74);
479        assert_eq!(snap.monthly.as_ref().unwrap().utilization_pct, 100);
480        assert_eq!(
481            snap.session.as_ref().unwrap().resets_at,
482            DateTime::from_timestamp_millis(1_789_200_000_000)
483        );
484        assert_eq!(
485            snap.session.as_ref().unwrap().window_duration,
486            FIVE_HOUR_WINDOW
487        );
488        assert_eq!(snap.weekly.as_ref().unwrap().window_duration, WEEKLY_WINDOW);
489        assert_eq!(
490            snap.monthly.as_ref().unwrap().resets_at,
491            DateTime::from_timestamp_millis(1_794_067_200_000)
492        );
493    }
494
495    /// The live gateway wraps the dispatch in the mandated outer `data` object
496    /// (`{code, data: {DataV2: …}}`) — the spelling real captures carry. The
497    /// CLI's unwrap starts from that `data` key; an unwrap that only searched
498    /// for a top-level `DataV2` fell through to the `data` fallback and never
499    /// reached any field, rendering "no usage windows reported" on healthy
500    /// sessions.
501    #[test]
502    fn the_real_gateway_data_envelope_unwraps_to_the_fields() {
503        let fields = parse_response(
504            br#"{"code":"200","data":{"DataV2":{"ret":["SUCCESS::ok"],"data":{
505                "msg":"Success.","code":"SUCCESS","data":{
506                    "per5HourPercentage":0.4217,
507                    "per5HourResetTime":1789200000000,
508                    "per1WeekPercentage":0.7356,
509                    "per1WeekResetTime":1789600000000,
510                    "per1MonthPercentage":1.0,
511                    "per1MonthResetTime":1794067200000
512                },"requestId":"rid-test","success":true}}},
513                "httpStatusCode":"200","successResponse":true}"#,
514        )
515        .unwrap();
516        assert_eq!(fields.per5_hour_percentage, Some(0.4217));
517        assert_eq!(fields.per1_week_percentage, Some(0.7356));
518        assert_eq!(fields.per1_month_percentage, Some(1.0));
519        assert_eq!(fields.per5_hour_reset_ms, Some(1_789_200_000_000));
520        assert_eq!(fields.per1_week_reset_ms, Some(1_789_600_000_000));
521        assert_eq!(fields.per1_month_reset_ms, Some(1_794_067_200_000));
522
523        let snap = fields.to_snapshot().unwrap();
524        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 42);
525        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 74);
526        assert_eq!(snap.monthly.as_ref().unwrap().utilization_pct, 100);
527    }
528
529    /// An account that reports only the monthly window (the individual Token
530    /// Plan shape — no 5h/weekly quotas) still yields a usable snapshot: the
531    /// monthly window, exactly as the console shows it.
532    #[test]
533    fn a_monthly_only_envelope_is_a_monthly_window() {
534        let fields = parse_response(
535            br#"{"data":{"per1MonthPercentage":1.0,"per1MonthResetTime":1794067200000}}"#,
536        )
537        .unwrap();
538        assert_eq!(fields.per5_hour_percentage, None);
539        assert_eq!(fields.per1_week_percentage, None);
540        assert_eq!(fields.per1_month_percentage, Some(1.0));
541        let snap = fields.to_snapshot().unwrap();
542        assert!(snap.session.is_none(), "{snap:?}");
543        assert!(snap.weekly.is_none(), "{snap:?}");
544        assert_eq!(snap.monthly.as_ref().unwrap().utilization_pct, 100);
545        assert_eq!(
546            snap.monthly.as_ref().unwrap().resets_at,
547            DateTime::from_timestamp_millis(1_794_067_200_000)
548        );
549    }
550
551    #[test]
552    fn shallower_envelopes_unwrap_the_same() {
553        let two = serde_json::json!({
554            "DataV2": { "data": {
555                "per5HourPercentage": 0.5,
556                "per1WeekPercentage": 0.25,
557            }}
558        });
559        let fields = parse_response(two.to_string().as_bytes()).unwrap();
560        assert_eq!(fields.per5_hour_percentage, Some(0.5));
561        assert_eq!(fields.per1_week_percentage, Some(0.25));
562
563        let one = serde_json::json!({
564            "DataV2": {
565                "per5HourPercentage": 0.5,
566                "per1WeekPercentage": 0.25,
567            }
568        });
569        let fields = parse_response(one.to_string().as_bytes()).unwrap();
570        assert_eq!(fields.per5_hour_percentage, Some(0.5));
571        assert_eq!(fields.per1_week_percentage, Some(0.25));
572
573        let plain = serde_json::json!({
574            "data": {
575                "per5HourPercentage": 0.5,
576                "per1WeekPercentage": 0.25,
577            }
578        });
579        let fields = parse_response(plain.to_string().as_bytes()).unwrap();
580        assert_eq!(fields.per5_hour_percentage, Some(0.5));
581        assert_eq!(fields.per1_week_percentage, Some(0.25));
582    }
583
584    /// `data.DataV2?.data?.data` wins over the shallower spellings, exactly
585    /// like the CLI's left-to-right `??` chain — starting from the outer
586    /// `data` wrapper the live dispatcher always emits.
587    #[test]
588    fn deeper_envelope_beats_the_shallower_ones() {
589        // Real gateway spelling: the mandated outer `data` wrapper.
590        let v = serde_json::json!({
591            "code": "200",
592            "data": { "DataV2": { "data": { "data": { "per5HourPercentage": 0.11 },
593                                            "per5HourPercentage": 0.22 },
594                                  "per5HourPercentage": 0.33 },
595                      "per5HourPercentage": 0.44 }
596        });
597        assert_eq!(
598            unwrap_payload(&v)
599                .get("per5HourPercentage")
600                .and_then(Value::as_f64),
601            Some(0.11)
602        );
603
604        // `data.DataV2?.data` is null: the chain skips to `data.DataV2`
605        // itself.
606        let v = serde_json::json!({
607            "data": { "DataV2": { "data": null, "per5HourPercentage": 0.33 },
608                      "per5HourPercentage": 0.44 }
609        });
610        assert_eq!(
611            unwrap_payload(&v)
612                .get("per5HourPercentage")
613                .and_then(Value::as_f64),
614            Some(0.33)
615        );
616
617        // No DataV2 at all: `data.data`, then `data`, then the root. The
618        // `data.data ?? data` step picks `data.data` — one level only; it
619        // does not dig a second `.data` out of it.
620        let v = serde_json::json!({ "data": { "per5HourPercentage": 0.66 } });
621        assert_eq!(
622            unwrap_payload(&v)
623                .get("per5HourPercentage")
624                .and_then(Value::as_f64),
625            Some(0.66)
626        );
627        let v = serde_json::json!({ "data": { "data": { "per5HourPercentage": 0.77 } } });
628        assert_eq!(
629            unwrap_payload(&v)
630                .get("per5HourPercentage")
631                .and_then(Value::as_f64),
632            Some(0.77)
633        );
634        // The final `?? data` is the root object itself.
635        let v = serde_json::json!({ "per5HourPercentage": 0.55 });
636        assert_eq!(
637            unwrap_payload(&v)
638                .get("per5HourPercentage")
639                .and_then(Value::as_f64),
640            Some(0.55)
641        );
642        assert_eq!(unwrap_payload(&v), &v);
643    }
644
645    /// An absent percentage is no window at all — never a zero.
646    #[test]
647    fn absent_percentages_leave_the_window_out() {
648        let fields = parse_response(
649            br#"{"data":{"per1WeekPercentage":0.5,"per1WeekResetTime":1789600000000}}"#,
650        )
651        .unwrap();
652        assert_eq!(fields.per5_hour_percentage, None);
653        let snap = fields.to_snapshot().unwrap();
654        assert!(snap.session.is_none(), "{snap:?}");
655        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 50);
656    }
657
658    #[test]
659    fn ratio_math_rounds_after_the_times_hundred() {
660        for (ratio, pct) in [(0.4217, 42), (0.005, 1), (0.0, 0), (1.0, 100), (0.995, 100)] {
661            let fields = UsageFields {
662                per5_hour_percentage: Some(ratio),
663                per1_week_percentage: None,
664                per1_month_percentage: None,
665                per5_hour_reset_ms: None,
666                per1_week_reset_ms: None,
667                per1_month_reset_ms: None,
668            };
669            assert_eq!(
670                fields
671                    .to_snapshot()
672                    .unwrap()
673                    .session
674                    .unwrap()
675                    .utilization_pct,
676                pct,
677                "{ratio}"
678            );
679        }
680    }
681
682    /// NaN / negative / above 1 are a changed wire contract, and must not
683    /// collapse into a confident 0%.
684    #[test]
685    fn invalid_ratios_are_schema_drift_never_zero() {
686        for raw in ["NaN", "-0.1", "1.4", "2", "null_plus", "\"0.5\"", "true"] {
687            let body = format!(r#"{{"data":{{"per5HourPercentage":{raw}}}}}"#);
688            let err = parse_response(body.as_bytes()).unwrap_err();
689            assert!(matches!(err, AppError::Schema(_)), "{raw}: {err:?}");
690        }
691    }
692
693    #[test]
694    fn invalid_reset_times_are_schema_drift() {
695        for raw in ["-1", "\"soon\"", "1e30", "true"] {
696            let body = format!(r#"{{"data":{{"per5HourResetTime":{raw}}}}}"#);
697            assert!(
698                parse_response(body.as_bytes()).is_err(),
699                "{raw} must not parse"
700            );
701        }
702    }
703
704    #[test]
705    fn null_optional_fields_count_as_absent() {
706        let fields =
707            parse_response(br#"{"data":{"per5HourPercentage":null,"per5HourResetTime":null}}"#)
708                .unwrap();
709        assert_eq!(fields.per5_hour_percentage, None);
710        assert_eq!(fields.per5_hour_reset_ms, None);
711    }
712
713    /// `NotLogined` in any spelling funnels into the Credentials re-auth
714    /// error naming the CLI's own command.
715    #[test]
716    fn not_logined_is_the_reauth_error() {
717        for code in ["NotLogined", "NotLogined_1001", "FLOW.NotLogined"] {
718            let body = format!(r#"{{"success":false,"errorCode":"{code}"}}"#);
719            let err = parse_response(body.as_bytes()).unwrap_err();
720            assert!(matches!(err, AppError::Credentials(_)), "{code}: {err:?}");
721            assert!(
722                err.to_string().contains("bl auth login --console"),
723                "{code}: {err}"
724            );
725        }
726    }
727
728    /// Any other failure code is upstream drift with its own name.
729    #[test]
730    fn other_gateway_failures_are_schema_errors() {
731        let err = parse_response(br#"{"success":false,"errorCode":"NoPermission"}"#).unwrap_err();
732        assert!(matches!(err, AppError::Schema(_)), "{err:?}");
733        assert!(err.to_string().contains("NoPermission"), "{err}");
734        // `success` absent or true is not a failure.
735        assert!(parse_response(br#"{"data":{}}"#).is_ok());
736        assert!(parse_response(br#"{"success":true,"data":{}}"#).is_ok());
737    }
738
739    #[test]
740    fn non_json_is_schema_drift() {
741        let err = parse_response(b"<html>login page</html>").unwrap_err();
742        assert!(matches!(err, AppError::Schema(_)), "{err:?}");
743    }
744}