1use 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#[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
51pub type FetchOutcome = crate::outcome::Outcome<ModelStudioSnapshot>;
54
55pub 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
68pub 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 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
121fn 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 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 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 #[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 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 #[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 #[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 #[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 #[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 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 #[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 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 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 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 #[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}