diff --git a/rust/src/providers/stepfun/mod.rs b/rust/src/providers/stepfun/mod.rs index 4f9a875cb8..b35f184016 100644 --- a/rust/src/providers/stepfun/mod.rs +++ b/rust/src/providers/stepfun/mod.rs @@ -24,8 +24,10 @@ const STEPFUN_REFRESH_TOKEN_URL: &str = const STEPFUN_CREDENTIAL_TARGET: &str = "codexbar-stepfun"; const STEPFUN_WEB_ID: &str = "734152690100432"; const STEPFUN_APP_ID: &str = "111003695"; +const CREDIT_LABEL: &str = "Credit"; +const NO_CREDIT_BALANCE: &str = "No credit balance reported"; -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] struct StepFunRateLimitResponse { status: Option, code: Option, @@ -35,6 +37,111 @@ struct StepFunRateLimitResponse { weekly_usage_left_rate: Option, five_hour_usage_reset_time: Option, weekly_usage_reset_time: Option, + plan_family: Option, + plan_credit_rate_limit: Option, +} + +impl StepFunRateLimitResponse { + /// StepFun serves two Step Plan billing models. The Coding Plan meters + /// rolling 5-hour / weekly windows; the Token Plan meters a monthly Credit + /// pool through `plan_credit_rate_limit` and reports its rolling windows as + /// 0 with a `"0"` reset time ("no window configured", not "used up"). + /// + /// Classify by the payload shape: a live rolling window means Coding Plan, + /// no window plus a credit pool means Token Plan. `plan_family == 2` only + /// breaks the tie for an ambiguous payload, so a future family-id change + /// cannot flip a windowed plan onto the credit renderer or vice versa. + fn is_credit_plan(&self) -> bool { + let has_live_window = [ + &self.five_hour_usage_reset_time, + &self.weekly_usage_reset_time, + ] + .into_iter() + .any(|reset| reset.as_ref().is_some_and(|ts| ts.0 > 0)); + if has_live_window { + return false; + } + if self + .plan_credit_rate_limit + .as_ref() + .is_some_and(StepFunPlanCreditRateLimit::has_credit_pool) + { + return true; + } + self.plan_family + .as_ref() + .is_some_and(|family| family.0 == 2.0) + } +} + +/// The `plan_credit_rate_limit` object returned for credit-based plans. +#[derive(Debug, Default, Deserialize)] +struct StepFunPlanCreditRateLimit { + subscription_credit_left_rate: Option, + subscription_credit_reset_time: Option, + topup_credit_left_rate: Option, + credit_buckets: Option>, +} + +#[derive(Debug, Default, Deserialize)] +struct StepFunCreditBucket { + credit_total: Option, + credit_residual: Option, +} + +impl StepFunPlanCreditRateLimit { + fn has_credit_pool(&self) -> bool { + self.subscription_credit_left_rate.is_some() + || self.topup_credit_left_rate.is_some() + || self + .credit_buckets + .as_ref() + .is_some_and(|buckets| !buckets.is_empty()) + } + + /// Remaining fraction of the credit pool, or `None` when no balance is reported. + /// + /// Subscription and top-up rates are independent fractions, so adding them + /// does not give a combined rate; absolute bucket balances are preferred. + /// Without usable bucket sizes the subscription rate is the plan allowance + /// and the top-up rate is used only when no subscription rate is present. + fn left_rate(&self) -> Option { + let buckets = self.credit_buckets.as_deref().unwrap_or_default(); + let balances: Vec<(f64, f64)> = buckets + .iter() + .filter_map(StepFunCreditBucket::balance) + .collect(); + if !buckets.is_empty() && balances.len() == buckets.len() { + let total: f64 = balances.iter().map(|(total, _)| total).sum(); + let residual: f64 = balances.iter().map(|(_, residual)| residual).sum(); + return Some(residual / total); + } + self.subscription_credit_left_rate + .as_ref() + .or(self.topup_credit_left_rate.as_ref()) + .map(|rate| rate.0) + } + + /// A real monthly reset; a missing or zero timestamp stays unknown. + fn reset_at(&self) -> Option> { + self.subscription_credit_reset_time + .as_ref() + .filter(|ts| ts.0 > 0) + .and_then(|ts| Utc.timestamp_opt(ts.0, 0).single()) + } +} + +impl StepFunCreditBucket { + /// `(total, residual)` when the bucket carries a sound balance. + fn balance(&self) -> Option<(f64, f64)> { + let total = self.credit_total.as_ref()?.0; + let residual = self.credit_residual.as_ref()?.0; + (total.is_finite() + && residual.is_finite() + && total > 0.0 + && (0.0..=total).contains(&residual)) + .then_some((total, residual)) + } } #[derive(Debug, Deserialize)] @@ -225,6 +332,14 @@ fn snapshot_from_response( return Err(ProviderError::Other(format!("StepFun API error: {msg}"))); } + let login_method = plan_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "Oasis-Token".into()); + + if response.is_credit_plan() { + return Ok(credit_snapshot(response).with_login_method(login_method)); + } + let five_left = response .five_hour_usage_left_rate .as_ref() @@ -257,13 +372,28 @@ fn snapshot_from_response( weekly_reset.map(reset_description), ); - let mut snapshot = UsageSnapshot::new(primary).with_secondary(secondary); - if let Some(plan_name) = plan_name.filter(|value| !value.trim().is_empty()) { - snapshot = snapshot.with_login_method(plan_name); - } else { - snapshot = snapshot.with_login_method("Oasis-Token"); - } - Ok(snapshot) + Ok(UsageSnapshot::new(primary) + .with_secondary(secondary) + .with_login_method(login_method)) +} + +/// Credit plans populate only the primary lane, including balances without a +/// reset timestamp, and never invent a reset date. +fn credit_snapshot(response: &StepFunRateLimitResponse) -> UsageSnapshot { + let credit = response.plan_credit_rate_limit.as_ref(); + let primary = match credit.and_then(StepFunPlanCreditRateLimit::left_rate) { + Some(left_rate) => { + let reset = credit.and_then(StepFunPlanCreditRateLimit::reset_at); + RateWindow::with_details( + (1.0 - left_rate).clamp(0.0, 1.0) * 100.0, + RateWindow::monthly_window_minutes(reset), + reset, + reset.map(reset_description), + ) + } + None => RateWindow::informational(NO_CREDIT_BALANCE), + }; + UsageSnapshot::new(primary).with_primary_label(CREDIT_LABEL) } struct StepFunTokenParts { @@ -455,12 +585,132 @@ mod tests { weekly_usage_left_rate: Some(FlexibleNumber(0.75)), five_hour_usage_reset_time: Some(FlexibleTimestamp(1_800_000_000)), weekly_usage_reset_time: Some(FlexibleTimestamp(1_800_000_000)), + ..Default::default() }; let snapshot = snapshot_from_response(&response, Some("Step Plan".into())).unwrap(); assert_eq!(snapshot.primary.used_percent, 75.0); + assert_eq!(snapshot.primary_label, None); assert_eq!(snapshot.secondary.unwrap().used_percent, 25.0); } + fn snapshot_from_json(json: &str) -> Result { + let response: StepFunRateLimitResponse = serde_json::from_str(json).unwrap(); + snapshot_from_response(&response, None) + } + + #[test] + fn stepfun_coding_plan_payload_keeps_window_labels_and_lanes() { + let snapshot = snapshot_from_json( + r#"{"status":1,"five_hour_usage_left_rate":0.99781543,"weekly_usage_left_rate":1, + "five_hour_usage_reset_time":"1777528800","weekly_usage_reset_time":"1777852800", + "plan_family":1}"#, + ) + .unwrap(); + assert_eq!(snapshot.primary_label, None); + assert_eq!(snapshot.primary.window_minutes, Some(300)); + assert_eq!(snapshot.secondary.unwrap().window_minutes, Some(10080)); + } + + #[test] + fn stepfun_coding_plan_payload_still_requires_window_fields() { + let error = snapshot_from_json( + r#"{"status":1,"five_hour_usage_reset_time":"1777528800","plan_family":1}"#, + ) + .unwrap_err(); + assert!(error.to_string().contains("five-hour")); + } + + #[test] + fn stepfun_credit_plan_with_reset_uses_monthly_window() { + let snapshot = snapshot_from_json( + r#"{"status":1,"five_hour_usage_left_rate":0,"weekly_usage_left_rate":0, + "five_hour_usage_reset_time":"0","weekly_usage_reset_time":"0","plan_family":2, + "plan_credit_rate_limit":{"subscription_credit_left_rate":0.75, + "subscription_credit_reset_time":"1777528800","topup_credit_left_rate":0}}"#, + ) + .unwrap(); + assert_eq!(snapshot.primary_label.as_deref(), Some("Credit")); + assert!(snapshot.secondary.is_none()); + assert_eq!(snapshot.primary.used_percent, 25.0); + assert_eq!( + snapshot.primary.resets_at.map(|t| t.timestamp()), + Some(1_777_528_800) + ); + assert!(snapshot.primary.reset_description.is_some()); + let minutes = snapshot.primary.window_minutes.unwrap(); + assert!((28 * 1440..=31 * 1440).contains(&minutes)); + } + + #[test] + fn stepfun_credit_plan_without_or_with_zero_reset_invents_no_reset() { + for reset in ["", r#","subscription_credit_reset_time":"0""#] { + let json = format!( + r#"{{"status":1,"plan_credit_rate_limit":{{"subscription_credit_left_rate":0.4{reset}}}}}"# + ); + let snapshot = snapshot_from_json(&json).unwrap(); + assert_eq!(snapshot.primary_label.as_deref(), Some("Credit")); + assert_eq!(snapshot.primary.used_percent, 60.0); + assert_eq!(snapshot.primary.resets_at, None); + assert_eq!(snapshot.primary.reset_description, None); + assert_eq!(snapshot.primary.window_minutes, None); + assert!(snapshot.secondary.is_none()); + } + } + + #[test] + fn stepfun_credit_plan_weights_buckets_by_balance() { + let snapshot = snapshot_from_json( + r#"{"status":1,"plan_credit_rate_limit":{"subscription_credit_left_rate":0.9, + "topup_credit_left_rate":0.9,"credit_buckets":[ + {"credit_total":"400000000","credit_residual":"100000000"}, + {"credit_total":100000000,"credit_residual":100000000}]}}"#, + ) + .unwrap(); + // residual 200M of total 500M => 60% used, not derived from the rates. + assert!((snapshot.primary.used_percent - 60.0).abs() < 1e-9); + } + + #[test] + fn stepfun_credit_plan_falls_back_to_rates_when_buckets_are_unsound() { + let snapshot = snapshot_from_json( + r#"{"status":1,"plan_credit_rate_limit":{"topup_credit_left_rate":0.5, + "credit_buckets":[{"credit_total":10,"credit_residual":20}]}}"#, + ) + .unwrap(); + assert_eq!(snapshot.primary.used_percent, 50.0); + let snapshot = snapshot_from_json( + r#"{"status":1,"plan_credit_rate_limit":{"subscription_credit_left_rate":0.8, + "topup_credit_left_rate":0.1}}"#, + ) + .unwrap(); + assert!((snapshot.primary.used_percent - 20.0).abs() < 1e-9); + } + + #[test] + fn stepfun_credit_family_without_balance_has_no_quota_window() { + let snapshot = snapshot_from_json( + r#"{"status":1,"plan_family":2,"five_hour_usage_reset_time":"0", + "weekly_usage_reset_time":"0"}"#, + ) + .unwrap(); + assert!(snapshot.primary.is_informational); + assert_eq!(snapshot.primary_label.as_deref(), Some("Credit")); + assert!(snapshot.secondary.is_none()); + } + + #[test] + fn stepfun_live_window_wins_over_credit_family() { + let snapshot = snapshot_from_json( + r#"{"status":1,"plan_family":2,"five_hour_usage_left_rate":0.5, + "weekly_usage_left_rate":0.5,"five_hour_usage_reset_time":"1777528800", + "weekly_usage_reset_time":"1777852800", + "plan_credit_rate_limit":{"subscription_credit_left_rate":0.1}}"#, + ) + .unwrap(); + assert_eq!(snapshot.primary_label, None); + assert!(snapshot.secondary.is_some()); + } + #[test] fn stepfun_token_parts_extract_cookie_and_refresh_token() { let parts = token_parts("Cookie: Oasis-Token=access...refresh; Oasis-Webid=abc");