Files
steipete--codexbar/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift
T
2026-07-13 12:22:33 +08:00

1668 lines
68 KiB
Swift

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<String> = []
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)<script\b[^>]*>.*?</script>"#,
#"(?is)<style\b[^>]*>.*?</style>"#,
#"(?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("</script>".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.endIndex)
else { return nil }
let rawData = data[contentStart..<closeRange.lowerBound]
let trimmed = self.trimASCIIWhitespace(Data(rawData))
return trimmed.isEmpty ? nil : trimmed
}
private static func trimASCIIWhitespace(_ data: Data) -> 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..<end)
}
private static func isASCIIWhitespace(_ value: UInt8) -> 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: "&nbsp;", with: " ")
text = text.replacingOccurrences(of: "&amp;", with: "&")
text = text.replacingOccurrences(of: "&lt;", with: "<")
text = text.replacingOccurrences(of: "&gt;", 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..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range),
match.numberOfRanges >= 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..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range),
match.numberOfRanges >= 2
else { return nil }
return (1..<match.numberOfRanges).compactMap { idx in
guard let captureRange = Range(match.range(at: idx), in: text) else { return nil }
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,
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)
}
}