275 lines
11 KiB
Swift
275 lines
11 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
struct MiniMaxSubscriptionMetadata: Equatable {
|
|
let planName: String?
|
|
let subscriptionExpiresAt: Date?
|
|
let subscriptionRenewsAt: Date?
|
|
}
|
|
|
|
enum MiniMaxSubscriptionMetadataFetcher {
|
|
private static let comboPath = "v1/api/openplatform/charge/combo/cycle_audio_resource_package"
|
|
|
|
static func fetch(
|
|
cookieHeader: String,
|
|
groupID: String?,
|
|
region: MiniMaxAPIRegion,
|
|
environment: [String: String],
|
|
transport: any ProviderHTTPTransport) async throws -> MiniMaxSubscriptionMetadata
|
|
{
|
|
let url = try self.resolveComboURL(region: region, environment: environment)
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
|
if let groupID = groupID?.trimmingCharacters(in: .whitespacesAndNewlines), !groupID.isEmpty {
|
|
request.setValue(groupID, forHTTPHeaderField: "x-group-id")
|
|
}
|
|
request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept")
|
|
request.setValue("zh-CN,zh;q=0.9", forHTTPHeaderField: "accept-language")
|
|
request.setValue(self.platformOrigin(region: region).absoluteString, forHTTPHeaderField: "origin")
|
|
request.setValue(self.platformOrigin(region: region).absoluteString + "/", forHTTPHeaderField: "referer")
|
|
|
|
let response = try await transport.response(for: request)
|
|
guard response.statusCode == 200 else {
|
|
if response.statusCode == 401 || response.statusCode == 403 { throw MiniMaxUsageError.invalidCredentials }
|
|
throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)")
|
|
}
|
|
return try self.parse(data: response.data)
|
|
}
|
|
|
|
static func parse(data: Data) throws -> MiniMaxSubscriptionMetadata {
|
|
let object = try JSONSerialization.jsonObject(with: data, options: [])
|
|
try self.validateBaseResponse(in: object)
|
|
let planName = self.findPlanName(in: object)
|
|
let subscriptionExpiresAt = self.findDate(
|
|
in: object,
|
|
keys: ["current_subscribe_end_time_ts", "current_subscribe_end_time"])
|
|
let subscriptionRenewsAt = self.findDate(
|
|
in: object,
|
|
keys: ["renewal_trigger_time_ts", "renewal_date"])
|
|
guard planName != nil || subscriptionExpiresAt != nil || subscriptionRenewsAt != nil else {
|
|
throw MiniMaxUsageError.parseFailed("MiniMax combo metadata did not include subscription metadata.")
|
|
}
|
|
return MiniMaxSubscriptionMetadata(
|
|
planName: planName,
|
|
subscriptionExpiresAt: subscriptionExpiresAt,
|
|
subscriptionRenewsAt: subscriptionRenewsAt)
|
|
}
|
|
|
|
static func resolveComboURL(region: MiniMaxAPIRegion, environment: [String: String]) throws -> URL {
|
|
if let rejectedKey = MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: environment) {
|
|
throw ProviderEndpointOverrideError.minimax(rejectedKey)
|
|
}
|
|
let baseURL = MiniMaxSettingsReader.hostOverride(environment: environment)
|
|
.map { "https://\($0)" } ?? self.defaultWebHost(region: region)
|
|
guard var components = URLComponents(string: baseURL),
|
|
components.host?.isEmpty == false
|
|
else {
|
|
throw MiniMaxUsageError.apiError("MiniMax combo metadata host is invalid.")
|
|
}
|
|
guard components.scheme?.lowercased() == "https" else {
|
|
throw MiniMaxUsageError.apiError("MiniMax combo metadata host must use HTTPS.")
|
|
}
|
|
components.path = "/" + Self.comboPath
|
|
components.queryItems = [
|
|
URLQueryItem(name: "biz_line", value: "2"),
|
|
URLQueryItem(name: "cycle_type", value: "3"),
|
|
URLQueryItem(name: "resource_package_type", value: "7"),
|
|
]
|
|
guard let url = components.url else {
|
|
throw MiniMaxUsageError.apiError("MiniMax combo metadata host is invalid.")
|
|
}
|
|
return url
|
|
}
|
|
|
|
private static func validateBaseResponse(in object: Any) throws {
|
|
guard let root = object as? [String: Any],
|
|
let baseResp = root["base_resp"] as? [String: Any]
|
|
else { return }
|
|
let status = self.intValue(baseResp["status_code"]) ?? 0
|
|
guard status != 0 else { return }
|
|
let message = (baseResp["status_msg"] as? String) ?? "MiniMax combo metadata error \(status)"
|
|
if status == 1004 || message.lowercased().contains("cookie") {
|
|
throw MiniMaxUsageError.invalidCredentials
|
|
}
|
|
throw MiniMaxUsageError.apiError(message)
|
|
}
|
|
|
|
private static func findPlanName(in object: Any) -> String? {
|
|
let currentSubscriptionStrings = self.collectCurrentSubscriptionStrings(in: object)
|
|
if let tokenPlan = self.bestPlanName(in: currentSubscriptionStrings) {
|
|
return tokenPlan
|
|
}
|
|
|
|
let strings = self.collectStrings(in: object)
|
|
if let tokenPlan = self.bestPlanName(in: strings) {
|
|
return tokenPlan
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func bestPlanName(in strings: [String]) -> String? {
|
|
let tokenPlans = strings.compactMap { value -> (rank: Int, value: String)? in
|
|
guard let rank = self.tokenPlanRank(value) else { return nil }
|
|
return (rank, value.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
}
|
|
if let tokenPlan = tokenPlans.min(by: { lhs, rhs in
|
|
lhs.rank == rhs.rank ? lhs.value.count < rhs.value.count : lhs.rank < rhs.rank
|
|
}) {
|
|
return tokenPlan.value
|
|
}
|
|
return strings.first { value in
|
|
let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return ["plus", "max", "ultra"].contains(cleaned.lowercased())
|
|
}
|
|
}
|
|
|
|
private static func collectCurrentSubscriptionStrings(in object: Any) -> [String] {
|
|
guard let dictionary = object as? [String: Any] else {
|
|
if let array = object as? [Any] {
|
|
return array.flatMap(self.collectCurrentSubscriptionStrings(in:))
|
|
}
|
|
return []
|
|
}
|
|
|
|
return dictionary.flatMap { key, value in
|
|
let lowercasedKey = key.lowercased()
|
|
let stringsForCurrentField: [String] = if lowercasedKey == "current_subscribe" ||
|
|
lowercasedKey == "current_subscription" ||
|
|
lowercasedKey.contains("current_subscribe") ||
|
|
lowercasedKey.contains("current_subscription") ||
|
|
lowercasedKey.contains("current_plan")
|
|
{
|
|
self.collectStrings(in: value)
|
|
} else {
|
|
[]
|
|
}
|
|
return stringsForCurrentField + self.collectCurrentSubscriptionStrings(in: value)
|
|
}
|
|
}
|
|
|
|
private static func tokenPlanRank(_ value: String) -> Int? {
|
|
let lower = value.lowercased()
|
|
if lower.contains("tokenplanplus") { return 0 }
|
|
if lower.contains("tokenplanmax") { return 1 }
|
|
if lower.contains("tokenplanultra") { return 2 }
|
|
if lower.contains("token plan"), lower.contains("plus") || lower.contains("max") || lower.contains("ultra") {
|
|
return 3
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func collectStrings(in object: Any) -> [String] {
|
|
if let string = object as? String { return [string] }
|
|
if let array = object as? [Any] { return array.flatMap(self.collectStrings(in:)) }
|
|
if let dictionary = object as? [String: Any] {
|
|
return dictionary.sorted { $0.key < $1.key }.flatMap { self.collectStrings(in: $0.value) }
|
|
}
|
|
return []
|
|
}
|
|
|
|
private static func intValue(_ value: Any?) -> Int? {
|
|
if let int = value as? Int { return int }
|
|
if let string = value as? String { return Int(string) }
|
|
return nil
|
|
}
|
|
|
|
private static func findDate(in object: Any, keys: [String]) -> Date? {
|
|
keys.lazy.compactMap { key in
|
|
self.findValue(forKey: key, in: object).flatMap(self.dateValue(from:))
|
|
}.first
|
|
}
|
|
|
|
private static func findValue(forKey key: String, in object: Any) -> Any? {
|
|
if let dictionary = object as? [String: Any] {
|
|
if let value = dictionary[key] { return value }
|
|
for nested in dictionary.values {
|
|
if let value = self.findValue(forKey: key, in: nested) {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
if let array = object as? [Any] {
|
|
for nested in array {
|
|
if let value = self.findValue(forKey: key, in: nested) {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func dateValue(from value: Any) -> Date? {
|
|
if let int = value as? Int {
|
|
return self.dateValue(fromNumber: Double(int))
|
|
}
|
|
if let double = value as? Double {
|
|
return self.dateValue(fromNumber: double)
|
|
}
|
|
guard let string = value as? String else { return nil }
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let numeric = Double(trimmed) {
|
|
return self.dateValue(fromNumber: numeric)
|
|
}
|
|
return self.dateFromMonthDayYear(trimmed)
|
|
}
|
|
|
|
private static func dateValue(fromNumber value: Double) -> Date? {
|
|
guard value.isFinite, value > 0 else { return nil }
|
|
let seconds = value > 10_000_000_000 ? value / 1000 : value
|
|
return Date(timeIntervalSince1970: seconds)
|
|
}
|
|
|
|
private static func dateFromMonthDayYear(_ value: String) -> Date? {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
|
|
formatter.dateFormat = "MM/dd/yyyy"
|
|
return formatter.date(from: value)
|
|
}
|
|
|
|
private static func defaultWebHost(region: MiniMaxAPIRegion) -> String {
|
|
switch region {
|
|
case .global: "https://www.minimax.io"
|
|
case .chinaMainland: "https://www.minimaxi.com"
|
|
}
|
|
}
|
|
|
|
private static func platformOrigin(region: MiniMaxAPIRegion) -> URL {
|
|
switch region {
|
|
case .global: URL(string: "https://platform.minimax.io")!
|
|
case .chinaMainland: URL(string: "https://platform.minimaxi.com")!
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MiniMaxUsageFetcher {
|
|
static func attachingSubscriptionMetadataIfAvailable(
|
|
to snapshot: MiniMaxUsageSnapshot,
|
|
context: WebFetchContext,
|
|
groupID: String?) async throws -> MiniMaxUsageSnapshot
|
|
{
|
|
let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID
|
|
guard resolvedGroupID?.isEmpty == false else { return snapshot }
|
|
do {
|
|
let metadata = try await MiniMaxSubscriptionMetadataFetcher.fetch(
|
|
cookieHeader: context.cookie,
|
|
groupID: resolvedGroupID,
|
|
region: context.region,
|
|
environment: context.environment,
|
|
transport: context.transport)
|
|
return snapshot.withSubscriptionMetadata(metadata)
|
|
} catch is CancellationError {
|
|
throw CancellationError()
|
|
} catch let error as URLError where error.code == .cancelled {
|
|
throw CancellationError()
|
|
} catch {
|
|
Self.log.debug("MiniMax subscription metadata unavailable: \(error.localizedDescription)")
|
|
return snapshot
|
|
}
|
|
}
|
|
}
|