1use chrono::DateTime;
16use serde_json::Value;
17
18use crate::error::{AppError, Result};
19use crate::usage::{ModelStudioSnapshot, UsageWindow};
20
21#[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#[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#[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
93pub const USAGE_API: &str = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
96pub const USAGE_API_ENCODED: &str =
99 "zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fusage";
100pub const PRODUCT: &str = "sfm_bailian";
102
103pub fn usage_path(action: &str) -> String {
105 format!("/cli/api.json?action={action}&product={PRODUCT}&api={USAGE_API_ENCODED}")
106}
107
108pub 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
128fn 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
144pub fn form_body(region: ConsoleRegion) -> String {
146 format!(
147 "region={}¶ms={}",
148 encode_form_value(region.as_str()),
149 encode_form_value(¶ms_body())
150 )
151}
152
153#[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
164pub 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
183pub 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
210pub 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
218fn 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
236fn 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
259pub const FIVE_HOUR_WINDOW: chrono::Duration = chrono::Duration::hours(5);
261pub 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
299fn 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 #[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 #[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(¶ms_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¶ms=%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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 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}