1689 lines · 68.2 KB · Swift
RawDownload
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 snapshot = try await self.attachingSubscriptionMetadataIfAvailable(                to: self.fetchCodingPlanHTML(context: context, now: now),                context: context,                groupID: groupID)            return try await self.attachingBillingIfAvailable(                to: snapshot,                context: context,                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 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

Lines 1–500 of 1689.