diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 520878d4d7..bf603407d4 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -104,7 +104,7 @@ enum MenuBarMetricWindowResolver { { return primary.usedPercent >= secondary.usedPercent ? primary : secondary } - if provider == .cursor { + if provider == .cursor || provider == .minimax { return Self.mostConstrainedWindow( primary: snapshot.primary, secondary: snapshot.secondary, diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 37973a99ab..62a25cb49d 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -166,6 +166,15 @@ extension UsageMenuCardView.Model { percentLine: nil) } + if provider == .minimax, cost.period == "MiniMax points balance" { + let balance = String(format: "%.0f", cost.used) + return ProviderCostSection( + title: L("Credits"), + percentUsed: nil, + spendLine: "\(L("Balance")): \(balance)", + percentLine: nil) + } + if provider == .openai || provider == .claude, cost.limit <= 0 { let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index dda62d32fe..d410770619 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -4,21 +4,22 @@ import Foundation extension UsageMenuCardView.Model { static func minimaxMetrics(services: [MiniMaxServiceUsage], input: Input) -> [Metric] { let percentStyle: PercentStyle = .used - let textGenerationCount = services.count { $0.displayName == "Text Generation" } + let displayNameCounts = Dictionary(grouping: services.map(\.displayName), by: { $0 }).mapValues(\.count) return services.enumerated().map { index, service in let used = service.usage let displayPercent = min(100, max(0, service.percent)) - let usageLabel = String( - format: L("minimax_usage_amount_format"), - used.formatted(), - service.limit.formatted()) - let usedLabel = String( - format: L("minimax_used_percent_format"), - String(format: "%.0f%%", displayPercent)) + let usageLabel = if service.isUnlimited { + nil as String? + } else { + String( + format: L("minimax_usage_amount_format"), + used.formatted(), + service.limit.formatted()) + } let localizedName = Self.localizedMiniMaxServiceName(service.displayName) - let title = if localizedName == L("minimax_service_text_generation"), textGenerationCount > 1 { - "\(L("minimax_service_text_generation")) · \(Self.displayWindowBadge(for: service.windowType))" + let title = if (displayNameCounts[service.displayName] ?? 0) > 1 { + "\(localizedName) · \(Self.displayWindowBadge(for: service.windowType))" } else { localizedName } @@ -28,13 +29,60 @@ extension UsageMenuCardView.Model { title: title, percent: displayPercent, percentStyle: percentStyle, + statusText: service.isUnlimited ? "∞ Unlimited" : nil, resetText: Self.localizedMiniMaxResetDescription(service.resetDescription), - detailText: service.timeRange, + detailText: nil, detailLeftText: usageLabel, - detailRightText: usedLabel, + detailRightText: nil, pacePercent: nil, paceOnTop: true, - cardStyle: true) + warningMarkerPercents: service.isUnlimited + ? [] + : Self.miniMaxWarningMarkerPercents(service: service, input: input), + cardStyle: false) + } + } + + private static func miniMaxWarningMarkerPercents(service: MiniMaxServiceUsage, input: Input) -> [Double] { + switch self.miniMaxQuotaWarningWindow(for: service) { + case .session: + warningMarkerPercents( + thresholds: input.quotaWarningThresholds[.session], + showUsed: true) + case .weekly: + markerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: true, + workDays: input.workDaysPerWeek, + windowMinutes: self.miniMaxWindowMinutes(for: service.windowType), + includeWorkdayMarkers: true) + } + } + + private static func miniMaxQuotaWarningWindow(for service: MiniMaxServiceUsage) -> QuotaWarningWindow { + service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" ? .weekly : .session + } + + private static func miniMaxWindowMinutes(for windowType: String) -> Int? { + let normalized = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == "weekly" { + return 7 * 24 * 60 + } + if normalized == "today" || normalized == "daily" { + return 24 * 60 + } + if normalized == "5h" { + return 5 * 60 + } + let pieces = normalized.split(separator: " ") + guard pieces.count >= 2, let value = Int(pieces[0]) else { return nil } + switch pieces[1] { + case "hour", "hours", "hr", "hrs": + return value * 60 + case "minute", "minutes", "min", "mins": + return value + default: + return nil } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 7601233dc9..4d345bf6ef 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -845,8 +845,10 @@ extension UsageMenuCardView.Model { } private static func usageNotes(input: Input) -> [String] { + let subscriptionNotes = self.subscriptionMetadataNotes(snapshot: input.snapshot, provider: input.provider) + if input.provider == .kiro { - return kiroUsageNotes(input: input) + return kiroUsageNotes(input: input) + subscriptionNotes } if input.provider == .kilo { @@ -860,24 +862,24 @@ extension UsageMenuCardView.Model { { notes.append(L("Using CLI fallback")) } - return notes + return notes + subscriptionNotes } if input.provider == .mimo, input.snapshot != nil { return [ L("Balance updates in near-real time (up to 5 min lag)"), L("Daily billing data finalizes at 07:00 UTC"), - ] + ] + subscriptionNotes } if let notes = apiProviderUsageNotes(input: input) { - return notes + return notes + subscriptionNotes } guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { - return [] + return subscriptionNotes } var notes = Self.openRouterSpendNotes(openRouter) @@ -889,7 +891,35 @@ extension UsageMenuCardView.Model { case .unavailable: notes.append(L("API key limit unavailable right now")) } - return notes + return notes + subscriptionNotes + } + + private static func subscriptionMetadataNotes(snapshot: UsageSnapshot?, provider: UsageProvider) -> [String] { + guard let snapshot else { return [] } + if let renewsAt = snapshot.subscriptionRenewsAt { + return [String(format: L("Renews: %@"), self.subscriptionDateString(renewsAt, provider: provider))] + } + if let expiresAt = snapshot.subscriptionExpiresAt { + return [String(format: L("Plan expires: %@"), self.subscriptionDateString(expiresAt, provider: provider))] + } + return [] + } + + private static func subscriptionDateString(_ date: Date, provider: UsageProvider) -> String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = self.subscriptionDateTimeZone(provider: provider) + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter.string(from: date) + } + + private static func subscriptionDateTimeZone(provider: UsageProvider) -> TimeZone { + switch provider { + case .minimax: + TimeZone(identifier: "Asia/Shanghai") ?? .current + default: + .current + } } private static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { @@ -952,6 +982,9 @@ extension UsageMenuCardView.Model { } private static func planDisplay(_ text: String, for provider: UsageProvider) -> String { + if provider == .minimax { + return self.miniMaxPlanDisplay(text) + } let cleaned = if provider == .codex { CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text) } else { @@ -960,6 +993,21 @@ extension UsageMenuCardView.Model { return cleaned.isEmpty ? text : cleaned } + private static func miniMaxPlanDisplay(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.lowercased() + if normalized.contains("tokenplanplus") || normalized.contains("token plan plus") { + return "Plus" + } + if normalized.contains("tokenplanmax") || normalized.contains("token plan max") { + return "Max" + } + if normalized.contains("tokenplanultra") || normalized.contains("token plan ultra") { + return "Ultra" + } + return trimmed + } + private static func kiloLoginPass(snapshot: UsageSnapshot?) -> String? { self.kiloLoginParts(snapshot: snapshot).pass } @@ -1057,7 +1105,8 @@ extension UsageMenuCardView.Model { } if input.provider == .minimax { if let minimaxUsage = snapshot.minimaxUsage { - if let services = minimaxUsage.services, !services.isEmpty { + let services = minimaxUsage.orderedQuotaServices + if !services.isEmpty { return Self.minimaxMetrics(services: services, input: input) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index bf93d57a70..56e77e2c75 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -7,6 +7,7 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let remainsPath = "v1/api/openplatform/coding_plan/remains" + private static let tokenPlanRemainsPath = "v1/token_plan/remains" private static let billingHistoryPath = "account/amount" public var displayName: String { @@ -57,6 +58,10 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.remainsPath) } + public var tokenPlanRemainsURL: URL { + URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.tokenPlanRemainsPath) + } + public var dashboardURL: URL { var components = URLComponents(string: self.baseURLString)! components.path = "/" + Self.codingPlanPath diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift index 3f16aef95e..ab68319a04 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieHeader.swift @@ -24,7 +24,11 @@ public enum MiniMaxCookieHeader { #"(?i)(?:--cookie|-b)\s*([^\s]+)"#, ] private static let authorizationPattern = #"(?i)\bauthorization:\s*bearer\s+([A-Za-z0-9._\-+=/]+)"# - private static let groupIDPattern = #"(?i)\bgroup[_]?id=([0-9]{4,})"# + private static let groupIDPatterns = [ + #"(?i)\bx-group-id:\s*([0-9]{4,})"#, + #"(?i)\bminimax_group_id_v2=([0-9]{4,})"#, + #"(?i)\bgroup[_]?id=([0-9]{4,})"#, + ] public static func override(from raw: String?) -> MiniMaxCookieOverride? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -34,7 +38,7 @@ public enum MiniMaxCookieHeader { } guard let cookie = self.normalized(from: raw) else { return nil } let authorizationToken = self.extractFirst(pattern: self.authorizationPattern, text: raw) - let groupID = self.extractFirst(pattern: self.groupIDPattern, text: raw) + let groupID = self.extractFirst(patterns: self.groupIDPatterns, text: raw) return MiniMaxCookieOverride( cookieHeader: cookie, authorizationToken: authorizationToken, @@ -102,4 +106,13 @@ public enum MiniMaxCookieHeader { let value = text[captureRange].trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : String(value) } + + private static func extractFirst(patterns: [String], text: String) -> String? { + for pattern in patterns { + if let value = self.extractFirst(pattern: pattern, text: text) { + return value + } + } + return nil + } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift new file mode 100644 index 0000000000..6ad41bb9e3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDecoding.swift @@ -0,0 +1,46 @@ +import Foundation + +enum MiniMaxDecoding { + static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return Int(value) + } + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return Int(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Int(trimmed) + } + return nil + } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: K) -> Double? { + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) + } + return nil + } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKeys keys: [K]) -> Double? { + for key in keys { + if let value = self.decodeDouble(container, forKey: key) { + return value + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift new file mode 100644 index 0000000000..6dc8838aaa --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxModelRemains.swift @@ -0,0 +1,64 @@ +struct MiniMaxModelRemains: Decodable { + let modelName: String? + let currentIntervalTotalCount: Int? + let currentIntervalUsageCount: Int? + let startTime: Int? + let endTime: Int? + let remainsTime: Int? + let intervalBoostPermille: Int? + let currentIntervalRemainingPercent: Double? + let currentIntervalStatus: Int? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? + let weeklyStartTime: Int? + let weeklyEndTime: Int? + let weeklyRemainsTime: Int? + let weeklyBoostPermille: Int? + let currentWeeklyRemainingPercent: Double? + let currentWeeklyStatus: Int? + + private enum CodingKeys: String, CodingKey { + case modelName = "model_name" + case currentIntervalTotalCount = "current_interval_total_count" + case currentIntervalUsageCount = "current_interval_usage_count" + case startTime = "start_time" + case endTime = "end_time" + case remainsTime = "remains_time" + case intervalBoostPermille = "interval_boost_permill" + case currentIntervalRemainingPercent = "current_interval_remaining_percent" + case currentIntervalStatus = "current_interval_status" + case currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" + case weeklyStartTime = "weekly_start_time" + case weeklyEndTime = "weekly_end_time" + case weeklyRemainsTime = "weekly_remains_time" + case weeklyBoostPermille = "weekly_boost_permill" + case currentWeeklyRemainingPercent = "current_weekly_remaining_percent" + case currentWeeklyStatus = "current_weekly_status" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) + self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) + self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) + self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) + self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.intervalBoostPermille = MiniMaxDecoding.decodeInt(container, forKey: .intervalBoostPermille) + self.currentIntervalRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentIntervalRemainingPercent) + self.currentIntervalStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalStatus) + self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) + self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) + self.weeklyBoostPermille = MiniMaxDecoding.decodeInt(container, forKey: .weeklyBoostPermille) + self.currentWeeklyRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentWeeklyRemainingPercent) + self.currentWeeklyStatus = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyStatus) + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift index 488b0b0f59..71101e8ca3 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift @@ -34,6 +34,9 @@ public struct MiniMaxServiceUsage: Sendable { /// The percentage of quota used (0-100) public let percent: Double + /// Whether this quota window is explicitly unlimited. + public let isUnlimited: Bool + /// The timestamp when the quota will reset, if available public let resetsAt: Date? @@ -49,6 +52,10 @@ public struct MiniMaxServiceUsage: Sendable { public var displayName: String { let normalized = self.serviceType.lowercased() return switch normalized { + case "general": + "General" + case "video": + "Video" case "text-generation": "Text Generation" case "text-to-speech": @@ -80,6 +87,11 @@ public struct MiniMaxServiceUsage: Sendable { } } + public var isPrimaryTextQuotaLane: Bool { + let normalized = self.serviceType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "general" || self.displayName == "Text Generation" + } + /// Creates a new MiniMaxServiceUsage instance. /// /// - Parameters: @@ -98,6 +110,7 @@ public struct MiniMaxServiceUsage: Sendable { usage: Int, limit: Int, percent: Double, + isUnlimited: Bool = false, resetsAt: Date?, resetDescription: String) { @@ -107,6 +120,7 @@ public struct MiniMaxServiceUsage: Sendable { self.usage = usage self.limit = limit self.percent = percent + self.isUnlimited = isUnlimited self.resetsAt = resetsAt self.resetDescription = resetDescription } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift new file mode 100644 index 0000000000..56a2d45c07 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -0,0 +1,270 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct MiniMaxSubscriptionMetadata: Sendable, Equatable { + let planName: String? + let subscriptionExpiresAt: Date? + let subscriptionRenewsAt: Date? +} + +enum MiniMaxSubscriptionMetadataFetcher { + private static let comboPath = "v1/api/openplatform/charge/combo/cycle_audio_resource_package" + + static func fetch( + cookieHeader: String, + groupID: String?, + region: MiniMaxAPIRegion, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> MiniMaxSubscriptionMetadata + { + let url = try self.resolveComboURL(region: region, environment: environment) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + if let groupID = groupID?.trimmingCharacters(in: .whitespacesAndNewlines), !groupID.isEmpty { + request.setValue(groupID, forHTTPHeaderField: "x-group-id") + } + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept") + request.setValue("zh-CN,zh;q=0.9", forHTTPHeaderField: "accept-language") + request.setValue(self.platformOrigin(region: region).absoluteString, forHTTPHeaderField: "origin") + request.setValue(self.platformOrigin(region: region).absoluteString + "/", forHTTPHeaderField: "referer") + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") + } + return try self.parse(data: response.data) + } + + static func parse(data: Data) throws -> MiniMaxSubscriptionMetadata { + let object = try JSONSerialization.jsonObject(with: data, options: []) + try self.validateBaseResponse(in: object) + let planName = self.findPlanName(in: object) + let subscriptionExpiresAt = self.findDate( + in: object, + keys: ["current_subscribe_end_time_ts", "current_subscribe_end_time"]) + let subscriptionRenewsAt = self.findDate( + in: object, + keys: ["renewal_trigger_time_ts", "renewal_date"]) + guard planName != nil || subscriptionExpiresAt != nil || subscriptionRenewsAt != nil else { + throw MiniMaxUsageError.parseFailed("MiniMax combo metadata did not include subscription metadata.") + } + return MiniMaxSubscriptionMetadata( + planName: planName, + subscriptionExpiresAt: subscriptionExpiresAt, + subscriptionRenewsAt: subscriptionRenewsAt) + } + + static func resolveComboURL(region: MiniMaxAPIRegion, environment: [String: String]) throws -> URL { + let host = MiniMaxSettingsReader.hostOverride(environment: environment) ?? self.defaultWebHost(region: region) + guard var components = URLComponents(string: host.hasPrefix("http") ? host : "https://\(host)"), + components.host?.isEmpty == false + else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host is invalid.") + } + guard components.scheme?.lowercased() == "https" else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host must use HTTPS.") + } + components.path = "/" + Self.comboPath + components.queryItems = [ + URLQueryItem(name: "biz_line", value: "2"), + URLQueryItem(name: "cycle_type", value: "3"), + URLQueryItem(name: "resource_package_type", value: "7"), + ] + guard let url = components.url else { + throw MiniMaxUsageError.apiError("MiniMax combo metadata host is invalid.") + } + return url + } + + private static func validateBaseResponse(in object: Any) throws { + guard let root = object as? [String: Any], + let baseResp = root["base_resp"] as? [String: Any] + else { return } + let status = self.intValue(baseResp["status_code"]) ?? 0 + guard status != 0 else { return } + let message = (baseResp["status_msg"] as? String) ?? "MiniMax combo metadata error \(status)" + if status == 1004 || message.lowercased().contains("cookie") { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError(message) + } + + private static func findPlanName(in object: Any) -> String? { + let currentSubscriptionStrings = self.collectCurrentSubscriptionStrings(in: object) + if let tokenPlan = self.bestPlanName(in: currentSubscriptionStrings) { + return tokenPlan + } + + let strings = self.collectStrings(in: object) + if let tokenPlan = self.bestPlanName(in: strings) { + return tokenPlan + } + + return nil + } + + private static func bestPlanName(in strings: [String]) -> String? { + let tokenPlans = strings.compactMap { value -> (rank: Int, value: String)? in + guard let rank = self.tokenPlanRank(value) else { return nil } + return (rank, value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + if let tokenPlan = tokenPlans.min(by: { lhs, rhs in + lhs.rank == rhs.rank ? lhs.value.count < rhs.value.count : lhs.rank < rhs.rank + }) { + return tokenPlan.value + } + return strings.first { value in + let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines) + return ["plus", "max", "ultra"].contains(cleaned.lowercased()) + } + } + + private static func collectCurrentSubscriptionStrings(in object: Any) -> [String] { + guard let dictionary = object as? [String: Any] else { + if let array = object as? [Any] { + return array.flatMap(self.collectCurrentSubscriptionStrings(in:)) + } + return [] + } + + return dictionary.flatMap { key, value in + let lowercasedKey = key.lowercased() + let stringsForCurrentField: [String] = if lowercasedKey == "current_subscribe" || + lowercasedKey == "current_subscription" || + lowercasedKey.contains("current_subscribe") || + lowercasedKey.contains("current_subscription") || + lowercasedKey.contains("current_plan") + { + self.collectStrings(in: value) + } else { + [] + } + return stringsForCurrentField + self.collectCurrentSubscriptionStrings(in: value) + } + } + + private static func tokenPlanRank(_ value: String) -> Int? { + let lower = value.lowercased() + if lower.contains("tokenplanplus") { return 0 } + if lower.contains("tokenplanmax") { return 1 } + if lower.contains("tokenplanultra") { return 2 } + if lower.contains("token plan"), lower.contains("plus") || lower.contains("max") || lower.contains("ultra") { + return 3 + } + return nil + } + + private static func collectStrings(in object: Any) -> [String] { + if let string = object as? String { return [string] } + if let array = object as? [Any] { return array.flatMap(self.collectStrings(in:)) } + if let dictionary = object as? [String: Any] { + return dictionary.sorted { $0.key < $1.key }.flatMap { self.collectStrings(in: $0.value) } + } + return [] + } + + private static func intValue(_ value: Any?) -> Int? { + if let int = value as? Int { return int } + if let string = value as? String { return Int(string) } + return nil + } + + private static func findDate(in object: Any, keys: [String]) -> Date? { + keys.lazy.compactMap { key in + self.findValue(forKey: key, in: object).flatMap(self.dateValue(from:)) + }.first + } + + private static func findValue(forKey key: String, in object: Any) -> Any? { + if let dictionary = object as? [String: Any] { + if let value = dictionary[key] { return value } + for nested in dictionary.values { + if let value = self.findValue(forKey: key, in: nested) { + return value + } + } + } + if let array = object as? [Any] { + for nested in array { + if let value = self.findValue(forKey: key, in: nested) { + return value + } + } + } + return nil + } + + private static func dateValue(from value: Any) -> Date? { + if let int = value as? Int { + return self.dateValue(fromNumber: Double(int)) + } + if let double = value as? Double { + return self.dateValue(fromNumber: double) + } + guard let string = value as? String else { return nil } + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if let numeric = Double(trimmed) { + return self.dateValue(fromNumber: numeric) + } + return self.dateFromMonthDayYear(trimmed) + } + + private static func dateValue(fromNumber value: Double) -> Date? { + guard value.isFinite, value > 0 else { return nil } + let seconds = value > 10_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + private static func dateFromMonthDayYear(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + formatter.dateFormat = "MM/dd/yyyy" + return formatter.date(from: value) + } + + private static func defaultWebHost(region: MiniMaxAPIRegion) -> String { + switch region { + case .global: "https://www.minimax.io" + case .chinaMainland: "https://www.minimaxi.com" + } + } + + private static func platformOrigin(region: MiniMaxAPIRegion) -> URL { + switch region { + case .global: URL(string: "https://platform.minimax.io")! + case .chinaMainland: URL(string: "https://platform.minimaxi.com")! + } + } +} + +extension MiniMaxUsageFetcher { + static func attachingSubscriptionMetadataIfAvailable( + to snapshot: MiniMaxUsageSnapshot, + context: WebFetchContext, + groupID: String?) async throws -> MiniMaxUsageSnapshot + { + let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID + guard resolvedGroupID?.isEmpty == false else { return snapshot } + do { + let metadata = try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + return snapshot.withSubscriptionMetadata(metadata) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + Self.log.debug("MiniMax subscription metadata unavailable: \(error.localizedDescription)") + return snapshot + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift new file mode 100644 index 0000000000..4cd856816e --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageError.swift @@ -0,0 +1,21 @@ +import Foundation + +public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "MiniMax credentials are invalid or expired." + case let .networkError(message): + "MiniMax network error: \(message)" + case let .apiError(message): + "MiniMax API error: \(message)" + case let .parseFailed(message): + "Failed to parse MiniMax coding plan: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 7b250d6a2e..3f67e9a5b4 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -8,6 +8,7 @@ public struct MiniMaxUsageFetcher: Sendable { private static let codingPlanPath = "user-center/payment/coding-plan" private static let codingPlanQuery = "cycle_type=3" private static let codingPlanRemainsPath = "v1/api/openplatform/coding_plan/remains" + private static let tokenPlanRemainsPath = "v1/token_plan/remains" private static let billingHistoryPath = "account/amount" private static let billingHistoryLimit = 100 private struct RemainsContext { @@ -15,7 +16,7 @@ public struct MiniMaxUsageFetcher: Sendable { let groupID: String? } - private struct WebFetchContext { + struct WebFetchContext { let cookie: String let authorizationToken: String? let region: MiniMaxAPIRegion @@ -44,7 +45,10 @@ public struct MiniMaxUsageFetcher: Sendable { environment: environment, transport: transport) do { - let snapshot = try await self.fetchCodingPlanHTML(context: context, now: now) + let snapshot = try await self.attachingSubscriptionMetadataIfAvailable( + to: self.fetchCodingPlanHTML(context: context, now: now), + context: context, + groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, @@ -53,12 +57,15 @@ public struct MiniMaxUsageFetcher: Sendable { } catch let error as MiniMaxUsageError { if case .parseFailed = error { Self.log.debug("MiniMax coding plan HTML parse failed, trying remains API") - let snapshot = try await self.fetchCodingPlanRemains( + let snapshot = try await self.attachingSubscriptionMetadataIfAvailable( + to: self.fetchCodingPlanRemains( + context: context, + remainsContext: RemainsContext( + authorizationToken: authorizationToken, + groupID: groupID), + now: now), context: context, - remainsContext: RemainsContext( - authorizationToken: authorizationToken, - groupID: groupID), - now: now) + groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, @@ -112,7 +119,35 @@ public struct MiniMaxUsageFetcher: Sendable { now: Date, transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot { - var request = URLRequest(url: region.apiRemainsURL) + var lastError: Error? + for remainsURL in [region.tokenPlanRemainsURL, region.apiRemainsURL] { + do { + return try await self.fetchAPIUsageOnce( + apiToken: apiToken, + remainsURL: remainsURL, + now: now, + transport: transport) + } catch let error as MiniMaxUsageError { + lastError = error + guard remainsURL == region.tokenPlanRemainsURL, + self.shouldTryLegacyAPIEndpoint(after: error) + else { + throw error + } + Self.log.debug("MiniMax token-plan API failed, trying legacy coding-plan endpoint") + } + } + if let lastError { throw lastError } + throw MiniMaxUsageError.parseFailed("Missing MiniMax API remains URL.") + } + + private static func fetchAPIUsageOnce( + apiToken: String, + remainsURL: URL, + now: Date, + transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot + { + var request = URLRequest(url: remainsURL) request.httpMethod = "GET" request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "accept") @@ -122,10 +157,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -137,13 +170,31 @@ public struct MiniMaxUsageFetcher: Sendable { throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } return snapshot } + private static func shouldTryLegacyAPIEndpoint(after error: MiniMaxUsageError) -> Bool { + switch error { + case .invalidCredentials: + true + case let .apiError(message): + message.contains("HTTP 404") || message.contains("HTTP 405") + case .networkError, .parseFailed: + true + } + } + private static func fetchCodingPlanHTML( context: WebFetchContext, now: Date) async throws -> MiniMaxUsageSnapshot @@ -171,10 +222,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await context.transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -189,7 +238,14 @@ public struct MiniMaxUsageFetcher: Sendable { if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } @@ -211,7 +267,30 @@ public struct MiniMaxUsageFetcher: Sendable { remainsContext: RemainsContext, now: Date) async throws -> MiniMaxUsageSnapshot { - let baseRemainsURL = self.resolveRemainsURL(region: context.region, environment: context.environment) + var lastError: Error? + for baseRemainsURL in self.resolveRemainsURLs(region: context.region, environment: context.environment) { + do { + return try await self.fetchCodingPlanRemainsOnce( + baseRemainsURL: baseRemainsURL, + context: context, + remainsContext: remainsContext, + now: now) + } catch let error as MiniMaxUsageError { + lastError = error + guard self.shouldTryNextRemainsURL(after: error) else { throw error } + Self.log.debug("MiniMax remains API failed for \(baseRemainsURL.host ?? "unknown host"), trying next") + } + } + if let lastError { throw lastError } + throw MiniMaxUsageError.parseFailed("Missing MiniMax remains URL.") + } + + private static func fetchCodingPlanRemainsOnce( + baseRemainsURL: URL, + context: WebFetchContext, + remainsContext: RemainsContext, + now: Date) async throws -> MiniMaxUsageSnapshot + { let remainsURL = self.appendGroupID(remainsContext.groupID, to: baseRemainsURL) var request = URLRequest(url: remainsURL) request.httpMethod = "GET" @@ -236,10 +315,8 @@ public struct MiniMaxUsageFetcher: Sendable { let response: ProviderHTTPResponse do { response = try await context.transport.response(for: request) - } catch let error as URLError where error.code == .badServerResponse { - throw MiniMaxUsageError.networkError("Invalid response") } catch { - throw error + throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { @@ -254,7 +331,14 @@ public struct MiniMaxUsageFetcher: Sendable { if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + let snapshot: MiniMaxUsageSnapshot + do { + snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) + } catch let error as MiniMaxUsageError { + throw error + } catch { + throw MiniMaxUsageError.parseFailed(error.localizedDescription) + } if let services = snapshot.services, !services.isEmpty { Self.log.debug("MiniMax multi-service response detected: \(services.count) services") } @@ -268,6 +352,33 @@ public struct MiniMaxUsageFetcher: Sendable { return try MiniMaxUsageParser.parse(html: html, now: now) } + private static func shouldTryNextRemainsURL(after error: MiniMaxUsageError) -> Bool { + switch error { + case .invalidCredentials: + false + case let .apiError(message): + message.contains("HTTP 404") || message.contains("HTTP 405") + case .networkError, .parseFailed: + true + } + } + + private static func normalizedTransportError(_ error: Error) -> Error { + if error is MiniMaxUsageError || error is CancellationError { + return error + } + if let urlError = error as? URLError { + if urlError.code == .cancelled { + return error + } + if urlError.code == .badServerResponse { + return MiniMaxUsageError.networkError("Invalid response") + } + return MiniMaxUsageError.networkError(urlError.localizedDescription) + } + return error + } + private static func attachingBillingIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, @@ -427,6 +538,50 @@ public struct MiniMaxUsageFetcher: Sendable { return region.remainsURL } + static func resolveRemainsURLs( + region: MiniMaxAPIRegion, + environment: [String: String]) -> [URL] + { + if let override = MiniMaxSettingsReader.remainsURL(environment: environment) { + return [override] + } + if let host = MiniMaxSettingsReader.hostOverride(environment: environment), + let hostURL = self.url(from: host, path: Self.codingPlanRemainsPath) + { + return [hostURL] + } + + let primary = region.remainsURL + let webCandidates = self.webRemainsFallbackURLs(region: region) + return self.deduplicated([primary] + webCandidates) + } + + static func resolveTokenPlanRemainsURL(region: MiniMaxAPIRegion) -> URL { + region.tokenPlanRemainsURL + } + + private static func webRemainsFallbackURLs(region: MiniMaxAPIRegion) -> [URL] { + let hosts = switch region { + case .global: + ["https://www.minimax.io"] + case .chinaMainland: + ["https://www.minimaxi.com"] + } + return hosts.compactMap { self.url(from: $0, path: Self.codingPlanRemainsPath) } + } + + private static func deduplicated(_ urls: [URL]) -> [URL] { + var seen: Set = [] + var result: [URL] = [] + for url in urls { + let key = url.absoluteString + guard !seen.contains(key) else { continue } + seen.insert(key) + result.append(url) + } + return result + } + static func resolveBillingHistoryURL( region: MiniMaxAPIRegion, environment: [String: String], @@ -542,6 +697,7 @@ struct MiniMaxCodingPlanData: Decodable { let comboTitle: String? let currentPlanTitle: String? let currentComboCard: MiniMaxComboCard? + let pointsBalance: Double? let modelRemains: [MiniMaxModelRemains] private enum CodingKeys: String, CodingKey { @@ -551,6 +707,11 @@ struct MiniMaxCodingPlanData: Decodable { case comboTitle = "combo_title" case currentPlanTitle = "current_plan_title" case currentComboCard = "current_combo_card" + case pointsBalance = "points_balance" + case pointBalance = "point_balance" + case creditsBalance = "credits_balance" + case creditBalance = "credit_balance" + case balance case modelRemains = "model_remains" } @@ -562,6 +723,13 @@ struct MiniMaxCodingPlanData: Decodable { self.comboTitle = try container.decodeIfPresent(String.self, forKey: .comboTitle) self.currentPlanTitle = try container.decodeIfPresent(String.self, forKey: .currentPlanTitle) self.currentComboCard = try container.decodeIfPresent(MiniMaxComboCard.self, forKey: .currentComboCard) + self.pointsBalance = MiniMaxDecoding.decodeDouble(container, forKeys: [ + .pointsBalance, + .pointBalance, + .creditsBalance, + .creditBalance, + .balance, + ]) self.modelRemains = try (container.decodeIfPresent([MiniMaxModelRemains].self, forKey: .modelRemains)) ?? [] } } @@ -570,49 +738,6 @@ struct MiniMaxComboCard: Decodable { let title: String? } -struct MiniMaxModelRemains: Decodable { - let modelName: String? - let currentIntervalTotalCount: Int? - let currentIntervalUsageCount: Int? - let startTime: Int? - let endTime: Int? - let remainsTime: Int? - let currentWeeklyTotalCount: Int? - let currentWeeklyUsageCount: Int? - let weeklyStartTime: Int? - let weeklyEndTime: Int? - let weeklyRemainsTime: Int? - - private enum CodingKeys: String, CodingKey { - case modelName = "model_name" - case currentIntervalTotalCount = "current_interval_total_count" - case currentIntervalUsageCount = "current_interval_usage_count" - case startTime = "start_time" - case endTime = "end_time" - case remainsTime = "remains_time" - case currentWeeklyTotalCount = "current_weekly_total_count" - case currentWeeklyUsageCount = "current_weekly_usage_count" - case weeklyStartTime = "weekly_start_time" - case weeklyEndTime = "weekly_end_time" - case weeklyRemainsTime = "weekly_remains_time" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) - self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) - self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) - self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) - self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) - self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) - self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) - self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) - self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) - self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) - self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) - } -} - struct MiniMaxBaseResponse: Decodable { let statusCode: Int? let statusMessage: String? @@ -676,42 +801,6 @@ struct MiniMaxServiceItem: Decodable { } } -enum MiniMaxDecoding { - static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { - if let value = try? container.decodeIfPresent(Int.self, forKey: key) { - return value - } - if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { - return Int(value) - } - if let value = try? container.decodeIfPresent(Double.self, forKey: key) { - return Int(value) - } - if let value = try? container.decodeIfPresent(String.self, forKey: key) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Int(trimmed) - } - return nil - } - - static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: K) -> Double? { - if let value = try? container.decodeIfPresent(Double.self, forKey: key) { - return value - } - if let value = try? container.decodeIfPresent(Int.self, forKey: key) { - return Double(value) - } - if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { - return Double(value) - } - if let value = try? container.decodeIfPresent(String.self, forKey: key) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Double(trimmed) - } - return nil - } -} - enum MiniMaxUsageParser { static func decodePayload(data: Data) throws -> MiniMaxCodingPlanPayload { let decoder = JSONDecoder() @@ -799,25 +888,31 @@ enum MiniMaxUsageParser { windowTypeOverride: nil, total: item.currentIntervalTotalCount, remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, + status: item.currentIntervalStatus, start: item.startTime, end: item.endTime, - remainsTime: item.remainsTime), + remainsTime: item.remainsTime, + boostPermille: item.intervalBoostPermille), now: now) { services.append(intervalService) } // current_weekly_usage_count is also REMAINING quota; render only when weekly quota is real. - if self.isTextGenerationModelName(modelName), + if self.shouldRenderWeeklyWindow(for: modelName), let weeklyService = self.makeServiceUsage( ServiceUsageInput( serviceType: serviceTypeIdentifier, windowTypeOverride: "Weekly", total: item.currentWeeklyTotalCount, remaining: item.currentWeeklyUsageCount, + remainingPercent: item.currentWeeklyRemainingPercent, + status: item.currentWeeklyStatus, start: item.weeklyStartTime, end: item.weeklyEndTime, - remainsTime: item.weeklyRemainsTime), + remainsTime: item.weeklyRemainsTime, + boostPermille: item.weeklyBoostPermille), now: now) { services.append(weeklyService) @@ -826,9 +921,15 @@ enum MiniMaxUsageParser { // Use first service for backward compatibility fields let first = payload.data.modelRemains.first - let total = first?.currentIntervalTotalCount - let remaining = first?.currentIntervalUsageCount - let usedPercent = self.usedPercent(total: total, remaining: remaining) + let hasPercentQuota = first?.currentIntervalRemainingPercent != nil + let total = hasPercentQuota && first?.currentIntervalTotalCount == 0 ? nil : first?.currentIntervalTotalCount + let remaining = hasPercentQuota && first?.currentIntervalUsageCount == 0 + ? nil + : first?.currentIntervalUsageCount + let usedPercent = self.usedPercent( + total: total, + remaining: remaining, + remainingPercent: first?.currentIntervalRemainingPercent) let windowMinutes = self.windowMinutes( start: self.dateFromEpoch(first?.startTime), @@ -856,16 +957,24 @@ enum MiniMaxUsageParser { usedPercent: usedPercent, resetsAt: resetsAt, updatedAt: now, - services: services.isEmpty ? nil : services) + services: services.isEmpty ? nil : services, + pointsBalance: payload.data.pointsBalance) } - private static func usedPercent(total: Int?, remaining: Int?) -> Double? { + private static func usedPercent(total: Int?, remaining: Int?, remainingPercent: Double? = nil) -> Double? { + if let remainingPercent { + return self.usedPercent(remainingPercent: remainingPercent) + } guard let total, total > 0, let remaining else { return nil } let used = max(0, total - remaining) let percent = Double(used) / Double(total) * 100 return min(100, max(0, percent)) } + private static func usedPercent(remainingPercent: Double) -> Double { + min(100, max(0, 100 - remainingPercent)) + } + private static func dateFromEpoch(_ value: Int?) -> Date? { guard let raw = value else { return nil } if raw > 1_000_000_000_000 { @@ -893,40 +1002,53 @@ enum MiniMaxUsageParser { } private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { - let candidates = [ + [ data.currentSubscribeTitle, data.planName, data.comboTitle, data.currentPlanTitle, data.currentComboCard?.title, - ].compactMap(\.self) + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? self.inferredTokenPlanName(data: data) + } - for candidate in candidates { - let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } + private static func inferredTokenPlanName(data: MiniMaxCodingPlanData) -> String? { + let hasTextGeneration = data.modelRemains.contains { $0.modelName.map(self.isTextGenerationModelName) ?? false } + let hasUnavailableVideo = data.modelRemains.contains { item in + item.modelName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "video" && + self.isUnavailableQuotaPlaceholder(ServiceUsageInput( + serviceType: "Text to Video", + windowTypeOverride: nil, + total: item.currentIntervalTotalCount, + remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, + status: item.currentIntervalStatus, + start: nil, + end: nil, + remainsTime: nil, + boostPermille: nil)) } - return nil + return hasTextGeneration && hasUnavailableVideo ? "Plus" : nil } private static func parsePlanName(html: String, text: String) -> String? { - let candidates = [ + [ self.extractFirst(pattern: #"(?i)"planName"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)"plan"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)"packageName"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)Coding\s*Plan\s*([A-Za-z0-9][A-Za-z0-9\s._-]{0,32})"#, text: text), - ].compactMap(\.self) - - for candidate in candidates { - let cleaned = UsageFormatter.cleanPlanName(candidate) - let trimmed = cleaned - .replacingOccurrences( - of: #"(?i)\s+available\s+usage.*$"#, - with: "", - options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - return nil + ] + .compactMap(\.self) + .map { + UsageFormatter.cleanPlanName($0) + .replacingOccurrences( + of: #"(?i)\s+available\s+usage.*$"#, + with: "", + options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + .first { !$0.isEmpty } } private static func parseNextData(html: String, now: Date) -> MiniMaxUsageSnapshot? { @@ -987,6 +1109,12 @@ enum MiniMaxUsageParser { if normalized["base_resp"] == nil, let value = normalized["baseResp"] { normalized["base_resp"] = value } + if normalized["points_balance"] == nil, let value = normalized["pointsBalance"] { + normalized["points_balance"] = value + } + if normalized["credits_balance"] == nil, let value = normalized["creditsBalance"] { + normalized["credits_balance"] = value + } if let data = normalized["data"] as? [String: Any] { normalized["data"] = self.normalizeCodingPlanPayload(data) @@ -1388,15 +1516,16 @@ enum MiniMaxUsageParser { let windowTypeOverride: String? let total: Int? let remaining: Int? + let remainingPercent: Double? + let status: Int? let start: Int? let end: Int? let remainsTime: Int? + let boostPermille: Int? } private static func makeServiceUsage(_ input: ServiceUsageInput, now: Date) -> MiniMaxServiceUsage? { - guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } - let used = max(0, total - remaining) - if used == 0, total == 0 { return nil } + guard self.shouldRenderQuotaWindow(input) else { return nil } let startTime = self.dateFromEpoch(input.start) let endTime = self.dateFromEpoch(input.end) @@ -1408,33 +1537,91 @@ enum MiniMaxUsageParser { timeRange = weeklyRange } - let resetsAt = self.resetsAt(end: endTime, remains: input.remainsTime, now: now) - let resetDescription = self.resetDescription( - for: windowType, - timeRange: timeRange, - now: now, - resetsAt: resetsAt) + let isUnlimited = self.isUnlimitedQuotaWindow(input, windowType: windowType) + let resetsAt = isUnlimited ? nil : self.resetsAt(end: endTime, remains: input.remainsTime, now: now) + let resetDescription = if isUnlimited { + "Unlimited" + } else { + self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + } + let limit: Int + let usage: Int + let percent: Double + if isUnlimited { + percent = 0 + limit = 0 + usage = 0 + } else if let remainingPercent = input.remainingPercent { + let quotaLimit = self.percentQuotaLimit(boostPermille: input.boostPermille) + percent = self.usedPercent(remainingPercent: remainingPercent) + limit = quotaLimit + usage = Int((percent * Double(quotaLimit) / 100.0).rounded()) + } else { + guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } + let used = max(0, total - remaining) + percent = Double(used) / Double(total) * 100.0 + limit = total + usage = used + } - let percent = Double(used) / Double(total) * 100.0 return MiniMaxServiceUsage( serviceType: input.serviceType, windowType: windowType, timeRange: timeRange, - usage: used, - limit: total, + usage: usage, + limit: limit, percent: min(100.0, max(0.0, percent)), + isUnlimited: isUnlimited, resetsAt: resetsAt, resetDescription: resetDescription) } + private static func percentQuotaLimit(boostPermille: Int?) -> Int { + guard let boostPermille, boostPermille > 0 else { return 100 } + return max(1, Int((Double(boostPermille) / 10.0).rounded())) + } + + private static func shouldRenderQuotaWindow(_ input: ServiceUsageInput) -> Bool { + // MiniMax Token Plan returns status 3 for quota lanes that exist in the schema but are not included in + // the current subscription, for example Plus accounts receiving a video lane with 100% remaining and 0 count. + !self.isUnavailableQuotaPlaceholder(input) + } + + private static func isUnavailableQuotaPlaceholder(_ input: ServiceUsageInput) -> Bool { + if let windowType = input.windowTypeOverride, self.isUnlimitedQuotaWindow(input, windowType: windowType) { + return false + } + return input.status == 3 && + (input.total ?? 0) == 0 && + (input.remaining ?? 0) == 0 && + (input.remainingPercent.map { $0 >= 100 } ?? false) + } + + private static func isUnlimitedQuotaWindow(_ input: ServiceUsageInput, windowType: String) -> Bool { + let normalizedService = input.serviceType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedWindow = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let unlimitedServices = ["text generation", "general"] + return input.status == 3 && + unlimitedServices.contains(normalizedService) && + normalizedWindow == "weekly" && + (input.remainingPercent.map { $0 >= 100 } ?? false) + } + private static func mapModelNameToServiceType(modelName: String) -> String { - // Text Generation (文本生成): M2.7, M2.7-highspeed, MiniMax-M*, etc. + let lower = modelName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if lower == "general" || lower == "video" { + return lower + } + + // Legacy text model names are separate from Token Plan's `general` bucket. if self.isTextGenerationModelName(modelName) { return "Text Generation" } - let lower = modelName.lowercased() - // Text to Speech (语音合成): speech-hd, Speech 2.8, etc. if lower.contains("speech") { return "Text to Speech" @@ -1466,7 +1653,11 @@ enum MiniMaxUsageParser { private static func isTextGenerationModelName(_ modelName: String) -> Bool { let lower = modelName.lowercased() - return lower.contains("minimax-m") || lower.hasPrefix("m2.") + return lower == "general" || lower.contains("minimax-m") || lower.hasPrefix("m2.") + } + + private static func shouldRenderWeeklyWindow(for modelName: String) -> Bool { + self.isTextGenerationModelName(modelName) } private static func formatMiniMaxDateTimeRange(startTime: Date?, endTime: Date?) -> String? { @@ -1480,23 +1671,3 @@ enum MiniMaxUsageParser { return "\(start) - \(end)(UTC+8)" } } - -public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { - case invalidCredentials - case networkError(String) - case apiError(String) - case parseFailed(String) - - public var errorDescription: String? { - switch self { - case .invalidCredentials: - "MiniMax credentials are invalid or expired." - case let .networkError(message): - "MiniMax network error: \(message)" - case let .apiError(message): - "MiniMax API error: \(message)" - case let .parseFailed(message): - "Failed to parse MiniMax coding plan: \(message)" - } - } -} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift new file mode 100644 index 0000000000..17532c9fa0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -0,0 +1,45 @@ +import Foundation + +extension MiniMaxUsageSnapshot { + func withPlanNameIfAvailable(_ planName: String?) -> MiniMaxUsageSnapshot { + let cleaned = planName?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let cleaned, !cleaned.isEmpty else { return self } + return MiniMaxUsageSnapshot( + planName: cleaned, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) + } + + func withSubscriptionMetadata(_ metadata: MiniMaxSubscriptionMetadata) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: metadata.planName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: metadata.subscriptionExpiresAt ?? self.subscriptionExpiresAt, + subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt) + } +} + +extension String { + fileprivate var nonEmpty: String? { + self.isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index f66de9d233..fe73270d0d 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -11,40 +11,59 @@ public struct MiniMaxUsageSnapshot: Sendable { public let updatedAt: Date public let services: [MiniMaxServiceUsage]? public let billingSummary: MiniMaxBillingSummary? + public let pointsBalance: Double? + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? public var primaryService: MiniMaxServiceUsage? { - // Priority: "Text Generation" > first service - if let services = self.services, !services.isEmpty { - if let textGenService = services.first(where: { $0.displayName == "Text Generation" }) { - return textGenService - } - return services.first - } - return nil + self.orderedQuotaServices.first } public var secondaryService: MiniMaxServiceUsage? { - // Return second service for RateWindow.secondary if exists - guard let services = self.services, services.count >= 2 else { return nil } - // If we have Text Generation as primary, get the next non-Text Generation service - if let textGenIndex = services.firstIndex(where: { $0.displayName == "Text Generation" }) { - // If Text Generation is first, secondary is second - if textGenIndex == 0 { - return services[1] - } - // If Text Generation is not first, secondary could be first or second depending on count - return services[0] - } - // No Text Generation found, just return second service + let services = self.orderedQuotaServices + guard services.count >= 2 else { return nil } return services[1] } public var tertiaryService: MiniMaxServiceUsage? { - // Return third service for RateWindow.tertiary if exists - guard let services = self.services, services.count >= 3 else { return nil } + let services = self.orderedQuotaServices + guard services.count >= 3 else { return nil } return services[2] } + public var orderedQuotaServices: [MiniMaxServiceUsage] { + guard let services, !services.isEmpty else { return [] } + return services.enumerated().sorted { lhs, rhs in + let lhsRank = self.quotaServiceRank(lhs.element, originalIndex: lhs.offset) + let rhsRank = self.quotaServiceRank(rhs.element, originalIndex: rhs.offset) + if lhsRank.primary != rhsRank.primary { + return lhsRank.primary < rhsRank.primary + } + if lhsRank.window != rhsRank.window { + return lhsRank.window < rhsRank.window + } + return lhsRank.originalIndex < rhsRank.originalIndex + }.map(\.element) + } + + private func quotaServiceRank( + _ service: MiniMaxServiceUsage, + originalIndex: Int) -> (primary: Int, window: Int, originalIndex: Int) + { + ( + primary: service.isPrimaryTextQuotaLane ? 0 : 1, + window: self.quotaWindowRank(service), + originalIndex: originalIndex) + } + + private func quotaWindowRank(_ service: MiniMaxServiceUsage) -> Int { + let window = service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if window == "weekly" { + return 1 + } + return 0 + } + public init( planName: String?, availablePrompts: Int?, @@ -55,7 +74,10 @@ public struct MiniMaxUsageSnapshot: Sendable { resetsAt: Date?, updatedAt: Date, services: [MiniMaxServiceUsage]? = nil, - billingSummary: MiniMaxBillingSummary? = nil) + billingSummary: MiniMaxBillingSummary? = nil, + pointsBalance: Double? = nil, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil) { self.planName = planName self.availablePrompts = availablePrompts @@ -67,6 +89,9 @@ public struct MiniMaxUsageSnapshot: Sendable { self.updatedAt = updatedAt self.services = services self.billingSummary = billingSummary + self.pointsBalance = pointsBalance + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt } public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { @@ -80,7 +105,10 @@ public struct MiniMaxUsageSnapshot: Sendable { resetsAt: self.resetsAt, updatedAt: self.updatedAt, services: self.services, - billingSummary: billingSummary) + billingSummary: billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) } } @@ -104,8 +132,10 @@ extension MiniMaxUsageSnapshot { primary: primaryWindow, secondary: secondaryWindow, tertiary: tertiaryWindow, - providerCost: nil, + providerCost: self.pointsBalanceSnapshot(), minimaxUsage: self, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -131,8 +161,10 @@ extension MiniMaxUsageSnapshot { primary: primary, secondary: nil, tertiary: nil, - providerCost: nil, + providerCost: self.pointsBalanceSnapshot(), minimaxUsage: self, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -178,6 +210,9 @@ extension MiniMaxUsageSnapshot { if windowType == "today" { return 24 * 60 } + if windowType == "weekly" { + return 7 * 24 * 60 + } // Handle time duration formats like "5 hours", "30 minutes", etc. let components = windowType.split(separator: " ") @@ -197,4 +232,14 @@ extension MiniMaxUsageSnapshot { return nil } } + + private func pointsBalanceSnapshot() -> ProviderCostSnapshot? { + guard let pointsBalance, pointsBalance >= 0 else { return nil } + return ProviderCostSnapshot( + used: pointsBalance, + limit: 0, + currencyCode: "Points", + period: "MiniMax points balance", + updatedAt: self.updatedAt) + } } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 471f1147f5..d3b3353380 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -95,6 +95,8 @@ public struct UsageSnapshot: Codable, Sendable { public let mistralUsage: MistralUsageSnapshot? public let deepgramUsage: DeepgramUsageSnapshot? public let cursorRequests: CursorRequestUsage? + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -110,6 +112,8 @@ public struct UsageSnapshot: Codable, Sendable { case claudeAdminAPIUsage case mistralUsage case deepgramUsage + case subscriptionExpiresAt + case subscriptionRenewsAt case updatedAt case identity case accountEmail @@ -133,6 +137,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: MistralUsageSnapshot? = nil, deepgramUsage: DeepgramUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) { @@ -151,6 +157,8 @@ public struct UsageSnapshot: Codable, Sendable { self.mistralUsage = mistralUsage self.deepgramUsage = deepgramUsage self.cursorRequests = cursorRequests + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt self.updatedAt = updatedAt self.identity = identity } @@ -174,6 +182,8 @@ public struct UsageSnapshot: Codable, Sendable { self.mistralUsage = try container.decodeIfPresent(MistralUsageSnapshot.self, forKey: .mistralUsage) self.deepgramUsage = try container.decodeIfPresent(DeepgramUsageSnapshot.self, forKey: .deepgramUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time + self.subscriptionExpiresAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionExpiresAt) + self.subscriptionRenewsAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionRenewsAt) self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { self.identity = identity @@ -207,6 +217,8 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage) try container.encodeIfPresent(self.deepgramUsage, forKey: .deepgramUsage) + try container.encodeIfPresent(self.subscriptionExpiresAt, forKey: .subscriptionExpiresAt) + try container.encodeIfPresent(self.subscriptionRenewsAt, forKey: .subscriptionRenewsAt) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) @@ -310,6 +322,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: self.mistralUsage, deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } @@ -335,6 +349,7 @@ public struct UsageSnapshot: Codable, Sendable { secondary: secondary, tertiary: tertiary, extraRateWindows: self.extraRateWindows, + kiroUsage: self.kiroUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, @@ -345,6 +360,8 @@ public struct UsageSnapshot: Codable, Sendable { mistralUsage: self.mistralUsage, deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: self.identity) } diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 267f3098b3..4047bbaef4 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -21,6 +21,23 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.usedPercent == 92) } + @Test + func `automatic metric uses minimax weekly token lane when it is most constrained`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 97, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .minimax, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 97) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + @Test func `extra usage metric maps provider cost into a menu bar window`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 6e64710af2..5842663519 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -531,7 +531,7 @@ struct FactoryMenuCardModelTests { struct MiniMaxMenuCardModelTests { @Test - func `minimax service metrics use quota card copy`() throws { + func `minimax service metrics use codex aligned quota copy`() throws { let now = Date() let minimax = MiniMaxUsageSnapshot( planName: "Max", @@ -587,10 +587,10 @@ struct MiniMaxMenuCardModelTests { #expect(used.metrics.first?.title == "Text Generation") #expect(used.metrics.first?.detailLeftText == "Usage: 2 / 10") - #expect(used.metrics.first?.detailRightText == "Used 20%") - #expect(used.metrics.first?.detailText == "10:00-15:00(UTC+8)") + #expect(used.metrics.first?.detailRightText == nil) + #expect(used.metrics.first?.detailText == nil) #expect(used.metrics.first?.percent == 20) - #expect(used.metrics.first?.cardStyle == true) + #expect(used.metrics.first?.cardStyle == false) } @Test @@ -661,6 +661,79 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[0].title == "Text Generation · Today") #expect(model.metrics[1].title == "Text Generation · Weekly") } + + @Test + func `minimax token plan model shows weekly quota and points balance`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan · TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(4 * 3600), + resetDescription: "Resets in 4 hours"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ], + pointsBalance: 14000, + subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.planText == "Plus") + #expect(model.metrics[0].title == "Text Generation · 5h") + #expect(model.metrics[1].title == "Text Generation · Weekly") + #expect(model.metrics[0].detailLeftText == "Usage: 4 / 100") + #expect(model.metrics[1].detailLeftText == "Usage: 1 / 100") + #expect(model.metrics[0].detailRightText == nil) + #expect(model.metrics[1].detailRightText == nil) + #expect(model.metrics[0].detailText == nil) + #expect(model.metrics[1].detailText == nil) + #expect(model.metrics[0].cardStyle == false) + #expect(model.metrics[1].cardStyle == false) + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "Balance: 14000") + #expect(model.usageNotes == ["Renews: May 18, 2027"]) + } } struct ClaudeMenuCardCostTests { diff --git a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift index f1a69d37b0..4027ca11db 100644 --- a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift +++ b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift @@ -49,9 +49,16 @@ struct MiniMaxAPITokenFetchTests { session: Self.makeSession()) #expect(snapshot.planName == "Max") - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + ]) } @Test @@ -83,9 +90,18 @@ struct MiniMaxAPITokenFetchTests { session: Self.makeSession()) } - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) } @Test diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 9689625905..23740cb502 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -98,4 +98,189 @@ struct MiniMaxMenuCardModelPlanTests { #expect(model.planText == nil) } + + @Test + func `minimax quota rows include configured warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 31, + limit: 100, + percent: 31, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]]) + } + + @Test + func `minimax quota rows use canonical general first order`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanMax-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "video", + windowType: "Today", + timeRange: "06/01 00:00 - 06/02 00:00(UTC+8)", + usage: 70, + limit: 100, + percent: 70, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["General · 5h", "General · Weekly", "Video"]) + #expect(model.metrics.map(\.percent) == [4, 1, 70]) + } + + @Test + func `minimax unlimited quota rows omit usage copy and warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 2, + limit: 200, + percent: 2, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 0, + limit: 0, + percent: 0, + isUnlimited: true, + resetsAt: nil, + resetDescription: "Unlimited"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics[1].title == "General · Weekly") + #expect(model.metrics[1].statusText == "∞ Unlimited") + #expect(model.metrics[1].detailLeftText == nil) + #expect(model.metrics[1].warningMarkerPercents == []) + } } diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 1313c24e9d..be79cc1dd7 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -124,6 +124,18 @@ struct MiniMaxCookieHeaderTests { #expect(override?.authorizationToken == "token-abc") #expect(override?.groupID == "98765") } + + @Test + func `extracts group ID from combo curl header and cookie`() { + let raw = """ + curl 'https://www.minimaxi.com/v1/api/openplatform/charge/combo/cycle_audio_resource_package' \ + -b 'foo=bar; minimax_group_id_v2=2013894056999916075' \ + -H 'x-group-id: 2013894056999916075' + """ + let override = MiniMaxCookieHeader.override(from: raw) + #expect(override?.cookieHeader == "foo=bar; minimax_group_id_v2=2013894056999916075") + #expect(override?.groupID == "2013894056999916075") + } } struct MiniMaxUsageParserTests { @@ -1009,6 +1021,24 @@ struct MiniMaxAPIRegionTests { #expect(codingPlan.query == "cycle_type=3") } + @Test + func `resolves web remains fallback hosts`() { + let global = MiniMaxUsageFetcher.resolveRemainsURLs(region: .global, environment: [:]) + let china = MiniMaxUsageFetcher.resolveRemainsURLs(region: .chinaMainland, environment: [:]) + + #expect(global.map(\.host).contains("platform.minimax.io")) + #expect(global.map(\.host).contains("www.minimax.io")) + #expect(china.map(\.host).contains("platform.minimaxi.com")) + #expect(china.map(\.host).contains("www.minimaxi.com")) + } + + @Test + func `resolves official token plan remains URL`() { + let url = MiniMaxUsageFetcher.resolveTokenPlanRemainsURL(region: .chinaMainland) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + } + @Test func `host override wins for remains and coding plan`() { let env = [MiniMaxSettingsReader.hostKey: "api.minimaxi.com"] diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift new file mode 100644 index 0000000000..899d60c0b6 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -0,0 +1,741 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MiniMaxTokenPlanChangeTests { + @Test + func `parses percent based general token plan remains`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(Self.percentBasedRemainsJSON.utf8), + now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.availablePrompts == nil) + #expect(snapshot.currentPrompts == nil) + #expect(snapshot.remainingPrompts == nil) + #expect(snapshot.usedPercent == 4) + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 4) + #expect(services[0].limit == 100) + #expect(services[0].percent == 4) + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 1) + #expect(services[1].limit == 100) + #expect(services[1].percent == 1) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + } + + @Test + func `zero count fields do not suppress percent based quota windows`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "data": { + "current_subscribe_title": "Token Plan · TokenPlanPlus-年度会员", + "points_balance": "14000", + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": "96", + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": "99", + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Token Plan · TokenPlanPlus-年度会员") + #expect(snapshot.pointsBalance == 14000) + #expect(snapshot.services?.count == 2) + #expect(snapshot.toUsageSnapshot().providerCost?.used == 14000) + } + + @Test + func `video first token plan still uses general quota as primary and weekly secondary`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "model_remains": [ + { + "model_name": "video", + "current_interval_total_count": 100, + "current_interval_usage_count": 70, + "current_interval_remaining_percent": 30, + "start_time": 1780243200000, + "end_time": 1780329600000 + }, + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 96, + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": 99, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.services?.map(\.serviceType) == ["video", "general", "general"]) + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 70) + } + + @Test + func `plus token plan omits unavailable video quota lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + }, + { + "start_time": 1780243200000, + "end_time": 1780329600000, + "remains_time": 49059830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.planName == "Plus") + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "Plus") + #expect(services.map(\.serviceType) == ["general", "general"]) + #expect(services.map(\.displayName) == ["General", "General"]) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().tertiary == nil) + } + + @Test + func `plus token plan renders boosted interval and unlimited weekly lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_347_620) + let json = """ + { + "model_remains": [ + { + "start_time": 1780347600000, + "end_time": 1780365600000, + "remains_time": 4650822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 1, + "current_interval_remaining_percent": 99, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100, + "interval_boost_permill": 2000, + "weekly_boost_permill": 2000 + }, + { + "start_time": 1780329600000, + "end_time": 1780416000000, + "remains_time": 55050822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 2) + #expect(services[0].limit == 200) + #expect(services[0].percent == 1) + #expect(services[0].isUnlimited == false) + #expect(services[1].serviceType == "general") + #expect(services[1].displayName == "General") + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 0) + #expect(services[1].limit == 0) + #expect(services[1].percent == 0) + #expect(services[1].isUnlimited) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "Unlimited") + } + + @Test + func `web usage fetch falls back to www remains host after platform parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: "not json", contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { + $0.url?.host == "platform.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + #expect(requests.contains { + $0.url?.host == "www.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + } + + @Test + func `web usage fetch falls back to www remains host after platform transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + throw URLError(.timedOut) + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "platform.minimaxi.com", + "platform.minimaxi.com", + "www.minimaxi.com", + ]) + } + + @Test + func `web usage fetch preserves coding plan json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path.contains("coding-plan")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.count == 1) + } + + @Test + func `web usage fetch preserves remains json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + #expect(url.path.contains("coding_plan/remains")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch uses official token plan remains endpoint`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-cp-test") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + now: now, + session: transport) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official auth failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + throw URLError(.timedOut) + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch rejects after official and legacy endpoint auth failures`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect([ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ].contains(url.path)) + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + session: transport) + } + let requests = await transport.requests() + + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `combo metadata parser extracts token plan subscription label`() throws { + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(Self.comboMetadataJSON.utf8)) + #expect(metadata.planName == "TokenPlanMax-年度会员") + #expect(metadata.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(metadata.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + } + + @Test + func `combo metadata parser prefers current subscription over package catalog`() throws { + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanUltra-年度会员" + }, + "packages": [ + { "resource_package_name": "TokenPlanPlus" }, + { "resource_package_name": "TokenPlanMax" }, + { "resource_package_name": "TokenPlanUltra" } + ] + } + } + """ + + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(json.utf8)) + + #expect(metadata.planName == "TokenPlanUltra-年度会员") + } + + @Test + func `web usage fetch merges combo subscription metadata`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/charge/combo/cycle_audio_resource_package") + #expect(url.query?.contains("biz_line=2") == true) + #expect(request.value(forHTTPHeaderField: "x-group-id") == "2013894056999916075") + #expect(request.value(forHTTPHeaderField: "origin") == "https://platform.minimaxi.com") + return Self.httpResponse(url: url, body: Self.comboMetadataJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc; minimax_group_id_v2=2013894056999916075", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.planName == "TokenPlanMax-年度会员") + #expect(snapshot.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.toUsageSnapshot().subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { $0.url?.path.contains("cycle_audio_resource_package") == true }) + } + + @Test + func `combo metadata rejects non https host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.self) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "http://metadata.test"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `combo metadata rejects malformed host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.self) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "bad host"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `web usage fetch preserves combo metadata cancellation`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + } + } + + @Test + func `combo metadata failure does not block quota rendering`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + #expect(snapshot.planName == nil) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + private static let comboMetadataJSON = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanMax-年度会员", + "current_subscribe_end_time": "05/19/2027", + "renewal_date": "05/18/2027", + "current_subscribe_end_time_ts": 1810656000000, + "renewal_trigger_time_ts": 1810569600000 + }, + "packages": [ + { + "resource_package_name": "TokenPlanMax", + "display_name": "Token Plan · TokenPlanMax-年度会员" + } + ] + } + } + """ + + private static let percentBasedRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift index 7cdbf152fb..559bb19280 100644 --- a/Tests/CodexBarTests/ResetTimeBackfillTests.swift +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -76,6 +76,8 @@ final class ResetTimeBackfillTests: XCTestCase { secondary: nil, extraRateWindows: [extra], cursorRequests: CursorRequestUsage(used: 10, limit: 50), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) @@ -87,6 +89,8 @@ final class ResetTimeBackfillTests: XCTestCase { XCTAssertEqual(result.extraRateWindows?.first?.id, "overflow") XCTAssertEqual(result.extraRateWindows?.first?.window.nextRegenPercent, 2) XCTAssertEqual(result.cursorRequests?.used, 10) + XCTAssertEqual(result.subscriptionExpiresAt, reset.addingTimeInterval(86400)) + XCTAssertEqual(result.subscriptionRenewsAt, reset.addingTimeInterval(43200)) XCTAssertEqual(result.identity?.accountEmail, "peter@example.com") } diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 44a3e3e403..fc68cba5b6 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -74,6 +74,31 @@ struct SettingsStoreCoverageTests { #expect(settings.resetTimeDisplayStyle == .absolute) } + @Test + func `minimax settings snapshot uses selected token account as manual cookie`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-token-account") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + settings.addTokenAccount(provider: .minimax, label: "account", token: "HERTZ-SESSION=selected") + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=selected") + } + + @Test + func `minimax settings snapshot falls back to global cookie without token accounts`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-global-cookie") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .auto) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=global") + } + @Test func `multi account menu layout persists and bridges legacy show all token accounts`() throws { let suite = "SettingsStoreCoverageTests-multi-account-layout" diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index cf31c60254..9695f90e9e 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -741,7 +741,7 @@ extension StatusMenuTests { let menuKey = ObjectIdentifier(menu) controller.openMenus[menuKey] = menu controller.menuRefreshEnabledOverrideForTesting = true - controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 50_000_000 + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 0 defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } var rebuildCount = 0 @@ -749,18 +749,50 @@ extension StatusMenuTests { rebuildCount += 1 } defer { controller._test_openMenuRebuildObserver = nil } + var refreshGateEntries = 0 + var pendingRefreshGates: [CheckedContinuation] = [] + func resumePendingRefreshGates() { + let gates = pendingRefreshGates + pendingRefreshGates.removeAll(keepingCapacity: true) + for gate in gates { + gate.resume() + } + } + controller._test_openMenuRefreshYieldOverride = { + refreshGateEntries += 1 + await withCheckedContinuation { continuation in + pendingRefreshGates.append(continuation) + } + } + defer { + resumePendingRefreshGates() + controller._test_openMenuRefreshYieldOverride = nil + } controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) - try? await Task.sleep(nanoseconds: 10_000_000) + for _ in 0..<20 where refreshGateEntries == 0 { + await Task.yield() + } + #expect(refreshGateEntries == 1) + #expect(rebuildCount == 0) + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + resumePendingRefreshGates() + for _ in 0..<20 where refreshGateEntries < 2 { + await Task.yield() + } + #expect(refreshGateEntries == 2) + #expect(rebuildCount == 0) + resumePendingRefreshGates() - for _ in 0..<40 where rebuildCount == 0 { + for _ in 0..<20 where rebuildCount == 0 { await Task.yield() - try? await Task.sleep(nanoseconds: 5_000_000) } #expect(rebuildCount == 1) - try? await Task.sleep(nanoseconds: 75_000_000) + for _ in 0..<20 { + await Task.yield() + } #expect(rebuildCount == 1) } diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 14dfe6392c..148ef24246 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -974,6 +974,8 @@ extension TokenAccountEnvironmentPrecedenceTests { rateLimit: nil, updatedAt: now), cursorRequests: CursorRequestUsage(used: 7, limit: 70), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) } @@ -993,6 +995,8 @@ extension TokenAccountEnvironmentPrecedenceTests { #expect(after.openRouterUsage?.rateLimit?.requests == before.openRouterUsage?.rateLimit?.requests) #expect(after.cursorRequests?.used == before.cursorRequests?.used) #expect(after.cursorRequests?.limit == before.cursorRequests?.limit) + #expect(after.subscriptionExpiresAt == before.subscriptionExpiresAt) + #expect(after.subscriptionRenewsAt == before.subscriptionRenewsAt) #expect(after.updatedAt == before.updatedAt) } } diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 1798b8eb4f..6199b162c3 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -510,6 +510,35 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.map(\.event.window) == [.weekly]) } + @Test + func `minimax quota warning posts for session and weekly windows`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-minimax") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 40, weeklyUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 55, weeklyUsed: 55)) + + #expect(notifier.quotaWarningPosts.map(\.provider) == [.minimax, .minimax]) + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + @Test func `disabling quota warning window clears fired state`() { let settings = self @@ -550,4 +579,37 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.count == 1) #expect(store.quotaWarningState[UsageStore.QuotaWarningStateKey(provider: .codex, window: .session)] == nil) } + + private func minimaxSnapshot(sessionUsed: Double, weeklyUsed: Double) -> UsageSnapshot { + let now = Date() + return MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: Int(sessionUsed), + limit: 100, + percent: sessionUsed, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: Int(weeklyUsed), + limit: 100, + percent: weeklyUsed, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]).toUsageSnapshot() + } }