import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public struct MiniMaxUsageFetcher: Sendable { 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" private static let tokenPlanRemainsPath = "v1/token_plan/remains" private static let billingHistoryPath = "account/amount" private static let billingHistoryLimit = 100 private struct RemainsContext { let authorizationToken: String? let groupID: String? } struct WebFetchContext { let cookie: String let authorizationToken: String? let region: MiniMaxAPIRegion let environment: [String: String] let transport: any ProviderHTTPTransport } public static func fetchUsage( cookieHeader: String, authorizationToken: String? = nil, groupID: String? = nil, region: MiniMaxAPIRegion = .global, environment: [String: String] = ProcessInfo.processInfo.environment, includeBillingHistory: Bool = true, session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: Date = Date()) async throws -> MiniMaxUsageSnapshot { guard let cookie = MiniMaxCookieHeader.normalized(from: cookieHeader) else { throw MiniMaxUsageError.invalidCredentials } if let rejectedKey = MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: environment) { throw ProviderEndpointOverrideError.minimax(rejectedKey) } let context = WebFetchContext( cookie: cookie, authorizationToken: authorizationToken, region: region, environment: environment, transport: transport) do { let htmlSnapshot = try await self.fetchCodingPlanHTML(context: context, now: now) if htmlSnapshot.services?.isEmpty != false { do { Self.log.debug("MiniMax coding plan HTML lacks service quota data, trying remains API") let remainsSnapshot = try await self.fetchCodingPlanRemains( context: context, remainsContext: RemainsContext( authorizationToken: authorizationToken, groupID: groupID), now: now) .withPlanNameIfMissing(htmlSnapshot.planName) let snapshot = try await self.attachingSubscriptionMetadataIfAvailable( to: remainsSnapshot, context: context, groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, includeBillingHistory: includeBillingHistory, now: now) } catch { if self.shouldRethrowAfterHTMLFallback(error) { throw error } Self.log.debug( "MiniMax remains API enrichment failed after HTML fallback: \(error.localizedDescription)") } } let snapshot = try await self.attachingSubscriptionMetadataIfAvailable( to: htmlSnapshot, context: context, groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, includeBillingHistory: includeBillingHistory, now: now) } 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.attachingSubscriptionMetadataIfAvailable( to: self.fetchCodingPlanRemains( context: context, remainsContext: RemainsContext( authorizationToken: authorizationToken, groupID: groupID), now: now), context: context, groupID: groupID) return try await self.attachingBillingIfAvailable( to: snapshot, context: context, includeBillingHistory: includeBillingHistory, now: now) } throw error } } public static func fetchUsage( apiToken: String, region: MiniMaxAPIRegion = .global, now: Date = Date(), session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MiniMaxUsageSnapshot { let cleaned = apiToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleaned.isEmpty else { throw MiniMaxUsageError.invalidCredentials } // Historically, MiniMax API token fetching used a China endpoint by default in some configurations. If the // user has no persisted region and we default to `.global`, retry the China endpoint when the global host // rejects the token so upgrades don't regress existing setups. if region != .global { return try await self.fetchUsageOnce(apiToken: cleaned, region: region, now: now, transport: transport) } do { return try await self.fetchUsageOnce( apiToken: cleaned, region: .global, now: now, transport: transport) } catch let error as MiniMaxUsageError { guard case .invalidCredentials = error else { throw error } Self.log.debug("MiniMax API token rejected for global host, retrying China mainland host") do { return try await self.fetchUsageOnce( apiToken: cleaned, region: .chinaMainland, now: now, transport: transport) } catch { // Preserve the original invalid-credentials error so the fetch pipeline can fall back to web. Self.log.debug("MiniMax China mainland retry failed, preserving global invalidCredentials") throw MiniMaxUsageError.invalidCredentials } } } private static func fetchUsageOnce( apiToken: String, region: MiniMaxAPIRegion, now: Date, transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSnapshot { var lastError: Error? var tokenPlanCredentialFailure = false 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 { if tokenPlanCredentialFailure { throw MiniMaxUsageError.invalidCredentials } throw error } if case .invalidCredentials = error { tokenPlanCredentialFailure = true } 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") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("CodexBar", forHTTPHeaderField: "MM-API-Source") let response: ProviderHTTPResponse do { response = try await transport.response(for: request) } catch { throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { let body = String(data: response.data, encoding: .utf8) ?? "" Self.log.error("MiniMax returned \(response.statusCode): \(body)") if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } let snapshot: MiniMaxUsageSnapshot do { snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: response.data, now: now) } catch let error as MiniMaxUsageError { throw self.normalizedAPITokenError(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 normalizedAPITokenError(_ error: MiniMaxUsageError) -> MiniMaxUsageError { guard case let .apiError(message) = error else { return error } let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalizedMessage == "invalid api key" ? .invalidCredentials : error } 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 { let url = self.resolveCodingPlanURL(region: context.region, environment: context.environment) var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue(context.cookie, forHTTPHeaderField: "Cookie") if let authorizationToken = context.authorizationToken { request.setValue("Bearer \(authorizationToken)", forHTTPHeaderField: "Authorization") } let acceptHeader = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" request.setValue(acceptHeader, forHTTPHeaderField: "accept") let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" request.setValue(userAgent, forHTTPHeaderField: "user-agent") request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") let origin = self.originURL(from: url) request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") request.setValue( self.resolveCodingPlanRefererURL(region: context.region, environment: context.environment).absoluteString, forHTTPHeaderField: "referer") let response: ProviderHTTPResponse do { response = try await context.transport.response(for: request) } catch { throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { let body = String(data: response.data, encoding: .utf8) ?? "" Self.log.error("MiniMax returned \(response.statusCode): \(body)") if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { 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 } let html = String(data: response.data, encoding: .utf8) ?? "" if html.contains("__NEXT_DATA__") { Self.log.debug("MiniMax coding plan HTML contains __NEXT_DATA__") } if self.looksSignedOut(html: html) { throw MiniMaxUsageError.invalidCredentials } return try MiniMaxUsageParser.parse(html: html, now: now) } private static func fetchCodingPlanRemains( context: WebFetchContext, remainsContext: RemainsContext, now: Date) async throws -> MiniMaxUsageSnapshot { 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" request.setValue(context.cookie, forHTTPHeaderField: "Cookie") if let authorizationToken = remainsContext.authorizationToken { request.setValue("Bearer \(authorizationToken)", forHTTPHeaderField: "Authorization") } let acceptHeader = "application/json, text/plain, */*" request.setValue(acceptHeader, forHTTPHeaderField: "accept") request.setValue("XMLHttpRequest", forHTTPHeaderField: "x-requested-with") let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" request.setValue(userAgent, forHTTPHeaderField: "user-agent") request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") let origin = self.originURL(from: baseRemainsURL) request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") request.setValue( self.resolveCodingPlanRefererURL(region: context.region, environment: context.environment).absoluteString, forHTTPHeaderField: "referer") let response: ProviderHTTPResponse do { response = try await context.transport.response(for: request) } catch { throw self.normalizedTransportError(error) } guard response.statusCode == 200 else { let body = String(data: response.data, encoding: .utf8) ?? "" Self.log.error("MiniMax returned \(response.statusCode): \(body)") if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } if let contentType = response.response.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") { 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 } let html = String(data: response.data, encoding: .utf8) ?? "" if self.looksSignedOut(html: html) { throw MiniMaxUsageError.invalidCredentials } 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 shouldRethrowAfterHTMLFallback(_ error: Error) -> Bool { if error is CancellationError { return true } if let urlError = error as? URLError, urlError.code == .cancelled { return true } if let minimaxError = error as? MiniMaxUsageError { if case .invalidCredentials = minimaxError { return true } } if error is ProviderEndpointOverrideError { return true } return false } private static func attachingBillingIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, includeBillingHistory: Bool, now: Date) async throws -> MiniMaxUsageSnapshot { guard includeBillingHistory else { return snapshot } do { let billing = try await self.fetchBillingSummary(context: context, now: now) return snapshot.withBillingSummary(billing) } catch is CancellationError { throw CancellationError() } catch let error as URLError where error.code == .cancelled { throw error } catch let error as MiniMaxUsageError { if case .invalidCredentials = error, context.authorizationToken != nil { throw error } Self.log.debug("MiniMax billing history unavailable: \(error.localizedDescription)") return snapshot } catch { Self.log.debug("MiniMax billing history unavailable: \(error.localizedDescription)") return snapshot } } private static func fetchBillingSummary(context: WebFetchContext, now: Date) async throws -> MiniMaxBillingSummary { var records: [MiniMaxBillingRecord] = [] var totalCount: Int? var page = 1 while true { let url = self.resolveBillingHistoryURL( region: context.region, environment: context.environment, page: page, limit: Self.billingHistoryLimit) let response = try await self.billingHistoryResponse(url: url, context: context) guard response.statusCode == 200 else { let body = String(data: response.data, encoding: .utf8) ?? "" Self.log.debug("MiniMax billing history returned \(response.statusCode): \(body)") if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials } throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } let payload = try MiniMaxBillingHistoryParser.decodePayload(data: response.data) if let status = payload.baseResp?.statusCode, status != 0 { let message = payload.baseResp?.statusMessage ?? "status_code \(status)" throw MiniMaxUsageError.apiError(message) } totalCount = payload.totalCount ?? totalCount guard !payload.chargeRecords.isEmpty else { break } records.append(contentsOf: payload.chargeRecords) if MiniMaxBillingHistoryParser.containsRecordBefore30DayWindow(payload.chargeRecords, now: now) { break } if let totalCount, records.count >= totalCount { break } page += 1 } return MiniMaxBillingHistoryParser.aggregate(records: records, now: now) } private static func billingHistoryResponse( url: URL, context: WebFetchContext) async throws -> ProviderHTTPResponse { var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue(context.cookie, forHTTPHeaderField: "Cookie") if let authorizationToken = context.authorizationToken { request.setValue("Bearer \(authorizationToken)", forHTTPHeaderField: "Authorization") } request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept") request.setValue("XMLHttpRequest", forHTTPHeaderField: "x-requested-with") let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" request.setValue(userAgent, forHTTPHeaderField: "user-agent") request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") let origin = self.originURL(from: url) request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") request.setValue(origin.appendingPathComponent("account").absoluteString, forHTTPHeaderField: "referer") do { return try await context.transport.response(for: request) } catch let error as URLError where error.code == .badServerResponse { throw MiniMaxUsageError.networkError("Invalid response") } catch { throw error } } private static func appendGroupID(_ groupID: String?, to url: URL) -> URL { guard let groupID, !groupID.isEmpty else { return url } guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } var queryItems = components.queryItems ?? [] queryItems.append(URLQueryItem(name: "GroupId", value: groupID)) components.queryItems = queryItems return components.url ?? url } static func originURL(from url: URL) -> URL { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } components.path = "" components.query = nil components.fragment = nil return components.url ?? url } static func resolveCodingPlanURL( region: MiniMaxAPIRegion, environment: [String: String]) -> URL { if let override = MiniMaxSettingsReader.codingPlanURL(environment: environment) { return override } if let host = MiniMaxSettingsReader.hostOverride(environment: environment), let hostURL = self.url(from: host, path: Self.codingPlanPath, query: Self.codingPlanQuery) { return hostURL } return region.codingPlanURL } static func resolveCodingPlanRefererURL( region: MiniMaxAPIRegion, environment: [String: String]) -> URL { if let override = MiniMaxSettingsReader.codingPlanURL(environment: environment) { if var components = URLComponents(url: override, resolvingAgainstBaseURL: false) { components.query = nil return components.url ?? override } return override } if let host = MiniMaxSettingsReader.hostOverride(environment: environment), let hostURL = self.url(from: host, path: Self.codingPlanPath) { return hostURL } return region.codingPlanRefererURL } static func resolveRemainsURL( 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 } 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], page: Int, limit: Int = Self.billingHistoryLimit) -> URL { if let override = MiniMaxSettingsReader.billingHistoryURL(environment: environment) { return self.billingHistoryURL(from: override, page: page, limit: limit) } if let host = MiniMaxSettingsReader.hostOverride(environment: environment), let hostURL = self.url(from: host, path: Self.billingHistoryPath) { return self.billingHistoryURL(from: hostURL, page: page, limit: limit) } return region.billingHistoryURL(page: page, limit: limit) } private static func billingHistoryURL(from url: URL, page: Int, limit: Int) -> URL { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } var items = components.queryItems?.filter { $0.name != "page" && $0.name != "limit" && $0.name != "aggregate" } ?? [] items.append(URLQueryItem(name: "page", value: "\(page)")) items.append(URLQueryItem(name: "limit", value: "\(limit)")) items.append(URLQueryItem(name: "aggregate", value: "false")) components.queryItems = items return components.url ?? url } static func url(from raw: String, path: String? = nil, query: String? = nil) -> URL? { guard let cleaned = MiniMaxSettingsReader.cleaned(raw) else { return nil } func compose(_ base: URL) -> URL? { var components = URLComponents(url: base, resolvingAgainstBaseURL: false)! if let path { components.path = "/" + path } if let query { components.query = query } return components.url } guard let base = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil } return compose(base) } private static func logCodingPlanStatus(payload: MiniMaxCodingPlanPayload) { let baseResponse = payload.data.baseResp ?? payload.baseResp guard let status = baseResponse?.statusCode else { return } let message = baseResponse?.statusMessage ?? "" if !message.isEmpty { Self.log.debug("MiniMax coding plan status \(status): \(message)") } else { Self.log.debug("MiniMax coding plan status \(status)") } } private static func looksSignedOut(html: String) -> Bool { let lower = self.visibleText(from: html).lowercased() return lower.contains("sign in") || lower.contains("log in") || lower.contains("登录") || lower.contains("登入") } static func _looksSignedOutForTesting(html: String) -> Bool { self.looksSignedOut(html: html) } private static func visibleText(from html: String) -> String { let patterns = [ #"(?is)]*>.*?"#, #"(?is)]*>.*?"#, #"(?is)"#, #"<[^>]+>"#, #"\s+"#, ] return patterns.enumerated().reduce(html) { result, item in let replacement = item.offset == patterns.count - 1 ? " " : "" return result.replacingOccurrences( of: item.element, with: replacement, options: .regularExpression) } .trimmingCharacters(in: .whitespacesAndNewlines) } } struct MiniMaxCodingPlanPayload: Decodable { let baseResp: MiniMaxBaseResponse? let data: MiniMaxCodingPlanData private enum CodingKeys: String, CodingKey { case baseResp = "base_resp" case data } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.baseResp = try container.decodeIfPresent(MiniMaxBaseResponse.self, forKey: .baseResp) if container.contains(.data) { let dataDecoder = try container.superDecoder(forKey: .data) self.data = try MiniMaxCodingPlanData(from: dataDecoder) } else { self.data = try MiniMaxCodingPlanData(from: decoder) } } } struct MiniMaxCodingPlanData: Decodable { let baseResp: MiniMaxBaseResponse? let currentSubscribeTitle: String? let planName: String? let comboTitle: String? let currentPlanTitle: String? let currentComboCard: MiniMaxComboCard? let pointsBalance: Double? let modelRemains: [MiniMaxModelRemains] private enum CodingKeys: String, CodingKey { case baseResp = "base_resp" case currentSubscribeTitle = "current_subscribe_title" case planName = "plan_name" 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" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.baseResp = try container.decodeIfPresent(MiniMaxBaseResponse.self, forKey: .baseResp) self.currentSubscribeTitle = try container.decodeIfPresent(String.self, forKey: .currentSubscribeTitle) self.planName = try container.decodeIfPresent(String.self, forKey: .planName) 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)) ?? [] } } struct MiniMaxComboCard: Decodable { let title: String? } struct MiniMaxBaseResponse: Decodable { let statusCode: Int? let statusMessage: String? private enum CodingKeys: String, CodingKey { case statusCode = "status_code" case statusMessage = "status_msg" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.statusCode = MiniMaxDecoding.decodeInt(container, forKey: .statusCode) self.statusMessage = try container.decodeIfPresent(String.self, forKey: .statusMessage) } } // 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 MiniMaxUsageParser { static func decodePayload(data: Data) throws -> MiniMaxCodingPlanPayload { let decoder = JSONDecoder() 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: []) return try self.decodePayload(data: data) } 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) } static func parse(html: String, now: Date = Date()) throws -> MiniMaxUsageSnapshot { if let snapshot = self.parseNextData(html: html, now: now) { return snapshot } let text = self.stripHTML(html) let planName = self.parsePlanName(html: html, text: text) let available = self.parseAvailableUsage(text: text) let usedPercent = self.parseUsedPercent(text: text) let resetsAt = self.parseResetsAt(text: text, now: now) if planName == nil, available == nil, usedPercent == nil { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } return MiniMaxUsageSnapshot( planName: planName, availablePrompts: available?.prompts, currentPrompts: nil, remainingPrompts: nil, windowMinutes: available?.windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, updatedAt: now) } static func parseCodingPlanRemains( payload: MiniMaxCodingPlanPayload, now: Date = Date()) throws -> MiniMaxUsageSnapshot { let baseResponse = payload.data.baseResp ?? payload.baseResp if let status = baseResponse?.statusCode, status != 0 { let message = baseResponse?.statusMessage ?? "status_code \(status)" let lower = message.lowercased() if status == 1004 || lower.contains("cookie") || lower.contains("log in") || lower.contains("login") { throw MiniMaxUsageError.invalidCredentials } throw MiniMaxUsageError.apiError(message) } guard !payload.data.modelRemains.isEmpty else { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } // Convert model_remains to services array for multi-service UI display var services: [MiniMaxServiceUsage] = [] for item in payload.data.modelRemains { guard let modelName = item.modelName else { continue } let serviceTypeIdentifier = self.mapModelNameToServiceType(modelName: modelName) if let intervalService = self.makeServiceUsage( ServiceUsageInput( serviceType: serviceTypeIdentifier, windowTypeOverride: nil, total: item.currentIntervalTotalCount, remaining: item.currentIntervalUsageCount, remainingPercent: item.currentIntervalRemainingPercent, status: item.currentIntervalStatus, start: item.startTime, end: item.endTime, 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.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, boostPermille: item.weeklyBoostPermille), now: now) { services.append(weeklyService) } } // Use first service for backward compatibility fields let first = payload.data.modelRemains.first 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), end: self.dateFromEpoch(first?.endTime)) let resetsAt = self.resetsAt( end: self.dateFromEpoch(first?.endTime), remains: first?.remainsTime, now: now) let planName = self.parsePlanName(data: payload.data) let currentPrompts: Int? = if let total, let remaining { max(0, total - remaining) } else { nil } return MiniMaxUsageSnapshot( planName: planName, availablePrompts: total, currentPrompts: currentPrompts, remainingPrompts: remaining, windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, updatedAt: now, services: services.isEmpty ? nil : services, pointsBalance: payload.data.pointsBalance) } 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 { return Date(timeIntervalSince1970: TimeInterval(raw) / 1000) } if raw > 1_000_000_000 { return Date(timeIntervalSince1970: TimeInterval(raw)) } return nil } private static func windowMinutes(start: Date?, end: Date?) -> Int? { guard let start, let end else { return nil } let minutes = Int(end.timeIntervalSince(start) / 60) return minutes > 0 ? minutes : nil } private static func resetsAt(end: Date?, remains: Int?, now: Date) -> Date? { if let end, end > now { return end } guard let remains, remains > 0 else { return nil } let seconds: TimeInterval = remains > 1_000_000 ? TimeInterval(remains) / 1000 : TimeInterval(remains) return now.addingTimeInterval(seconds) } private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { [ data.currentSubscribeTitle, data.planName, data.comboTitle, data.currentPlanTitle, data.currentComboCard?.title, ] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty } ?? self.inferredTokenPlanName(data: data) } 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 hasTextGeneration && hasUnavailableVideo ? "Plus" : nil } private static func parsePlanName(html: String, text: String) -> String? { [ 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) .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? { guard let data = self.nextDataJSONData(fromHTML: html), let object = try? JSONSerialization.jsonObject(with: data, options: []), let payload = self.findCodingPlanPayload(in: object), let decoded = try? self.decodePayload(json: payload) else { return nil } return try? self.parseCodingPlanRemains(payload: decoded, now: now) } private static func findCodingPlanPayload(in object: Any) -> [String: Any]? { if let dict = object as? [String: Any] { if dict["model_remains"] != nil || dict["modelRemains"] != nil { return dict } for value in dict.values { if let match = self.findCodingPlanPayload(in: value) { return match } } return nil } if let array = object as? [Any] { for value in array { if let match = self.findCodingPlanPayload(in: value) { return match } } } return nil } private static func normalizeCodingPlanPayload(_ payload: [String: Any]) -> [String: Any] { var normalized = payload if normalized["model_remains"] == nil, let value = normalized["modelRemains"] { normalized["model_remains"] = value } if normalized["current_subscribe_title"] == nil, let value = normalized["currentSubscribeTitle"] { normalized["current_subscribe_title"] = value } if normalized["plan_name"] == nil, let value = normalized["planName"] { normalized["plan_name"] = value } if normalized["combo_title"] == nil, let value = normalized["comboTitle"] { normalized["combo_title"] = value } if normalized["current_plan_title"] == nil, let value = normalized["currentPlanTitle"] { normalized["current_plan_title"] = value } if normalized["current_combo_card"] == nil, let value = normalized["currentComboCard"] { normalized["current_combo_card"] = value } 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) } return normalized } private static let nextDataNeedle = Data("id=\"__NEXT_DATA__\"".utf8) private static let scriptCloseNeedle = Data("".utf8) private static func nextDataJSONData(fromHTML html: String) -> Data? { let data = Data(html.utf8) guard let idRange = data.range(of: self.nextDataNeedle) else { return nil } guard let openTagEnd = data[idRange.upperBound...].firstIndex(of: UInt8(ascii: ">")) else { return nil } let contentStart = data.index(after: openTagEnd) guard let closeRange = data.range( of: self.scriptCloseNeedle, options: [], in: contentStart.. Data { guard !data.isEmpty else { return data } var start = data.startIndex var end = data.endIndex while start < end, self.isASCIIWhitespace(data[start]) { start = data.index(after: start) } while end > start { let prev = data.index(before: end) if self.isASCIIWhitespace(data[prev]) { end = prev } else { break } } return data.subdata(in: start.. Bool { switch value { case 9, 10, 13, 32: true default: false } } private static func parseAvailableUsage(text: String) -> (prompts: Int, windowMinutes: Int)? { let pattern = #"(?i)available\s+usage[:\s]*([0-9][0-9,]*)\s*prompts?\s*/\s*"# + #"([0-9]+(?:\.[0-9]+)?)\s*(hours?|hrs?|h|minutes?|mins?|m|days?|d)"# guard let match = self.extractMatch(pattern: pattern, text: text), match.count >= 3 else { return nil } let promptsRaw = match[0] let durationRaw = match[1] let unitRaw = match[2] let prompts = Int(promptsRaw.replacingOccurrences(of: ",", with: "")) ?? 0 guard prompts > 0 else { return nil } guard let duration = Double(durationRaw) else { return nil } let windowMinutes = self.minutes(from: duration, unit: unitRaw) guard windowMinutes > 0 else { return nil } return (prompts, windowMinutes) } private static func parseUsedPercent(text: String) -> Double? { let patterns = [ #"(?i)([0-9]{1,3}(?:\.[0-9]+)?)\s*%\s*used"#, #"(?i)used\s*([0-9]{1,3}(?:\.[0-9]+)?)\s*%"#, ] for pattern in patterns { if let raw = self.extractFirst(pattern: pattern, text: text), let value = Double(raw), value >= 0, value <= 100 { return value } } return nil } private static func parseResetsAt(text: String, now: Date) -> Date? { if let match = self.extractMatch( pattern: #"(?i)resets?\s+in\s+([0-9]+)\s*(seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)"#, text: text), match.count >= 2, let value = Double(match[0]) { let unit = match[1] let seconds = self.seconds(from: value, unit: unit) return now.addingTimeInterval(seconds) } if let match = self.extractMatch( pattern: #"(?i)resets?\s+at\s+([0-9]{1,2}:[0-9]{2})(?:\s*\(([^)]+)\))?"#, text: text), match.count >= 1 { let timeText = match[0] let tzText = match.count > 1 ? match[1] : nil return self.dateForTime(timeText, timeZoneHint: tzText, now: now) } return nil } private static func dateForTime(_ time: String, timeZoneHint: String?, now: Date) -> Date? { let formatter = DateFormatter() formatter.dateFormat = "HH:mm" if let tzHint = timeZoneHint?.trimmingCharacters(in: .whitespacesAndNewlines), !tzHint.isEmpty { formatter.timeZone = self.timeZone(from: tzHint) } formatter.locale = Locale(identifier: "en_US_POSIX") guard let timeOnly = formatter.date(from: time) else { return nil } var calendar = Calendar(identifier: .gregorian) calendar.timeZone = formatter.timeZone ?? .current let nowComponents = calendar.dateComponents([.year, .month, .day], from: now) var targetComponents = calendar.dateComponents([.hour, .minute], from: timeOnly) targetComponents.year = nowComponents.year targetComponents.month = nowComponents.month targetComponents.day = nowComponents.day guard var candidate = calendar.date(from: targetComponents) else { return nil } if candidate < now { candidate = calendar.date(byAdding: .day, value: 1, to: candidate) ?? candidate } return candidate } private static func minutes(from value: Double, unit: String) -> Int { let lower = unit.lowercased() if lower.hasPrefix("d") { return Int((value * 24 * 60).rounded()) } if lower.hasPrefix("h") { return Int((value * 60).rounded()) } if lower.hasPrefix("m") { return Int(value.rounded()) } if lower.hasPrefix("s") { return max(1, Int((value / 60).rounded())) } return 0 } private static func timeZone(from hint: String) -> TimeZone? { let trimmed = hint.trimmingCharacters(in: .whitespacesAndNewlines) if let timeZone = TimeZone(identifier: trimmed) { return timeZone } let pattern = #"(?i)^(?:UTC|GMT)\s*([+-])\s*(\d{1,2})(?::?(\d{2}))?$"# guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed)), let signRange = Range(match.range(at: 1), in: trimmed), let hourRange = Range(match.range(at: 2), in: trimmed) else { return nil } let sign = trimmed[signRange] == "-" ? -1 : 1 let hours = Int(trimmed[hourRange]) ?? 0 let minutes = if match.range(at: 3).location != NSNotFound, let minuteRange = Range(match.range(at: 3), in: trimmed) { Int(trimmed[minuteRange]) ?? 0 } else { 0 } return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60))) } private static func seconds(from value: Double, unit: String) -> TimeInterval { let lower = unit.lowercased() if lower.hasPrefix("d") { return value * 24 * 60 * 60 } if lower.hasPrefix("h") { return value * 60 * 60 } if lower.hasPrefix("m") { return value * 60 } return value } private static func stripHTML(_ html: String) -> String { var text = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression) text = text.replacingOccurrences(of: " ", with: " ") text = text.replacingOccurrences(of: "&", with: "&") text = text.replacingOccurrences(of: "<", with: "<") text = text.replacingOccurrences(of: ">", with: ">") text = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) return text.trimmingCharacters(in: .whitespacesAndNewlines) } private static func extractFirst(pattern: String, text: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } let range = NSRange(text.startIndex..= 2, let captureRange = Range(match.range(at: 1), in: text) else { return nil } return String(text[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) } private static func extractMatch(pattern: String, text: String) -> [String]? { guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } let range = NSRange(text.startIndex..= 2 else { return nil } return (1.. 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, limit > 0 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") { "text-generation" } else if serviceType.lowercased().contains("text"), serviceType.lowercased().contains("speech") { "text-to-speech" } else if serviceType.lowercased().contains("image") { "image" } else { 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 > 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?, now: Date) -> (windowType: String, timeRange: String) { guard let startTime, let endTime else { return (windowType: "Unknown", timeRange: "N/A") } let durationSeconds = endTime.timeIntervalSince(startTime) let durationHours = durationSeconds / 3600 // Determine window type based on duration let windowType = if durationHours >= 23, durationHours <= 25 { "Today" } else if durationHours >= 4, durationHours <= 6 { "5 hours" } else if durationHours >= 1, durationHours < 23 { "\(Int(durationHours)) hours" } else { "Custom" } // Format time range let formatter = DateFormatter() formatter.dateFormat = "HH:mm" formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .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 struct ServiceUsageInput { let serviceType: String 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 self.shouldRenderQuotaWindow(input) else { return nil } let startTime = self.dateFromEpoch(input.start) let endTime = self.dateFromEpoch(input.end) var (windowType, timeRange) = self.parseWindowInfo(startTime: startTime, endTime: endTime, now: now) if let windowTypeOverride = input.windowTypeOverride { windowType = windowTypeOverride } if windowType.lowercased() == "weekly", let weeklyRange = self.formatMiniMaxDateTimeRange(startTime: startTime, endTime: endTime) { timeRange = weeklyRange } 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 } return MiniMaxServiceUsage( serviceType: input.serviceType, windowType: windowType, timeRange: timeRange, 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) } }