Skip to content
Merged
Show file tree
Hide file tree
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
51 changes: 27 additions & 24 deletions Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,28 +81,31 @@ public struct StepFunRateLimitResponse: Decodable, Sendable {
self.status == 1
}

/// Credit-based plans (plan_family=2) report usage via `plan_credit_rate_limit`
/// instead of the five-hour / weekly rate windows. Those rate fields are 0 with
/// reset_time "0" — meaning "no window configured", NOT "fully consumed".
/// StepFun runs two Step Plan billing models side by side after the 2026-06-18
/// upgrade (docs/zh/step-plan/upgrade-notice): the grandfathered **Coding Plan**
/// meters rolling **5-hour / weekly** windows, while the current **Token Plan**
/// meters a monthly **Credit** pool via `plan_credit_rate_limit` (its rate windows
/// come back as 0 with `reset_time` `"0"` — "no window configured", not "used up").
///
/// Classify by the shape the payload actually carries rather than trusting
/// `plan_family` alone: a live rolling window means Coding Plan; no window plus a
/// Credit pool means Token Plan. `plan_family` (2 == the Credit family) is only a
/// tie-breaker for an ambiguous payload (e.g. a brand-new plan with neither a live
/// window nor credit yet), so a future family-id change can't silently flip a
/// windowed plan onto the credit renderer or vice versa.
var isCreditPlan: Bool {
// plan_family 2 = credit-based subscription plans (e.g. Mini, Pro).
if let family = self.planFamily?.value, family > 0 {
return family == 2
}
// Fallback heuristic: if both rate windows are 0 with no reset time, but
// credit data is present, treat as a credit plan.
if let credit = self.planCreditRateLimit,
(credit.subscriptionCreditLeftRate?.value ?? 0) > 0
{
let fiveHourZero = (self.fiveHourUsageLeftRate?.value ?? 1) == 0
let weeklyZero = (self.weeklyUsageLeftRate?.value ?? 1) == 0
let fiveHourNoReset = (self.fiveHourUsageResetTime?.value ?? 0) == 0
let weeklyNoReset = (self.weeklyUsageResetTime?.value ?? 0) == 0
if (fiveHourZero && fiveHourNoReset) || (weeklyZero && weeklyNoReset) {
return true
}
}
return false
let hasLiveWindow = (self.fiveHourUsageResetTime?.value ?? 0) > 0
|| (self.weeklyUsageResetTime?.value ?? 0) > 0
if hasLiveWindow {
return false
}
let hasCreditPool = self.planCreditRateLimit?.subscriptionCreditLeftRate != nil
|| self.planCreditRateLimit?.topupCreditLeftRate != nil
|| !(self.planCreditRateLimit?.creditBuckets?.isEmpty ?? true)
if hasCreditPool {
return true
}
return (self.planFamily?.value).map { $0 == 2 } ?? false
}
}

Expand Down Expand Up @@ -253,9 +256,9 @@ public struct StepFunUsageSnapshot: Sendable {
accountOrganization: nil,
loginMethod: loginMethod)

// Credit-based plans (plan_family=2) don't have 5h/weekly rate windows.
// Show the credit balance as the primary window and drop the meaningless
// 0%-left rate windows entirely.
// Token Plan (Credit pool) carries no live 5h/weekly windows. Show the credit
// balance as the primary window and drop the meaningless 0%-left rate windows
// entirely. Coding Plan keeps its rolling windows and falls through below.
if self.isCreditPlan, let creditRate = self.creditLeftRate {
let creditUsedPercent = max(0, min(100, (1.0 - creditRate) * 100))
let resetDate = self.creditResetTime ?? Date.distantFuture
Expand Down
90 changes: 90 additions & 0 deletions Tests/CodexBarTests/StepFunUsageFetcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,96 @@ struct StepFunUsageFetcherParsingTests {
#expect(usage.primary?.usedPercent ?? -1 > 24.9 && usage.primary?.usedPercent ?? -1 < 25.1)
}

@Test
func `live rolling windows win over a credit-family id`() throws {
// Robustness across the 2026-06-18 Coding Plan → Token Plan split: the
// grandfathered Coding Plan meters live 5h/weekly windows. A live window must
// route to the rolling-window renderer even if the same payload also reports
// plan_family=2, so a stale/changed family id can never send a windowed plan
// to the credit renderer (which would drop the real windows and show a bogus
// credit balance).
let json = """
{
"status": 1,
"five_hour_usage_left_rate": 0.8,
"five_hour_usage_reset_time": "1746000000",
"weekly_usage_left_rate": 0.6,
"weekly_usage_reset_time": "1746500000",
"plan_family": 2,
"plan_credit_rate_limit": { "subscription_credit_left_rate": 1, "credit_buckets": [] }
}
"""
let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8))

#expect(snapshot.isCreditPlan == false)
let usage = snapshot.toUsageSnapshot()
#expect(usage.primary?.windowMinutes == 300)
#expect(usage.secondary?.windowMinutes == 10080)
}

@Test
func `falls back to the credit-family id only when the payload is otherwise ambiguous`() throws {
// A brand-new plan with neither a live window nor a credit pool yet leaves
// plan_family as the only signal: 2 == Token Plan (Credit family), else Coding.
let creditFamily = """
{"status":1,"five_hour_usage_left_rate":0,"five_hour_usage_reset_time":"0",\
"weekly_usage_left_rate":0,"weekly_usage_reset_time":"0","plan_family":2}
"""
let windowFamily = """
{"status":1,"five_hour_usage_left_rate":0,"five_hour_usage_reset_time":"0",\
"weekly_usage_left_rate":0,"weekly_usage_reset_time":"0","plan_family":1}
"""
#expect(try StepFunUsageFetcher._parseSnapshotForTesting(Data(creditFamily.utf8)).isCreditPlan == true)
#expect(try StepFunUsageFetcher._parseSnapshotForTesting(Data(windowFamily.utf8)).isCreditPlan == false)
}

@Test
func `classifies exhausted zero-credit pool without a family id`() throws {
let json = """
{
"status": 1,
"five_hour_usage_left_rate": 0,
"five_hour_usage_reset_time": "0",
"weekly_usage_left_rate": 0,
"weekly_usage_reset_time": "0",
"plan_credit_rate_limit": {
"subscription_credit_left_rate": 0,
"subscription_credit_reset_time": "1786288293"
}
}
"""
let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8))

#expect(snapshot.isCreditPlan == true)
#expect(snapshot.creditLeftRate == 0)
let usage = snapshot.toUsageSnapshot()
#expect(usage.primary?.usedPercent == 100)
#expect(usage.secondary == nil)
}

@Test
func `classifies zero-credit pool when only top-up field is present`() throws {
let json = """
{
"status": 1,
"five_hour_usage_left_rate": 0,
"five_hour_usage_reset_time": "0",
"weekly_usage_left_rate": 0,
"weekly_usage_reset_time": "0",
"plan_credit_rate_limit": {
"topup_credit_left_rate": 0
}
}
"""
let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8))

#expect(snapshot.isCreditPlan == true)
#expect(snapshot.creditLeftRate == 0)
let usage = snapshot.toUsageSnapshot()
#expect(usage.primary?.usedPercent == 100)
#expect(usage.secondary == nil)
}

@Test
func `weights mixed subscription and top-up credit buckets`() throws {
let json = """
Expand Down