diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ff90fbe3..be9654b411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changed - About: link the Website entry to codex.bar. +### Fixed +- MiMo/StepFun: feed monthly token-plan windows into usage history, pace, and forecasts (#2526, part of #2431). Thanks @LeoLin990405! + ## 0.46.0 — 2026-07-29 ### Added diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 75d26ce540..3cee8240bd 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -565,6 +565,18 @@ extension UsageStore { resetsAt: window.resetsAt)) } + func appendGenericSessionEquivalentWindows() { + let components = Self.genericSessionEquivalentWindowComponents(snapshot: snapshot) + switch Self.genericSessionEquivalentWindowPairResolution(snapshot: snapshot) { + case let .resolved(session, weekly, _, _): + appendWindow(session, name: .session) + appendWindow(weekly, name: .weekly) + case .incomplete, .ambiguous: + appendWindow(components.session?.window, name: .session) + appendWindow(components.weekly?.window, name: .weekly) + } + } + switch provider { case .codex: let projection = self.codexConsumerProjection( @@ -582,6 +594,12 @@ extension UsageStore { appendWindow(snapshot.primary, name: .session) appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .monthly) + case .mimo, .stepfun: + if snapshot.primary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes { + appendWindow(snapshot.primary, name: .monthly) + } else { + appendGenericSessionEquivalentWindows() + } case .antigravity: if forSessionEquivalents { guard let windows = self.sessionEquivalentWindows(provider: provider, snapshot: snapshot) else { @@ -606,15 +624,7 @@ extension UsageStore { } } default: - let components = Self.genericSessionEquivalentWindowComponents(snapshot: snapshot) - switch Self.genericSessionEquivalentWindowPairResolution(snapshot: snapshot) { - case let .resolved(session, weekly, _, _): - appendWindow(session, name: .session) - appendWindow(weekly, name: .weekly) - case .incomplete, .ambiguous: - appendWindow(components.session?.window, name: .session) - appendWindow(components.weekly?.window, name: .weekly) - } + appendGenericSessionEquivalentWindows() } return samplesByKey.values.sorted { lhs, rhs in diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift index 416c81de12..febeb04896 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum MiMoProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Xiaomi MiMo cost summary is not supported." }), + pace: .calendarMonthResetWindow, fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { context in diff --git a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift index fbec8688d8..a0db58c941 100644 --- a/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiMo/MiMoUsageSnapshot.swift @@ -59,9 +59,15 @@ extension MiMoUsageSnapshot { let usedText = Self.fullCountString(self.tokenUsed) let limitText = Self.fullCountString(self.tokenLimit) let resetDesc = "\(usedText) / \(limitText) Credits" + // Populate windowMinutes so the plan-utilization history + pace forecast engine + // (which requires a windowed RateWindow) treats the monthly plan quota like the + // Codex/Claude windows. Only when there's a real period end to reset against. + let windowMinutes = self.planPeriodEnd == nil + ? nil + : ProviderPaceCapability.monthlyWindowSentinelMinutes return RateWindow( usedPercent: usedPercent, - windowMinutes: nil, + windowMinutes: windowMinutes, resetsAt: self.planPeriodEnd, resetDescription: resetDesc) }() diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift index 69c389c3a7..bf0fe602c7 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -36,6 +36,7 @@ public enum StepFunProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "StepFun per-day cost history is not available via API." }), + pace: .calendarMonthResetWindow, fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [StepFunWebFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift index 6d7fac3f5b..acc70c42d3 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -263,9 +263,15 @@ public struct StepFunUsageSnapshot: Sendable { let creditUsedPercent = max(0, min(100, (1.0 - creditRate) * 100)) let resetDate = self.creditResetTime ?? Date.distantFuture let resetDescription = UsageFormatter.resetDescription(from: resetDate) + // Populate windowMinutes so the monthly credit pool feeds the plan-utilization + // history + pace forecast, matching the Codex/Claude windows. Only when the credit + // pool has a real monthly reset (otherwise the pace projection is meaningless). + let creditWindowMinutes = self.creditResetTime == nil + ? nil + : ProviderPaceCapability.monthlyWindowSentinelMinutes let creditWindow = RateWindow( usedPercent: creditUsedPercent, - windowMinutes: nil, + windowMinutes: creditWindowMinutes, resetsAt: resetDate, resetDescription: resetDescription) @@ -728,7 +734,7 @@ public struct StepFunUsageFetcher: Sendable { let creditLeftRate = decoded.planCreditRateLimit?.totalCreditLeftRate let creditResetTime = decoded.planCreditRateLimit?.subscriptionCreditResetTime - .map { Date(timeIntervalSince1970: TimeInterval($0.value)) } + .flatMap { $0.value > 0 ? Date(timeIntervalSince1970: TimeInterval($0.value)) : nil } return StepFunUsageSnapshot( fiveHourUsageLeftRate: fiveHourRate, diff --git a/Tests/CodexBarTests/MiMoWindowMinutesTests.swift b/Tests/CodexBarTests/MiMoWindowMinutesTests.swift new file mode 100644 index 0000000000..dc5623055c --- /dev/null +++ b/Tests/CodexBarTests/MiMoWindowMinutesTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiMoWindowMinutesTests { + @Test + func `monthly token quota carries a monthly window for pace history`() { + // With a real period end, the monthly quota window carries a monthly windowMinutes so it + // feeds the plan-utilization history + pace forecast (parity with Codex/Claude). Without a + // period end there is nothing to reset against, so no window is claimed. + let withPeriod = MiMoUsageSnapshot( + balance: 0, + currency: "USD", + planPeriodEnd: Date(timeIntervalSince1970: 1_742_771_200), + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date(timeIntervalSince1970: 1_742_000_000)) + #expect(withPeriod.toUsageSnapshot().primary?.windowMinutes == 30 * 24 * 60) + + let noPeriod = MiMoUsageSnapshot( + balance: 0, + currency: "USD", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date(timeIntervalSince1970: 1_742_000_000)) + #expect(noPeriod.toUsageSnapshot().primary?.windowMinutes == nil) + } +} diff --git a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift index a743e20c9e..bd8953ea82 100644 --- a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift +++ b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift @@ -96,7 +96,7 @@ struct ProviderPaceCapabilityTests { && timeUntilReset <= TimeInterval(windowMinutes) * 60 case .kimi: return window.windowMinutes == self.weeklyWindowMinutes - case .alibaba, .alibabatokenplan, .amp, .doubao, .opencodego: + case .alibaba, .alibabatokenplan, .amp, .doubao, .mimo, .opencodego, .stepfun: return window.windowMinutes == self.monthlyWindowSentinelMinutes default: return false @@ -110,7 +110,7 @@ struct ProviderPaceCapabilityTests { switch provider { case .copilot: window.windowMinutes == nil - case .alibaba, .alibabatokenplan, .amp, .doubao, .opencodego: + case .alibaba, .alibabatokenplan, .amp, .doubao, .mimo, .opencodego, .stepfun: window.windowMinutes == self.monthlyWindowSentinelMinutes default: false diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift index 888eb856b9..57be4732d9 100644 --- a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -248,10 +248,33 @@ struct StepFunUsageFetcherParsingTests { let primaryUsed = usage.primary?.usedPercent ?? -1 #expect(primaryUsed > 3.5 && primaryUsed < 3.7) + // The monthly credit pool (with a real reset time) carries a monthly windowMinutes so + // it feeds the plan-utilization history + pace forecast (parity with Codex/Claude). + #expect(usage.primary?.windowMinutes == 30 * 24 * 60) + // No secondary window for credit plans. #expect(usage.secondary == nil) } + @Test + func `zero credit reset does not activate monthly pace`() throws { + let json = """ + { + "status": 1, + "plan_family": 2, + "plan_credit_rate_limit": { + "subscription_credit_left_rate": 0.5, + "subscription_credit_reset_time": "0" + } + } + """ + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.creditResetTime == nil) + #expect(usage.primary?.windowMinutes == nil) + } + @Test func `does not treat rate-window plan as credit plan`() throws { // plan_family absent → classic rate-window plan, unchanged behavior. diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 0a6db50ab9..87fd30f0c7 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -838,6 +838,66 @@ struct UsageStorePlanUtilizationTests { #expect(findSeries(histories, name: .monthly, windowMinutes: 43200)?.entries.last?.usedPercent == 34) } + @MainActor + @Test + func `record plan history stores mimo monthly series`() async { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 34, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(20 * 24 * 60 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .mimo, snapshot: snapshot, now: now) + + let histories = store.planUtilizationHistory(for: .mimo) + #expect(findSeries(histories, name: .monthly, windowMinutes: 43200)?.entries.last?.usedPercent == 34) + } + + @MainActor + @Test + func `record plan history stores stepfun monthly series`() async { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 58, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(20 * 24 * 60 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .stepfun, snapshot: snapshot, now: now) + + let histories = store.planUtilizationHistory(for: .stepfun) + #expect(findSeries(histories, name: .monthly, windowMinutes: 43200)?.entries.last?.usedPercent == 58) + } + + @MainActor + @Test + func `stepfun rolling windows keep their session and weekly history lanes`() async { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .stepfun, snapshot: snapshot, now: now) + + let histories = store.planUtilizationHistory(for: .stepfun) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 25) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 40) + } + @MainActor @Test func `generic provider weekly lane is persisted to provider history json`() async throws {