diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5f685af23..53d180b4f 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -935,6 +935,13 @@ extension UsageMenuCardView.Model { if input.provider == .antigravity { return Self.antigravityMetrics(input: input, snapshot: snapshot) } + if input.provider == .minimax { + if let minimaxUsage = snapshot.minimaxUsage { + if let services = minimaxUsage.services, !services.isEmpty { + return Self.minimaxMetrics(services: services, input: input) + } + } + } var metrics: [Metric] = [] let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil @@ -1095,6 +1102,42 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle), ] } + + private static func minimaxMetrics(services: [MiniMaxServiceUsage], input: Input) -> [Metric] { + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left + var metrics: [Metric] = [] + + for (index, service) in services.enumerated() { + let id = "minimax-service-\(index)" + let title = service.displayName + let used = service.limit - service.usage // usage is remaining, calculate used + let remaining = service.usage // service.usage is remaining quota + let percent = service.percent // This is used percent + + // Adjust display based on usageBarsShowUsed setting + let displayValue: Int = input.usageBarsShowUsed ? used : remaining + let detailText = "\(displayValue)/\(service.limit)" + + // Adjust percentage display - BOTH for progress bar AND text + let displayPercent = input.usageBarsShowUsed ? percent : (100 - percent) + let detailLeftText = service.windowType + let detailRightText = String(format: "%.0f%%", displayPercent) + + metrics.append(Metric( + id: id, + title: title, + percent: Self.clamped(displayPercent), // Use displayPercent for progress bar too + percentStyle: percentStyle, + resetText: service.resetDescription, + detailText: detailText, + detailLeftText: detailLeftText, + detailRightText: detailRightText, + pacePercent: nil, + paceOnTop: true)) + } + + return metrics + } private static func antigravityMetric( id: String, diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift index 6b7432edc..40be5929f 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift @@ -25,7 +25,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { - .minimax(context.settings.minimaxSettingsSnapshot(tokenOverride: context.tokenOverride)) + .minimax(context.settings.minimaxSettingsSnapshot()) } @MainActor @@ -65,7 +65,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { source: context.settings.minimaxCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies and local storage tokens.", - manual: "Paste a Cookie header or cURL capture from the Coding Plan page.", + manual: "Paste a Cookie header or cURL capture from the Token Plan page.", off: "MiniMax cookies are disabled.") } @@ -122,7 +122,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard", - title: "Open Coding Plan", + title: "Open Token Plan", style: .link, isVisible: nil, perform: { @@ -141,7 +141,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { actions: [ ProviderSettingsActionDescriptor( id: "minimax-open-dashboard-cookie", - title: "Open Coding Plan", + title: "Open Token Plan", style: .link, isVisible: nil, perform: { diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift index e10b621a5..e1fb14f08 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxSettingsStore.swift @@ -24,16 +24,6 @@ extension SettingsStore { } } - var minimaxAPIToken: String { - get { self.configSnapshot.providerConfig(for: .minimax)?.sanitizedAPIKey ?? "" } - set { - self.updateProviderConfig(provider: .minimax) { entry in - entry.apiKey = self.normalizedConfigValue(newValue) - } - self.logSecretUpdate(provider: .minimax, field: "apiKey", value: newValue) - } - } - var minimaxCookieSource: ProviderCookieSource { get { self.resolvedCookieSource(provider: .minimax, fallback: .auto) } set { @@ -44,53 +34,49 @@ extension SettingsStore { } } - func ensureMiniMaxCookieLoaded() {} - - func ensureMiniMaxAPITokenLoaded() {} - - func minimaxAuthMode( - environment: [String: String] = ProcessInfo.processInfo.environment) -> MiniMaxAuthMode - { - let apiToken = MiniMaxAPISettingsReader.apiToken(environment: environment) ?? self.minimaxAPIToken - let cookieHeader = MiniMaxSettingsReader.cookieHeader(environment: environment) ?? self.minimaxCookieHeader - return MiniMaxAuthMode.resolve(apiToken: apiToken, cookieHeader: cookieHeader) + var minimaxAPIToken: String { + get { self.configSnapshot.providerConfig(for: .minimax)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .minimax) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + let hasToken = !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if hasToken, + let metadata = ProviderDescriptorRegistry.metadata[.minimax], + !self.isProviderEnabled(provider: .minimax, metadata: metadata) + { + self.setProviderEnabled(provider: .minimax, metadata: metadata, enabled: true) + } + self.logSecretUpdate(provider: .minimax, field: "apiKey", value: newValue) + } } } extension SettingsStore { - func minimaxSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot - .MiniMaxProviderSettings { + func minimaxSettingsSnapshot() -> ProviderSettingsSnapshot.MiniMaxProviderSettings { ProviderSettingsSnapshot.MiniMaxProviderSettings( - cookieSource: self.minimaxSnapshotCookieSource(tokenOverride: tokenOverride), - manualCookieHeader: self.minimaxSnapshotCookieHeader(tokenOverride: tokenOverride), + cookieSource: self.minimaxCookieSource, + manualCookieHeader: self.minimaxCookieHeader, apiRegion: self.minimaxAPIRegion) } +} - private func minimaxSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String { - let fallback = self.minimaxCookieHeader - guard let support = TokenAccountSupportCatalog.support(for: .minimax), - case .cookieHeader = support.injection - else { - return fallback - } - guard let account = ProviderTokenAccountSelection.selectedAccount( - provider: .minimax, - settings: self, - override: tokenOverride) - else { - return fallback - } - return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) +extension SettingsStore { + func ensureMiniMaxCookieLoaded() { + // Cookie loading handled by MiniMaxCookieImporter } +} - private func minimaxSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource { - let fallback = self.minimaxCookieSource - guard let support = TokenAccountSupportCatalog.support(for: .minimax), - support.requiresManualCookieSource - else { - return fallback - } - if self.tokenAccounts(for: .minimax).isEmpty { return fallback } - return .manual +extension SettingsStore { + func ensureMiniMaxAPITokenLoaded() { + // Token loading handled by MiniMaxAPITokenStore + } +} + +extension SettingsStore { + func minimaxAuthMode() -> MiniMaxAuthMode { + MiniMaxAuthMode.resolve( + apiToken: self.minimaxAPIToken, + cookieHeader: self.minimaxCookieHeader) } } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index ad65b0879..4cc4521ad 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -43,6 +43,9 @@ extension StatusItemController { if provider == .alibaba { return self.settings.alibabaCodingPlanAPIRegion.dashboardURL } + if provider == .minimax { + return self.settings.minimaxAPIRegion.dashboardURL + } let meta = self.store.metadata(for: provider) let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index 8428f81f7..ea7f1dd61 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -55,4 +55,11 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { public var apiRemainsURL: URL { URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.remainsPath) } + + public var dashboardURL: URL { + var components = URLComponents(string: self.baseURLString)! + components.path = "/" + Self.codingPlanPath + components.query = Self.codingPlanQuery + return components.url! + } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift new file mode 100644 index 000000000..95ac76e9b --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift @@ -0,0 +1,190 @@ +// +// MiniMaxServiceUsage.swift +// CodexBarCore +// +// Created by Sisyphus on 2026-03-25. +// + +import Foundation + +/// Represents the usage information for a specific MiniMax service. +/// +/// This struct encapsulates all the relevant details about how much of a particular +/// MiniMax service has been used within its quota window, including reset timing +/// and localized display strings. +public struct MiniMaxServiceUsage: Sendable { + /// The service identifier (e.g., "text-generation", "text-to-speech", "image") + public let serviceType: String + + /// The type of time window for the quota (e.g., "5 hours" or "Today") + /// This should be a localized string. + public let windowType: String + + /// The specific time range for the current quota window. + /// For hourly quotas: "10:00-15:00(UTC+8)" + /// For daily quotas: full date range string + public let timeRange: String + + /// The amount of quota that has been used + public let usage: Int + + /// The total quota limit for this service in the current window + public let limit: Int + + /// The percentage of quota used (0-100) + public let percent: Double + + /// The timestamp when the quota will reset, if available + public let resetsAt: Date? + + /// A localized description of when the quota resets (e.g., "Resets in 2 hours 30 minutes") + public let resetDescription: String + + /// The remaining quota available (limit - usage) + public var remaining: Int { + return limit - usage + } + + /// The display name for this service + public var displayName: String { + switch serviceType { + case "text-generation": + return "Text Generation" + case "text-to-speech": + return "Text to Speech" + case "image": + return "Image" + default: + return serviceType + } + } + + /// Creates a new MiniMaxServiceUsage instance. + /// + /// - Parameters: + /// - serviceType: The service identifier + /// - windowType: The type of time window (localized) + /// - timeRange: The specific time range string + /// - usage: The amount of quota used + /// - limit: The total quota limit + /// - percent: The percentage used (0-100) + /// - resetsAt: Optional reset timestamp + /// - resetDescription: Localized reset description + public init( + serviceType: String, + windowType: String, + timeRange: String, + usage: Int, + limit: Int, + percent: Double, + resetsAt: Date?, + resetDescription: String + ) { + self.serviceType = serviceType + self.windowType = windowType + self.timeRange = timeRange + self.usage = usage + self.limit = limit + self.percent = percent + self.resetsAt = resetsAt + self.resetDescription = resetDescription + } +} + +extension MiniMaxServiceUsage { + public static func parseWindowType(_ windowType: String) -> (windowType: String, windowMinutes: Int?) { + switch windowType.lowercased() { + case "5 hours", "5 小时": + return ("5 hours", 300) + case "today", "今日": + return ("Today", 1440) + default: + // Try to extract hours from string like "X hours" + if let hours = Int(windowType.components(separatedBy: .whitespaces).first ?? "") { + return (windowType, hours * 60) + } + return (windowType, nil) + } + } + + public static func parseTimeRange(_ timeRange: String, now: Date) -> Date? { + let calendar = Calendar.current + + // Handle "10:00-15:00(UTC+8)" format + if timeRange.contains("-") && timeRange.contains("(") && timeRange.contains(")") { + // Extract the time part before the timezone + let components = timeRange.split(separator: "(") + guard components.count >= 1 else { return nil } + let timePart = String(components[0]).trimmingCharacters(in: .whitespaces) + + // Split by "-" to get start and end times + let timeComponents = timePart.split(separator: "-") + guard timeComponents.count == 2 else { return nil } + + let endTimeStr = String(timeComponents[1]).trimmingCharacters(in: .whitespaces) + + // Parse end time (HH:mm format) + let timeFormatter = DateFormatter() + timeFormatter.dateFormat = "HH:mm" + timeFormatter.timeZone = TimeZone.current + + guard let endTime = timeFormatter.date(from: endTimeStr) else { return nil } + + // Get today's date components + let nowComponents = calendar.dateComponents([.year, .month, .day], from: now) + let endTimeComponents = calendar.dateComponents([.hour, .minute], from: endTime) + + // Combine today's date with end time + var combinedComponents = DateComponents() + combinedComponents.year = nowComponents.year + combinedComponents.month = nowComponents.month + combinedComponents.day = nowComponents.day + combinedComponents.hour = endTimeComponents.hour + combinedComponents.minute = endTimeComponents.minute + + guard let resultDate = calendar.date(from: combinedComponents) else { return nil } + + // If the result date is in the past (before now), add one day + if resultDate < now { + return calendar.date(byAdding: .day, value: 1, to: resultDate) + } + + return resultDate + } + + // Handle "2026/03/25 00:00 - 2026/03/26 00:00" format + if timeRange.contains(" - ") { + let dateComponents = timeRange.split(separator: " - ") + guard dateComponents.count == 2 else { return nil } + + let endDateStr = String(dateComponents[1]).trimmingCharacters(in: .whitespaces) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy/MM/dd HH:mm" + dateFormatter.timeZone = TimeZone.current + + return dateFormatter.date(from: endDateStr) + } + + return nil + } + + public static func generateResetDescription(resetsAt: Date, now: Date = Date()) -> String { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute], from: now, to: resetsAt) + + guard let hours = components.hour, let minutes = components.minute else { + return "Resets soon" + } + + if hours > 0 && minutes > 0 { + return "Resets in \(hours) hours \(minutes) minutes" + } else if hours > 0 { + return "Resets in \(hours) hour\(hours > 1 ? "s" : "")" + } else if minutes > 0 { + return "Resets in \(minutes) minute\(minutes > 1 ? "s" : "")" + } else { + return "Resets now" + } + } +} \ No newline at end of file diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 93edc6d6c..65664f1ef 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -4,7 +4,7 @@ import FoundationNetworking #endif public struct MiniMaxUsageFetcher: Sendable { - private static let log = CodexBarLog.logger(LogCategories.minimaxUsage) + static let log = CodexBarLog.logger(LogCategories.minimaxUsage) 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" @@ -106,7 +106,11 @@ public struct MiniMaxUsageFetcher: Sendable { throw MiniMaxUsageError.apiError("HTTP \(httpResponse.statusCode)") } - return try MiniMaxUsageParser.parseCodingPlanRemains(data: data, now: now) + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: data, now: now) + if let services = snapshot.services, !services.isEmpty { + Self.log.debug("MiniMax multi-service response detected: \(services.count) services") + } + return snapshot } private static func fetchCodingPlanHTML( @@ -153,7 +157,11 @@ public struct MiniMaxUsageFetcher: Sendable { if let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { - return try MiniMaxUsageParser.parseCodingPlanRemains(data: data, now: now) + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: data, now: now) + if let services = snapshot.services, !services.isEmpty { + Self.log.debug("MiniMax multi-service response detected: \(services.count) services") + } + return snapshot } let html = String(data: data, encoding: .utf8) ?? "" @@ -214,7 +222,11 @@ public struct MiniMaxUsageFetcher: Sendable { { let payload = try MiniMaxUsageParser.decodePayload(data: data) self.logCodingPlanStatus(payload: payload) - return try MiniMaxUsageParser.parseCodingPlanRemains(payload: payload, now: now) + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(payload: payload, now: now) + if let services = snapshot.services, !services.isEmpty { + Self.log.debug("MiniMax multi-service response detected: \(services.count) services") + } + return snapshot } let html = String(data: data, encoding: .utf8) ?? "" @@ -382,27 +394,45 @@ struct MiniMaxComboCard: Decodable { } 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) } } @@ -422,6 +452,52 @@ struct MiniMaxBaseResponse: Decodable { } } +// MARK: - Multi-Service API Response Structures + +struct MiniMaxMultiServicePayload: Decodable { + let data: MiniMaxMultiServiceData +} + +struct MiniMaxMultiServiceData: Decodable { + let services: [MiniMaxServiceItem] +} + +struct MiniMaxServiceItem: Decodable { + let serviceType: String? + let windowType: String? + let timeRange: String? + let usage: Int? + let limit: Int? + let percent: Double? + + private enum CodingKeys: String, CodingKey { + case serviceType = "service_type" + case windowType = "window_type" + case timeRange = "time_range" + case usage + case limit + case percent + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.serviceType = try container.decodeIfPresent(String.self, forKey: .serviceType) + self.windowType = try container.decodeIfPresent(String.self, forKey: .windowType) + self.timeRange = try container.decodeIfPresent(String.self, forKey: .timeRange) + self.usage = MiniMaxDecoding.decodeInt(container, forKey: .usage) + self.limit = MiniMaxDecoding.decodeInt(container, forKey: .limit) + // Handle both Double and String for percent (flexible parsing) + if let percentDouble = try? container.decodeIfPresent(Double.self, forKey: .percent) { + self.percent = percentDouble + } else if let percentString = try? container.decodeIfPresent(String.self, forKey: .percent), + let percentValue = Double(percentString) { + self.percent = percentValue + } else { + self.percent = nil + } + } +} + enum MiniMaxDecoding { static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { if let value = try? container.decodeIfPresent(Int.self, forKey: key) { @@ -447,6 +523,11 @@ enum MiniMaxUsageParser { return try decoder.decode(MiniMaxCodingPlanPayload.self, from: data) } + static func decodeMultiServicePayload(data: Data) throws -> MiniMaxMultiServicePayload { + let decoder = JSONDecoder() + return try decoder.decode(MiniMaxMultiServicePayload.self, from: data) + } + static func decodePayload(json: [String: Any]) throws -> MiniMaxCodingPlanPayload { let normalized = self.normalizeCodingPlanPayload(json) let data = try JSONSerialization.data(withJSONObject: normalized, options: []) @@ -454,6 +535,15 @@ enum MiniMaxUsageParser { } static func parseCodingPlanRemains(data: Data, now: Date = Date()) throws -> MiniMaxUsageSnapshot { + do { + if let multiServiceSnapshot = try self.parseMultiService(data: data, now: now) { + return multiServiceSnapshot + } + } catch { + // Log multi-service parsing failure but continue to single-service parsing + MiniMaxUsageFetcher.log.debug("MiniMax multi-service parsing failed: \(error.localizedDescription)") + } + let payload = try self.decodePayload(data: data) return try self.parseCodingPlanRemains(payload: payload, now: now) } @@ -498,29 +588,76 @@ enum MiniMaxUsageParser { throw MiniMaxUsageError.apiError(message) } - guard let first = payload.data.modelRemains.first else { + guard !payload.data.modelRemains.isEmpty else { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } - let total = first.currentIntervalTotalCount - let remaining = first.currentIntervalUsageCount + // Convert model_remains to services array for multi-service UI display + var services: [MiniMaxServiceUsage] = [] + for item in payload.data.modelRemains { + // Skip services with no quota (limit = 0) + guard let modelName = item.modelName, + let limit = item.currentIntervalTotalCount, + limit > 0, + let remaining = item.currentIntervalUsageCount + else { + continue + } + + // Calculate usage and percentage + // current_interval_usage_count is REMAINING quota (not used) + let used = max(0, limit - remaining) + let percent = limit > 0 ? Double(used) / Double(limit) * 100.0 : 0.0 + + // Parse time window + let startTime = self.dateFromEpoch(item.startTime) + let endTime = self.dateFromEpoch(item.endTime) + let windowMinutes = self.windowMinutes(start: startTime, end: endTime) + + // Determine window type and time range + let (windowType, timeRange) = self.parseWindowInfo( + startTime: startTime, + endTime: endTime, + remainsSeconds: item.remainsTime, + now: now) + + // Calculate reset time + let resetsAt = self.resetsAt(end: endTime, remains: item.remainsTime, now: now) + let resetDescription = self.resetDescription(for: windowType, timeRange: timeRange, now: now, resetsAt: resetsAt) + + // Map model_name to service type identifier + let serviceTypeIdentifier = self.mapModelNameToServiceType(modelName: modelName) + + let serviceUsage = MiniMaxServiceUsage( + serviceType: serviceTypeIdentifier, + windowType: windowType, + timeRange: timeRange, + usage: remaining, + limit: limit, + percent: min(100.0, max(0.0, percent)), + resetsAt: resetsAt, + resetDescription: resetDescription + ) + services.append(serviceUsage) + } + + // 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 windowMinutes = self.windowMinutes( - start: self.dateFromEpoch(first.startTime), - end: self.dateFromEpoch(first.endTime)) + start: self.dateFromEpoch(first?.startTime), + end: self.dateFromEpoch(first?.endTime)) let resetsAt = self.resetsAt( - end: self.dateFromEpoch(first.endTime), - remains: first.remainsTime, + end: self.dateFromEpoch(first?.endTime), + remains: first?.remainsTime, now: now) let planName = self.parsePlanName(data: payload.data) - if planName == nil, total == nil, usedPercent == nil { - throw MiniMaxUsageError.parseFailed("Missing coding plan data.") - } - let currentPrompts: Int? = if let total, let remaining { max(0, total - remaining) } else { @@ -535,7 +672,8 @@ enum MiniMaxUsageParser { windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + services: services.isEmpty ? nil : services) } private static func usedPercent(total: Int?, remaining: Int?) -> Double? { @@ -855,6 +993,212 @@ enum MiniMaxUsageParser { return String(text[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) } } + + // MARK: - Multi-Service Parsing + + private static func parseMultiService(data: Data, now: Date) throws -> MiniMaxUsageSnapshot? { + let payload = try self.decodeMultiServicePayload(data: data) + + guard !payload.data.services.isEmpty else { + return nil + } + + var services: [MiniMaxServiceUsage] = [] + for item in payload.data.services { + guard let serviceType = item.serviceType, + let windowType = item.windowType, + let timeRange = item.timeRange, + let usage = item.usage, + let limit = item.limit else { + continue + } + + var percent = item.percent ?? 0.0 + if item.percent == nil && limit > 0 { + percent = Double(usage) / Double(limit) * 100.0 + } + + let resetsAt = self.parseResetsAtFromTimeRange(timeRange: timeRange, windowType: windowType, now: now) + let resetDescription = self.resetDescription(for: windowType, timeRange: timeRange, now: now, resetsAt: resetsAt) + + let serviceTypeIdentifier: String + if serviceType.lowercased().contains("text") && serviceType.lowercased().contains("generation") { + serviceTypeIdentifier = "text-generation" + } else if serviceType.lowercased().contains("text") && serviceType.lowercased().contains("speech") { + serviceTypeIdentifier = "text-to-speech" + } else if serviceType.lowercased().contains("image") { + serviceTypeIdentifier = "image" + } else { + serviceTypeIdentifier = serviceType.lowercased() + .replacingOccurrences(of: " ", with: "-") + .replacingOccurrences(of: "_", with: "-") + } + + let serviceUsage = MiniMaxServiceUsage( + serviceType: serviceTypeIdentifier, + windowType: windowType, + timeRange: timeRange, + usage: usage, + limit: limit, + percent: min(100.0, max(0.0, percent)), + resetsAt: resetsAt, + resetDescription: resetDescription + ) + services.append(serviceUsage) + } + + if services.isEmpty { + return nil + } + + let planName = self.extractPlanNameFromServices(services: payload.data.services) + + return MiniMaxUsageSnapshot( + planName: planName, + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: services + ) + } + + private static func parseResetsAtFromTimeRange(timeRange: String, windowType: String, now: Date) -> Date? { + let lowerWindow = windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + if lowerWindow == "today" { + let components = timeRange.split(separator: "-", maxSplits: 1) + guard components.count == 2 else { return nil } + + let endTimeStr = String(components[1].trimmingCharacters(in: .whitespacesAndNewlines)) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + + return formatter.date(from: endTimeStr) + } + + if lowerWindow.contains("hour") || lowerWindow.contains("h") { + let timeComponents = timeRange.split(separator: "-") + guard timeComponents.count >= 2 else { return nil } + + let endTimePart = String(timeComponents[1]) + let endTimeClean = endTimePart.replacingOccurrences(of: "\\(.*\\)", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + return self.dateForTime(endTimeClean, timeZoneHint: "UTC+8", now: now) + } + + return nil + } + + private static func resetDescription(for windowType: String, timeRange: String, now: Date, resetsAt: Date?) -> String { + if let resetsAt = resetsAt, resetsAt > now { + let interval = resetsAt.timeIntervalSince(now) + if interval < 60 { + return "Resets in \(Int(interval)) seconds" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "Resets in \(minutes) minute\(minutes == 1 ? "" : "s")" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "Resets in \(hours) hour\(hours == 1 ? "" : "s")" + } else { + let days = Int(interval / 86400) + return "Resets in \(days) day\(days == 1 ? "" : "s")" + } + } + + return "\(windowType): \(timeRange)" + } + + private static func extractPlanNameFromServices(services: [MiniMaxServiceItem]) -> String? { + for service in services { + if let serviceType = service.serviceType, + serviceType.lowercased().contains("pro") || serviceType.lowercased().contains("max") { + return serviceType + } + } + + return nil + } + + private static func parseWindowInfo( + startTime: Date?, + endTime: Date?, + remainsSeconds: Int?, + now: Date) -> (windowType: String, timeRange: String) { + guard let startTime, let endTime, let remains = remainsSeconds else { + return (windowType: "Unknown", timeRange: "N/A") + } + + let durationSeconds = endTime.timeIntervalSince(startTime) + let durationHours = durationSeconds / 3600 + + // Determine window type based on duration + let windowType: String + if durationHours >= 23 && durationHours <= 25 { + windowType = "Today" + } else if durationHours >= 4 && durationHours <= 6 { + windowType = "5 hours" + } else if durationHours >= 1 && durationHours < 23 { + windowType = "\(Int(durationHours)) hours" + } else { + windowType = "Custom" + } + + // Format time range + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone.current + let startStr = formatter.string(from: startTime) + let endStr = formatter.string(from: endTime) + + let timeRange = "\(startStr)-\(endStr)(UTC+8)" + + return (windowType: windowType, timeRange: timeRange) + } + + private static func mapModelNameToServiceType(modelName: String) -> String { + let lower = modelName.lowercased() + + // Text Generation (文本生成): M2.7, M2.7-highspeed, MiniMax-M*, etc. + if lower.contains("minimax-m") { + return "Text Generation" + } + + // Text to Speech (语音合成): speech-hd, Speech 2.8, etc. + if lower.contains("speech") { + return "Text to Speech" + } + + // Image to Video Fast (图生视频 Fast): Hailuo-2.3-Fast + if lower.contains("hailuo") && lower.contains("fast") { + return "Image to Video" + } + + // Text to Video (文生视频): Hailuo-2.3 (non-Fast) + if lower.contains("hailuo") { + return "Text to Video" + } + + // Image Generation (图像生成): image-01, image-02, etc. + if lower.hasPrefix("image-") { + return "Image Generation" + } + + // Music Generation (音乐生成): music-2.5, etc. + if lower.contains("music") { + return "Music Generation" + } + + // Default: use model name as-is + return modelName + } } public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 09ed671e2..01dbc4014 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -9,6 +9,40 @@ public struct MiniMaxUsageSnapshot: Sendable { public let usedPercent: Double? public let resetsAt: Date? public let updatedAt: Date + public let services: [MiniMaxServiceUsage]? + + 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 + } + + 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 + 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 } + return services[2] + } public init( planName: String?, @@ -18,7 +52,8 @@ public struct MiniMaxUsageSnapshot: Sendable { windowMinutes: Int?, usedPercent: Double?, resetsAt: Date?, - updatedAt: Date) + updatedAt: Date, + services: [MiniMaxServiceUsage]? = nil) { self.planName = planName self.availablePrompts = availablePrompts @@ -28,11 +63,37 @@ public struct MiniMaxUsageSnapshot: Sendable { self.usedPercent = usedPercent self.resetsAt = resetsAt self.updatedAt = updatedAt + self.services = services } } extension MiniMaxUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { + // If we have services array, use that for multi-service support + if let services = self.services, !services.isEmpty { + let primaryWindow = self.rateWindow(for: self.primaryService) + let secondaryWindow = self.rateWindow(for: self.secondaryService) + let tertiaryWindow = self.rateWindow(for: self.tertiaryService) + + let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (planName?.isEmpty ?? true) ? nil : planName + let identity = ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primaryWindow, + secondary: secondaryWindow, + tertiary: tertiaryWindow, + providerCost: nil, + minimaxUsage: self, + updatedAt: self.updatedAt, + identity: identity) + } + + // Fallback to single-service mode for backward compatibility let used = max(0, min(100, self.usedPercent ?? 0)) let resetDescription = self.limitDescription() let primary = RateWindow( @@ -59,6 +120,16 @@ extension MiniMaxUsageSnapshot { identity: identity) } + private func rateWindow(for service: MiniMaxServiceUsage?) -> RateWindow? { + guard let service else { return nil } + let windowMinutes = self.windowMinutes(for: service) + return RateWindow( + usedPercent: max(0, min(100, service.percent)), + windowMinutes: windowMinutes, + resetsAt: service.resetsAt, + resetDescription: service.resetDescription) + } + private func limitDescription() -> String? { guard let availablePrompts, availablePrompts > 0 else { return self.windowDescription() @@ -82,4 +153,31 @@ extension MiniMaxUsageSnapshot { } return "\(windowMinutes) \(windowMinutes == 1 ? "minute" : "minutes")" } + + private func windowMinutes(for service: MiniMaxServiceUsage) -> Int? { + let windowType = service.windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Handle "Today" case - 24 hours = 1440 minutes + if windowType == "today" { + return 24 * 60 + } + + // Handle time duration formats like "5 hours", "30 minutes", etc. + let components = windowType.split(separator: " ") + guard components.count >= 2 else { return nil } + + guard let value = Int(components[0]) else { return nil } + let unit = components[1].lowercased() + + switch unit { + case "hour", "hours", "h", "hr", "hrs": + return value * 60 + case "minute", "minutes", "min", "mins", "m": + return value + case "day", "days", "d": + return value * 24 * 60 + default: + return nil + } + } }