diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift index 886061ff54..6d7fac3f5b 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -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 } } @@ -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 diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift index b70864458d..888eb856b9 100644 --- a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -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 = """