use crate::auth::{AuthCredential, AuthResolver};
use crate::providers::{ProviderKind, ProviderQuota, ProviderResult, ProviderStatus, QuotaWindow};
use crate::Result;
use async_trait::async_trait;
use chrono::Utc;
use reqwest::Client;
use serde::Deserialize;
const PAYG_BASE: &str = "https://api.xiaomimimo.com/v1";
const TOKEN_PLAN_BASE: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
const DASHBOARD_URL: &str = "https://platform.xiaomimimo.com";
pub struct MimoProvider {
http: Client,
auth: Box<dyn AuthResolver>,
}
impl MimoProvider {
pub fn new(auth: Box<dyn AuthResolver>) -> Self {
Self {
http: Client::new(),
auth,
}
}
async fn try_json(&self, key: &str, url: &str) -> Result<Option<(u16, serde_json::Value)>> {
let resp = self
.http
.get(url)
.header("Authorization", format!("Bearer {}", key))
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(body) => Ok(Some((status, body))),
Err(_) => Ok(None), }
}
async fn detect_base_url(&self, key: &str) -> Result<Option<&'static str>> {
for base in &[TOKEN_PLAN_BASE, PAYG_BASE] {
if let Some((status, _)) = self.try_json(key, &format!("{}/models", base)).await? {
if status == 200 {
return Ok(Some(base));
}
}
}
Ok(None)
}
async fn fetch_balance(&self, key: &str) -> Result<ProviderResult> {
for base in &[TOKEN_PLAN_BASE, PAYG_BASE] {
if let Some((status, body)) = self
.try_json(key, &format!("{}/user/balance", base))
.await?
{
if status == 401 || status == 403 {
let reason = body
.pointer("/error/message")
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("Authentication failed")
.to_string();
return Ok(ProviderResult {
kind: ProviderKind::Mimo,
status: ProviderStatus::Unavailable {
info: crate::providers::UnavailableInfo {
reason,
console_url: Some(DASHBOARD_URL.into()),
},
},
fetched_at: Utc::now(),
raw_response: Some(body),
auth_source: None,
});
}
if status < 400 {
let quota = parse_balance(&body, base)?;
return Ok(ProviderResult {
kind: ProviderKind::Mimo,
status: ProviderStatus::Available { quota },
fetched_at: Utc::now(),
raw_response: Some(body),
auth_source: None,
});
}
}
}
match self.detect_base_url(key).await? {
Some(_base) => {
Ok(ProviderResult {
kind: ProviderKind::Mimo,
status: ProviderStatus::Unavailable {
info: crate::providers::UnavailableInfo {
reason: "No quota API — check usage at platform.xiaomimimo.com"
.to_string(),
console_url: Some(DASHBOARD_URL.into()),
},
},
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
})
}
None => {
Ok(ProviderResult {
kind: ProviderKind::Mimo,
status: ProviderStatus::Unavailable {
info: crate::providers::UnavailableInfo {
reason: "Invalid API key".to_string(),
console_url: Some(DASHBOARD_URL.into()),
},
},
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
})
}
}
}
}
#[derive(Deserialize, Default)]
struct BalanceResp {
#[serde(default)]
balance: Option<serde_json::Value>,
#[serde(default)]
granted_balance: Option<serde_json::Value>,
#[serde(default)]
charge_balance: Option<serde_json::Value>,
#[serde(default)]
token_balance: Option<serde_json::Value>,
#[serde(default)]
token_limit: Option<serde_json::Value>,
#[serde(default)]
plan: Option<String>,
#[serde(default)]
plan_name: Option<String>,
}
fn parse_f64(v: &serde_json::Value) -> f64 {
match v {
serde_json::Value::Number(n) => n.as_f64().unwrap_or(0.0),
serde_json::Value::String(s) => s.parse().unwrap_or(0.0),
_ => 0.0,
}
}
pub(crate) fn parse_balance(body: &serde_json::Value, base: &str) -> Result<ProviderQuota> {
let data = body.get("data").unwrap_or(body);
let resp: BalanceResp = serde_json::from_value(data.clone()).unwrap_or_default();
let mut windows: Vec<QuotaWindow> = Vec::new();
if let (Some(tok_bal), Some(tok_lim)) = (&resp.token_balance, &resp.token_limit) {
let remaining = parse_f64(tok_bal) as i64;
let limit = parse_f64(tok_lim) as i64;
if limit > 0 {
windows.push(QuotaWindow {
window_type: "tokens_monthly".into(),
used: (limit - remaining).max(0),
limit,
remaining,
reset_at: None,
period_seconds: Some(30 * 24 * 3600),
});
}
}
if let Some(bal) = &resp.balance {
let total = (parse_f64(bal) * 10_000.0).round() as i64;
if total > 0 {
windows.push(QuotaWindow {
window_type: "balance_cny".into(),
used: 0,
limit: total,
remaining: total,
reset_at: None,
period_seconds: None,
});
}
}
if let Some(charge) = &resp.charge_balance {
let v = (parse_f64(charge) * 10_000.0).round() as i64;
if v > 0 {
windows.push(QuotaWindow {
window_type: "paid_cny".into(),
used: 0,
limit: v,
remaining: v,
reset_at: None,
period_seconds: None,
});
}
}
if let Some(granted) = &resp.granted_balance {
let v = (parse_f64(granted) * 10_000.0).round() as i64;
if v > 0 {
windows.push(QuotaWindow {
window_type: "free_cny".into(),
used: 0,
limit: v,
remaining: v,
reset_at: None,
period_seconds: None,
});
}
}
let plan_label = resp
.plan_name
.or(resp.plan)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
if base == TOKEN_PLAN_BASE {
"Token Plan".to_string()
} else {
"PAYG".to_string()
}
});
Ok(ProviderQuota {
plan_name: format!("MiMo · {}", plan_label),
windows,
unlimited: false,
})
}
#[async_trait]
impl crate::providers::Provider for MimoProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Mimo
}
async fn fetch(&self) -> Result<ProviderResult> {
let auth = self.auth.resolve().await?;
let key = match &auth.credential {
AuthCredential::Bearer(k) => k.clone(),
AuthCredential::Token(t) => t.clone(),
};
match self.fetch_balance(&key).await {
Ok(r) => Ok(r),
Err(e) => Ok(ProviderResult {
kind: ProviderKind::Mimo,
status: ProviderStatus::NetworkError {
message: e.to_string(),
},
fetched_at: Utc::now(),
raw_response: None,
auth_source: None,
}),
}
}
fn auth_resolver(&self) -> &dyn AuthResolver {
&*self.auth
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_payg_cny_balance() {
let body = serde_json::json!({
"data": {
"balance": "12.5000",
"charge_balance": "10.0000",
"granted_balance": "2.5000",
"plan": "PAYG"
}
});
let quota = parse_balance(&body, PAYG_BASE).unwrap();
assert!(quota.plan_name.contains("MiMo"));
assert_eq!(quota.windows[0].window_type, "balance_cny");
assert_eq!(quota.windows[0].remaining, 125_000);
assert_eq!(quota.windows[1].window_type, "paid_cny");
assert_eq!(quota.windows[1].remaining, 100_000);
assert_eq!(quota.windows[2].window_type, "free_cny");
assert_eq!(quota.windows[2].remaining, 25_000);
}
#[test]
fn parses_token_plan_balance() {
let body = serde_json::json!({
"data": {
"token_balance": 800_000,
"token_limit": 1_000_000,
"plan_name": "Pro"
}
});
let quota = parse_balance(&body, TOKEN_PLAN_BASE).unwrap();
assert!(quota.plan_name.contains("Pro"));
assert_eq!(quota.windows[0].window_type, "tokens_monthly");
assert_eq!(quota.windows[0].limit, 1_000_000);
assert_eq!(quota.windows[0].remaining, 800_000);
assert_eq!(quota.windows[0].used, 200_000);
}
#[test]
fn empty_balance_response_returns_no_windows() {
let body = serde_json::json!({ "data": {} });
let quota = parse_balance(&body, PAYG_BASE).unwrap();
assert!(quota.windows.is_empty());
}
}