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