Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 258 additions & 8 deletions rust/src/providers/stepfun/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
code: Option<i64>,
Expand All @@ -35,6 +37,111 @@ struct StepFunRateLimitResponse {
weekly_usage_left_rate: Option<FlexibleNumber>,
five_hour_usage_reset_time: Option<FlexibleTimestamp>,
weekly_usage_reset_time: Option<FlexibleTimestamp>,
plan_family: Option<FlexibleNumber>,
plan_credit_rate_limit: Option<StepFunPlanCreditRateLimit>,
}

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<FlexibleNumber>,
subscription_credit_reset_time: Option<FlexibleTimestamp>,
topup_credit_left_rate: Option<FlexibleNumber>,
credit_buckets: Option<Vec<StepFunCreditBucket>>,
}

#[derive(Debug, Default, Deserialize)]
struct StepFunCreditBucket {
credit_total: Option<FlexibleNumber>,
credit_residual: Option<FlexibleNumber>,
}

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<f64> {
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<DateTime<Utc>> {
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)]
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<UsageSnapshot, ProviderError> {
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");
Expand Down