diff --git a/src/api/billing.rs b/src/api/billing.rs index a8d6c59..5c9d171 100644 --- a/src/api/billing.rs +++ b/src/api/billing.rs @@ -7,7 +7,31 @@ impl SunoClient { self.with_auth_retry(|| async { let resp = self.get("/api/billing/info/").send().await?; let resp = self.check_response(resp).await?; - Ok(resp.json().await?) + let raw = resp.text().await?; + let mut info: BillingInfo = serde_json::from_str(&raw).map_err(|e| CliError::Api { + code: "billing_schema_drift", + message: format!( + "billing/info returned unexpected JSON/body ({e}): {}", + raw.replace(['\n', '\r'], " ") + .chars() + .take(500) + .collect::() + ), + })?; + if info.total_credits_left == 0 { + info.total_credits_left = info.credits; + } + if info.plan.name.is_empty() { + info.plan.name = if info.is_active { + "Active".to_string() + } else { + "Unknown".to_string() + }; + } + if info.plan.plan_key.is_empty() { + info.plan.plan_key = info.plan.name.to_ascii_lowercase(); + } + Ok(info) }) .await } diff --git a/src/api/types.rs b/src/api/types.rs index 327151a..314bfee 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -2,24 +2,35 @@ use serde::{Deserialize, Serialize}; // --- Billing / Account --- -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Default, Deserialize, Serialize)] pub struct BillingInfo { + #[serde(default)] pub credits: u64, + #[serde(default)] pub total_credits_left: u64, + #[serde(default)] pub monthly_usage: u64, + #[serde(default)] pub monthly_limit: u64, + #[serde(default)] pub is_active: bool, + #[serde(default)] pub plan: Plan, + #[serde(default)] pub models: Vec, + #[serde(default)] pub period: String, + #[serde(default)] pub renews_on: Option, #[serde(default)] pub remaster_model_types: Vec, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Default, Deserialize, Serialize)] pub struct Plan { + #[serde(default)] pub name: String, + #[serde(default)] pub plan_key: String, #[serde(default)] pub usage_plan_features: Vec, @@ -30,12 +41,17 @@ pub struct Feature { pub name: String, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Default, Deserialize, Serialize)] pub struct Model { + #[serde(default)] pub name: String, + #[serde(default)] pub external_key: String, + #[serde(default)] pub can_use: bool, + #[serde(default)] pub is_default_model: bool, + #[serde(default)] pub description: String, #[serde(default)] pub max_lengths: MaxLengths, diff --git a/src/auth.rs b/src/auth.rs index 6598c49..88a3f65 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -223,6 +223,16 @@ fn response_excerpt(body: &str) -> String { } } +fn parse_json_value(body: &str, context: &'static str) -> Result { + serde_json::from_str(body).map_err(|e| CliError::Api { + code: "clerk_json_error", + message: format!( + "{context} returned unexpected JSON/body ({e}): {}", + response_excerpt(body) + ), + }) +} + /// Generate the dynamic browser-token header value. pub fn browser_token() -> String { let ms = SystemTime::now() @@ -322,7 +332,8 @@ pub async fn clerk_token_exchange( }); } - let body: serde_json::Value = resp.json().await.map_err(CliError::Http)?; + let raw = resp.text().await.map_err(CliError::Http)?; + let body = parse_json_value(&raw, "Clerk session lookup")?; let session_id = body .get("response") .and_then(|r| { @@ -372,7 +383,8 @@ pub async fn clerk_refresh_jwt( }); } - let body: serde_json::Value = resp.json().await.map_err(CliError::Http)?; + let raw = resp.text().await.map_err(CliError::Http)?; + let body = parse_json_value(&raw, "Clerk JWT refresh")?; body.get("jwt") .and_then(|j| j.as_str()) .map(String::from) diff --git a/src/main.rs b/src/main.rs index 947e981..05a8e6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -197,6 +197,9 @@ async fn run() -> Result<(), CliError> { } else if let Some(jwt) = args.jwt.clone() { // Legacy: direct JWT paste (expires in ~1 hour) state.jwt = Some(jwt); + state.session_id = None; + state.cookie = None; + state.clerk_client_cookie = None; if state.device_id.is_none() { state.device_id = Some(uuid::Uuid::new_v4().to_string()); }