diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3c16bb854..e7c9f5e74 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -843,6 +843,23 @@ extension UsageMenuCardView.Model { L("Daily billing data finalizes at 07:00 UTC"), ] } + if input.provider == .minimax, let usage = input.snapshot?.minimaxUsage { + var notes = Self.apiProviderUsageNotes(input: input) ?? [] + if let tier = usage.planTier?.trimmingCharacters(in: .whitespacesAndNewlines), !tier.isEmpty { + notes.append("Tier: \(tier)") + } + if let expiresAt = usage.planExpiresAt { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + notes.append("Expires: \(formatter.string(from: expiresAt))") + } + if let total = usage.creditTotal, total > 0 { + notes.append("Credits: \(total.formatted())") + } + return notes + } if let notes = apiProviderUsageNotes(input: input) { return notes diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift index 661441e99..6b14cadd4 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift @@ -64,7 +64,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { ProviderCookieSourceUI.subtitle( source: context.settings.minimaxCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, - auto: "Automatic imports browser cookies and local storage tokens.", + auto: "Automatic imports your browser session (after MiniMax web login) and local storage tokens.", manual: "Paste a Cookie header or cURL capture from the Token Plan page.", off: "MiniMax cookies are disabled.") } @@ -82,7 +82,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { ProviderSettingsPickerDescriptor( id: "minimax-cookie-source", title: "Cookie source", - subtitle: "Automatic imports browser cookies and local storage tokens.", + subtitle: "Automatic reuses your MiniMax web-login session from supported browsers.", dynamicSubtitle: cookieSubtitle, binding: cookieBinding, options: cookieOptions, @@ -114,10 +114,11 @@ struct MiniMaxProviderImplementation: ProviderImplementation { return [ ProviderSettingsFieldDescriptor( id: "minimax-api-token", - title: "API token", - subtitle: "Stored in ~/.codexbar/config.json. Paste your MiniMax API key.", + title: "MiniMax key (optional)", + subtitle: "Stored in ~/.codexbar/config.json. Paste a Token Plan subscription key " + + "or pay-as-you-go API key, or leave empty to reuse a MiniMax web-login session.", kind: .secure, - placeholder: "Paste API token…", + placeholder: "Paste MiniMax key…", binding: context.stringBinding(\.minimaxAPIToken), actions: [ ProviderSettingsActionDescriptor( diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift index 159e72af8..75dec6e46 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift @@ -1,6 +1,6 @@ import Foundation -public struct MiniMaxBillingSummary: Sendable { +public struct MiniMaxBillingSummary: Codable, Sendable { public let todayTokens: Int public let last30DaysTokens: Int public let todayCash: Double? @@ -31,7 +31,7 @@ public struct MiniMaxBillingSummary: Sendable { } } -public struct MiniMaxBillingDay: Sendable, Equatable { +public struct MiniMaxBillingDay: Codable, Sendable, Equatable { public let day: String public let tokens: Int public let cash: Double? @@ -43,7 +43,7 @@ public struct MiniMaxBillingDay: Sendable, Equatable { } } -public struct MiniMaxBillingBreakdown: Sendable, Equatable { +public struct MiniMaxBillingBreakdown: Codable, Sendable, Equatable { public let name: String public let tokens: Int public let cash: Double? diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift index 488b0b0f5..1f07b98f9 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift @@ -12,7 +12,7 @@ import Foundation /// This struct encapsulates all the relevant details about how much of a particular /// MiniMax service has been used within its quota window, including reset timing /// and localized display strings. -public struct MiniMaxServiceUsage: Sendable { +public struct MiniMaxServiceUsage: Codable, Sendable { /// The service identifier (e.g., "text-generation", "text-to-speech", "image") public let serviceType: String diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanModels.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanModels.swift new file mode 100644 index 000000000..b7d86cca2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanModels.swift @@ -0,0 +1,225 @@ +import Foundation + +struct MiniMaxCodingPlanData: Decodable { + let baseResp: MiniMaxBaseResponse? + let currentSubscribeTitle: String? + let planName: String? + let comboTitle: String? + let currentPlanTitle: String? + let currentComboCard: MiniMaxComboCard? + let tokenPlanCredit: MiniMaxTokenPlanCredit? + let cycleResourcePackage: MiniMaxCycleResourcePackage? + 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 tokenPlanCredit = "token_plan_credit" + case cycleResourcePackage = "cycle_resource_package" + 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.tokenPlanCredit = try container.decodeIfPresent(MiniMaxTokenPlanCredit.self, forKey: .tokenPlanCredit) + self.cycleResourcePackage = try container.decodeIfPresent( + MiniMaxCycleResourcePackage.self, + forKey: .cycleResourcePackage) + self.modelRemains = try (container.decodeIfPresent([MiniMaxModelRemains].self, forKey: .modelRemains)) ?? [] + } +} + +struct MiniMaxComboCard: Decodable { + let title: String? +} + +struct MiniMaxModelRemains: Decodable { + let modelName: String? + let currentIntervalTotalCount: Int? + let currentIntervalUsageCount: Int? + let startTime: Int? + let endTime: Int? + let remainsTime: Int? + let currentIntervalRemainingPercent: Double? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? + let currentWeeklyRemainingPercent: Double? + let weeklyStartTime: Int? + let weeklyEndTime: Int? + let weeklyRemainsTime: Int? + + private enum CodingKeys: String, CodingKey { + case modelName = "model_name" + case currentIntervalTotalCount = "current_interval_total_count" + case currentIntervalUsageCount = "current_interval_usage_count" + case startTime = "start_time" + case endTime = "end_time" + case remainsTime = "remains_time" + case currentIntervalRemainingPercent = "current_interval_remaining_percent" + case currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" + case currentWeeklyRemainingPercent = "current_weekly_remaining_percent" + case weeklyStartTime = "weekly_start_time" + case weeklyEndTime = "weekly_end_time" + case weeklyRemainsTime = "weekly_remains_time" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) + self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) + self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) + self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) + self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) + self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.currentIntervalRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentIntervalRemainingPercent) + self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) + self.currentWeeklyRemainingPercent = MiniMaxDecoding.decodeDouble( + container, + forKey: .currentWeeklyRemainingPercent) + self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) + } +} + +struct MiniMaxTokenPlanCredit: Decodable { + let total: Int? + let used: Int? + let remaining: Int? + + private enum CodingKeys: String, CodingKey { + case total + case used + case remaining + case apiKey = "api_key" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.total = MiniMaxDecoding.decodeInt(container, forKey: .total) + self.used = MiniMaxDecoding.decodeInt(container, forKey: .used) + self.remaining = MiniMaxDecoding.decodeInt(container, forKey: .remaining) + _ = try? container.decodeIfPresent(String.self, forKey: .apiKey) // explicitly ignored + } +} + +struct MiniMaxCycleResourcePackage: Decodable { + let title: String? + let tier: String? + let expiresAt: Date? + let creditTotal: Int? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + self.title = Self.decodeString(container, keys: ["title", "plan_title", "plan_name", "summary"]) + self.tier = Self.decodeString(container, keys: ["tier", "plan_tier", "level"]) + self.creditTotal = Self.decodeInt(container, keys: ["credit_total", "total_credit", "total_credits"]) + self.expiresAt = Self.decodeDate( + container, + keys: ["end_time", "end_ts", "expire_time", "expires_at", "end_date"]) + } + + private static func decodeString(_ container: KeyedDecodingContainer, keys: [String]) -> String? { + for key in keys { + guard let codingKey = DynamicCodingKey(stringValue: key) else { continue } + if let value = try? container.decodeIfPresent(String.self, forKey: codingKey), + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return value + } + } + return nil + } + + private static func decodeInt(_ container: KeyedDecodingContainer, keys: [String]) -> Int? { + for key in keys { + guard let codingKey = DynamicCodingKey(stringValue: key) else { continue } + if let value = MiniMaxDecoding.decodeInt(container, forKey: codingKey) { + return value + } + } + return nil + } + + private static func decodeDate(_ container: KeyedDecodingContainer, keys: [String]) -> Date? { + for key in keys { + guard let codingKey = DynamicCodingKey(stringValue: key) else { continue } + if let string = try? container.decodeIfPresent(String.self, forKey: codingKey) { + if let parsed = Self.parseDate(string) { return parsed } + } + if let intValue = MiniMaxDecoding.decodeInt(container, forKey: codingKey), + let parsed = Self.dateFromFlexibleEpoch(intValue) + { + return parsed + } + } + return nil + } + + private static func parseDate(_ value: String) -> Date? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + if let intValue = Int(trimmed), let date = Self.dateFromFlexibleEpoch(intValue) { + return date + } + let formats = ["yyyy-MM-dd", "yyyy/MM/dd", "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm:ss"] + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + for format in formats { + formatter.dateFormat = format + if let date = formatter.date(from: trimmed) { return date } + } + return ISO8601DateFormatter().date(from: trimmed) + } + + private static func dateFromFlexibleEpoch(_ raw: Int) -> Date? { + 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 + } +} + +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) + } +} + +private struct DynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 7b250d6a2..2b9d4545d 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -535,100 +535,6 @@ struct MiniMaxCodingPlanPayload: Decodable { } } -struct MiniMaxCodingPlanData: Decodable { - let baseResp: MiniMaxBaseResponse? - let currentSubscribeTitle: String? - let planName: String? - let comboTitle: String? - let currentPlanTitle: String? - let currentComboCard: MiniMaxComboCard? - 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 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.modelRemains = try (container.decodeIfPresent([MiniMaxModelRemains].self, forKey: .modelRemains)) ?? [] - } -} - -struct MiniMaxComboCard: Decodable { - let title: String? -} - -struct MiniMaxModelRemains: Decodable { - let modelName: String? - let currentIntervalTotalCount: Int? - let currentIntervalUsageCount: Int? - let startTime: Int? - let endTime: Int? - let remainsTime: Int? - let currentWeeklyTotalCount: Int? - let currentWeeklyUsageCount: Int? - let weeklyStartTime: Int? - let weeklyEndTime: Int? - let weeklyRemainsTime: Int? - - private enum CodingKeys: String, CodingKey { - case modelName = "model_name" - case currentIntervalTotalCount = "current_interval_total_count" - case currentIntervalUsageCount = "current_interval_usage_count" - case startTime = "start_time" - case endTime = "end_time" - case remainsTime = "remains_time" - case currentWeeklyTotalCount = "current_weekly_total_count" - case currentWeeklyUsageCount = "current_weekly_usage_count" - case weeklyStartTime = "weekly_start_time" - case weeklyEndTime = "weekly_end_time" - case weeklyRemainsTime = "weekly_remains_time" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) - self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) - self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) - self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) - self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) - self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) - self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) - self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) - self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) - self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) - self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) - } -} - -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 { @@ -787,10 +693,12 @@ enum MiniMaxUsageParser { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } + let primary = self.primaryModelRemains(from: payload.data.modelRemains) + // 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 modelName = item.modelName ?? "general" let serviceTypeIdentifier = self.mapModelNameToServiceType(modelName: modelName) if let intervalService = self.makeServiceUsage( @@ -799,6 +707,7 @@ enum MiniMaxUsageParser { windowTypeOverride: nil, total: item.currentIntervalTotalCount, remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, start: item.startTime, end: item.endTime, remainsTime: item.remainsTime), @@ -815,6 +724,7 @@ enum MiniMaxUsageParser { windowTypeOverride: "Weekly", total: item.currentWeeklyTotalCount, remaining: item.currentWeeklyUsageCount, + remainingPercent: item.currentWeeklyRemainingPercent, start: item.weeklyStartTime, end: item.weeklyEndTime, remainsTime: item.weeklyRemainsTime), @@ -824,34 +734,71 @@ enum MiniMaxUsageParser { } } - // Use first service for backward compatibility fields - let first = payload.data.modelRemains.first - let total = first?.currentIntervalTotalCount - let remaining = first?.currentIntervalUsageCount - let usedPercent = self.usedPercent(total: total, remaining: remaining) + if let credit = payload.data.tokenPlanCredit, + let total = credit.total ?? credit.used.map({ used in + let rem = credit.remaining ?? 0 + return used + rem + }) + { + let used = credit.used ?? max(0, total - (credit.remaining ?? 0)) + let percent = total > 0 ? Double(used) / Double(total) * 100.0 : 0 + services.append( + MiniMaxServiceUsage( + serviceType: "credits", + windowType: "Balance", + timeRange: "Token Plan credits", + usage: max(0, used), + limit: max(0, total), + percent: min(100.0, max(0.0, percent)), + resetsAt: nil, + resetDescription: "Credits balance")) + } + + let total = primary?.currentIntervalTotalCount + let remaining = primary?.currentIntervalUsageCount + let usedPercent = self.usedPercent( + total: total, + remaining: remaining, + remainingPercent: primary?.currentIntervalRemainingPercent) let windowMinutes = self.windowMinutes( - start: self.dateFromEpoch(first?.startTime), - end: self.dateFromEpoch(first?.endTime)) + start: self.dateFromEpoch(primary?.startTime), + end: self.dateFromEpoch(primary?.endTime)) let resetsAt = self.resetsAt( - end: self.dateFromEpoch(first?.endTime), - remains: first?.remainsTime, + end: self.dateFromEpoch(primary?.endTime), + remains: primary?.remainsTime, now: now) let planName = self.parsePlanName(data: payload.data) + let planTier = self.parsePlanTier(data: payload.data) + let planExpiresAt = payload.data.cycleResourcePackage?.expiresAt + let creditTotal = payload.data.cycleResourcePackage?.creditTotal ?? payload.data.tokenPlanCredit?.total let currentPrompts: Int? = if let total, let remaining { max(0, total - remaining) + } else if let usedPercent { + Int(usedPercent.rounded()) + } else { + nil + } + + let remainingPrompts: Int? = if let remaining { + remaining + } else if let usedPercent { + max(0, Int((100 - usedPercent).rounded())) } else { nil } return MiniMaxUsageSnapshot( planName: planName, + planTier: planTier, + planExpiresAt: planExpiresAt, + creditTotal: creditTotal, availablePrompts: total, currentPrompts: currentPrompts, - remainingPrompts: remaining, + remainingPrompts: remainingPrompts, windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, @@ -859,7 +806,10 @@ enum MiniMaxUsageParser { services: services.isEmpty ? nil : services) } - private static func usedPercent(total: Int?, remaining: Int?) -> Double? { + private static func usedPercent(total: Int?, remaining: Int?, remainingPercent: Double? = nil) -> Double? { + if let remainingPercent { + return min(100, max(0, 100 - remainingPercent)) + } guard let total, total > 0, let remaining else { return nil } let used = max(0, total - remaining) let percent = Double(used) / Double(total) * 100 @@ -888,12 +838,14 @@ enum MiniMaxUsageParser { return end } guard let remains, remains > 0 else { return nil } - let seconds: TimeInterval = remains > 1_000_000 ? TimeInterval(remains) / 1000 : TimeInterval(remains) + // Token Plan remains_time fields are milliseconds until reset. + let seconds: TimeInterval = remains >= 1000 ? TimeInterval(remains) / 1000 : TimeInterval(remains) return now.addingTimeInterval(seconds) } private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { let candidates = [ + data.cycleResourcePackage?.title, data.currentSubscribeTitle, data.planName, data.comboTitle, @@ -908,6 +860,16 @@ enum MiniMaxUsageParser { return nil } + private static func parsePlanTier(data: MiniMaxCodingPlanData) -> String? { + let tier = data.cycleResourcePackage?.tier?.trimmingCharacters(in: .whitespacesAndNewlines) + if let tier, !tier.isEmpty { return tier } + guard let planName = self.parsePlanName(data: data)?.lowercased() else { return nil } + if planName.contains("plus") { return "Plus" } + if planName.contains("pro") { return "Pro" } + if planName.contains("max") { return "Max" } + return nil + } + private static func parsePlanName(html: String, text: String) -> String? { let candidates = [ self.extractFirst(pattern: #"(?i)"planName"\s*:\s*"([^"]+)""#, text: html), @@ -984,6 +946,12 @@ enum MiniMaxUsageParser { if normalized["current_combo_card"] == nil, let value = normalized["currentComboCard"] { normalized["current_combo_card"] = value } + if normalized["token_plan_credit"] == nil, let value = normalized["tokenPlanCredit"] { + normalized["token_plan_credit"] = value + } + if normalized["cycle_resource_package"] == nil, let value = normalized["cycleResourcePackage"] { + normalized["cycle_resource_package"] = value + } if normalized["base_resp"] == nil, let value = normalized["baseResp"] { normalized["base_resp"] = value } @@ -1388,15 +1356,21 @@ enum MiniMaxUsageParser { let windowTypeOverride: String? let total: Int? let remaining: Int? + let remainingPercent: Double? let start: Int? let end: Int? let remainsTime: Int? } private static func makeServiceUsage(_ input: ServiceUsageInput, now: Date) -> MiniMaxServiceUsage? { - guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } + var total = input.total + var remaining = input.remaining + if total == nil || remaining == nil, let remainingPercent = input.remainingPercent { + total = 100 + remaining = Int(max(0, min(100, remainingPercent)).rounded()) + } + guard let total, total > 0, let remaining else { return nil } let used = max(0, total - remaining) - if used == 0, total == 0 { return nil } let startTime = self.dateFromEpoch(input.start) let endTime = self.dateFromEpoch(input.end) @@ -1466,7 +1440,20 @@ enum MiniMaxUsageParser { private static func isTextGenerationModelName(_ modelName: String) -> Bool { let lower = modelName.lowercased() - return lower.contains("minimax-m") || lower.hasPrefix("m2.") + return lower == "general" || lower.contains("minimax-m") || lower.hasPrefix("m2.") + } + + private static func primaryModelRemains(from remains: [MiniMaxModelRemains]) -> MiniMaxModelRemains? { + if let general = remains.first(where: { $0.modelName?.lowercased() == "general" }) { + return general + } + if let text = remains.first(where: { model in + guard let name = model.modelName else { return false } + return self.isTextGenerationModelName(name) + }) { + return text + } + return remains.first } private static func formatMiniMaxDateTimeRange(startTime: Date?, endTime: Date?) -> String? { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index f66de9d23..c996d899d 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -1,7 +1,10 @@ import Foundation -public struct MiniMaxUsageSnapshot: Sendable { +public struct MiniMaxUsageSnapshot: Codable, Sendable { public let planName: String? + public let planTier: String? + public let planExpiresAt: Date? + public let creditTotal: Int? public let availablePrompts: Int? public let currentPrompts: Int? public let remainingPrompts: Int? @@ -47,6 +50,9 @@ public struct MiniMaxUsageSnapshot: Sendable { public init( planName: String?, + planTier: String? = nil, + planExpiresAt: Date? = nil, + creditTotal: Int? = nil, availablePrompts: Int?, currentPrompts: Int?, remainingPrompts: Int?, @@ -58,6 +64,9 @@ public struct MiniMaxUsageSnapshot: Sendable { billingSummary: MiniMaxBillingSummary? = nil) { self.planName = planName + self.planTier = planTier + self.planExpiresAt = planExpiresAt + self.creditTotal = creditTotal self.availablePrompts = availablePrompts self.currentPrompts = currentPrompts self.remainingPrompts = remainingPrompts @@ -72,6 +81,9 @@ public struct MiniMaxUsageSnapshot: Sendable { public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { MiniMaxUsageSnapshot( planName: self.planName, + planTier: self.planTier, + planExpiresAt: self.planExpiresAt, + creditTotal: self.creditTotal, availablePrompts: self.availablePrompts, currentPrompts: self.currentPrompts, remainingPrompts: self.remainingPrompts, diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 471f1147f..d9773a7a2 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -105,6 +105,7 @@ public struct UsageSnapshot: Codable, Sendable { case extraRateWindows case providerCost case kiroUsage + case minimaxUsage case openRouterUsage case openAIAPIUsage case claudeAdminAPIUsage @@ -164,7 +165,7 @@ public struct UsageSnapshot: Codable, Sendable { self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time - self.minimaxUsage = nil // Not persisted, fetched fresh each time + self.minimaxUsage = try container.decodeIfPresent(MiniMaxUsageSnapshot.self, forKey: .minimaxUsage) self.deepseekUsage = nil // Not persisted, fetched fresh each time self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) @@ -202,6 +203,7 @@ public struct UsageSnapshot: Codable, Sendable { try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) try container.encodeIfPresent(self.kiroUsage, forKey: .kiroUsage) + try container.encodeIfPresent(self.minimaxUsage, forKey: .minimaxUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift index 968962590..9c4727677 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -98,4 +98,69 @@ struct MiniMaxMenuCardModelPlanTests { #expect(model.planText == nil) } + + @Test + func `minimax usage notes surface tier expiry and credits`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let expires = Date(timeIntervalSince1970: 1_800_000_000) + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanPlus-月度会员", + planTier: "Plus", + planExpiresAt: expires, + creditTotal: 28888, + availablePrompts: 100, + currentPrompts: 1, + remainingPrompts: 99, + windowMinutes: 300, + usedPercent: 1, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "Text Generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(240), + resetDescription: "Resets in 4 min"), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "TokenPlanPlus-月度会员")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.planText == "TokenPlanPlus-月度会员") + #expect(model.usageNotes.contains("Tier: Plus")) + #expect(model.usageNotes.contains("Credits: 28,888")) + #expect(model.usageNotes.contains(where: { $0.hasPrefix("Expires: ") })) + } } diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanParserTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanParserTests.swift new file mode 100644 index 000000000..583343a1f --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanParserTests.swift @@ -0,0 +1,170 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiniMaxTokenPlanParserTests { + @Test + func `zero usage remains percent maps to zero used`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "general", + "current_interval_remaining_percent": 100, + "remains_time": 240000 + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + #expect(snapshot.usedPercent == 0) + #expect(snapshot.currentPrompts == 0) + } + + @Test + func `non zero remains percent maps to used and reset windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "general", + "current_interval_remaining_percent": 99, + "current_weekly_remaining_percent": 99, + "remains_time": 240000, + "weekly_remains_time": 604800000 + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + #expect(snapshot.usedPercent == 1) + #expect(snapshot.resetsAt == now.addingTimeInterval(240)) + let services = try #require(snapshot.services) + let weekly = try #require(services.first(where: { $0.windowType == "Weekly" })) + #expect(weekly.percent == 1) + #expect(weekly.resetsAt == now.addingTimeInterval(604_800)) + } + + @Test + func `general model remains is preferred over video for primary quota`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "video-v1", + "current_interval_total_count": 500, + "current_interval_usage_count": 450 + }, + { + "model_name": "general", + "current_interval_total_count": 1000, + "current_interval_usage_count": 990 + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + #expect(snapshot.availablePrompts == 1000) + #expect(snapshot.currentPrompts == 10) + #expect(snapshot.remainingPrompts == 990) + #expect(snapshot.usedPercent == 1) + let primary = snapshot.toUsageSnapshot().primary + #expect(primary?.usedPercent == 1) + } + + @Test + func `token plan credit parses and api key is ignored`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [{ "model_name": "general", "current_interval_remaining_percent": 100 }], + "token_plan_credit": { + "total": 1000, + "used": 230, + "remaining": 770, + "api_key": "sk-cp-REDACTED" + } + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let credits = try #require(snapshot.services?.first(where: { $0.serviceType == "credits" })) + #expect(credits.limit == 1000) + #expect(credits.usage == 230) + #expect(credits.remaining == 770) + } + + @Test + func `cycle resource package parses token plan plus metadata`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let end = 1_770_000_000_000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [{ "model_name": "general", "current_interval_remaining_percent": 100 }], + "cycle_resource_package": { + "title": "TokenPlanPlus-月度会员", + "tier": "Plus", + "end_time": \(end), + "credit_total": 28888 + } + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + #expect(snapshot.planName == "TokenPlanPlus-月度会员") + #expect(snapshot.planTier == "Plus") + #expect(snapshot.planExpiresAt == Date(timeIntervalSince1970: TimeInterval(end) / 1000)) + #expect(snapshot.creditTotal == 28888) + } + + @Test + func `cycle resource package title is preferred over generic subscribe title`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Tag", + "model_remains": [{ "model_name": "general", "current_interval_remaining_percent": 100 }], + "cycle_resource_package": { + "title": "TokenPlanPlus-月度会员", + "tier": "Plus" + } + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + #expect(snapshot.planName == "TokenPlanPlus-月度会员") + #expect(snapshot.planTier == "Plus") + } + + @Test + func `missing model name still produces text generation service windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "current_interval_remaining_percent": 99, + "current_weekly_remaining_percent": 99, + "remains_time": 240000, + "weekly_remains_time": 604800000 + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + let fiveHour = try #require(services.first(where: { $0.windowType == "Unknown" })) + let weekly = try #require(services.first(where: { $0.windowType == "Weekly" })) + #expect(fiveHour.serviceType == "Text Generation") + #expect(weekly.serviceType == "Text Generation") + #expect(fiveHour.percent == 1) + #expect(weekly.percent == 1) + } +} diff --git a/Tests/CodexBarTests/MiniMaxUsageSnapshotEncodingTests.swift b/Tests/CodexBarTests/MiniMaxUsageSnapshotEncodingTests.swift new file mode 100644 index 000000000..8c21b3b21 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUsageSnapshotEncodingTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiniMaxUsageSnapshotEncodingTests { + @Test + func `usage snapshot json preserves token plan fields and service windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset5h = now.addingTimeInterval(4 * 60 * 60) + let resetWeekly = now.addingTimeInterval(3 * 24 * 60 * 60) + let expires = now.addingTimeInterval(30 * 24 * 60 * 60) + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanPlus-月度会员", + planTier: "Plus", + planExpiresAt: expires, + creditTotal: 28888, + availablePrompts: 1000, + currentPrompts: 10, + remainingPrompts: 990, + windowMinutes: 300, + usedPercent: 1, + resetsAt: reset5h, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "Text Generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 10, + limit: 1000, + percent: 1, + resetsAt: reset5h, + resetDescription: "Resets in 4 hours"), + MiniMaxServiceUsage( + serviceType: "Text Generation", + windowType: "Weekly", + timeRange: "2026/06/01 00:00 - 2026/06/08 00:00(UTC+8)", + usage: 20, + limit: 2000, + percent: 1, + resetsAt: resetWeekly, + resetDescription: "Resets in 3 days"), + ]) + + let usage = minimax.toUsageSnapshot() + let data = try JSONEncoder().encode(usage) + let payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let encodedMiniMax = try #require(payload["minimaxUsage"] as? [String: Any]) + #expect(encodedMiniMax["planName"] as? String == "TokenPlanPlus-月度会员") + #expect(encodedMiniMax["planTier"] as? String == "Plus") + #expect(encodedMiniMax["creditTotal"] as? Int == 28888) + + let services = try #require(encodedMiniMax["services"] as? [[String: Any]]) + #expect(services.count == 2) + #expect(services[0]["windowType"] as? String == "5 hours") + #expect(services[1]["windowType"] as? String == "Weekly") + + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.usedPercent == 1) + } +} diff --git a/docs/minimax.md b/docs/minimax.md index 15bb3ecbc..f72f05d22 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -1,23 +1,24 @@ --- -summary: "MiniMax provider data sources: Coding Plan tokens, browser cookies, and web-session parsing." +summary: "MiniMax provider data sources: Token Plan keys, browser cookies, and web-session parsing." read_when: - Debugging MiniMax usage parsing - - Updating MiniMax cookie handling or coding plan scraping + - Updating MiniMax cookie handling or token plan scraping - Adjusting MiniMax provider UI/menu behavior --- # MiniMax provider -MiniMax supports Coding Plan API tokens or web sessions. Web-session mode uses MiniMax browser/session state and +MiniMax supports Token Plan (M3) keys or web sessions. Web-session mode uses MiniMax browser/session state and falls back across the provider's supported web requests when needed. ## Data sources -1) **Coding Plan API token** +1) **Token Plan (M3) subscription key or pay-as-you-go API key** - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`), `MINIMAX_CODING_API_KEY`, or `MINIMAX_API_KEY`. + - `MINIMAX_CODING_API_KEY` is a legacy variable name retained for backward compatibility with existing setups. - When both environment variables are present, `MINIMAX_CODING_API_KEY` wins so a standard `sk-api-*` key does - not mask a coding-plan `sk-cp-*` key. + not mask a token-plan `sk-cp-*` style key. - Auto mode can fall back to the web/cookie path when API-token credentials are rejected or the global endpoint returns 404. @@ -41,19 +42,18 @@ falls back across the provider's supported web requests when needed. - `MINIMAX_REMAINS_URL=...` (full URL override) ## Cookie capture (optional override) -- Open the Coding Plan page and DevTools → Network. +- Open the Token Plan page and DevTools → Network. - Select the request to `/v1/api/openplatform/coding_plan/remains`. - Copy the `Cookie` request header (or use “Copy as cURL” and paste the whole line). - Paste into Preferences → Providers → MiniMax only if automatic import fails. ## Snapshot mapping -- Primary usage, reset timing, and plan/tier are derived from Coding Plan response fields or page text. -- Web-session billing history, when available, is mapped into the shared inline usage dashboard: - - 30-day token trend. - - Top model and top method breakdowns. - - Summary rows for recent billing-history totals. +- Primary usage, reset timing, and plan/tier are derived from current MiniMax response fields/page text. +- MiniMax's current dashboard may expose 5-hour, weekly, and Token Plan credit-balance surfaces; this document describes + what may be visible, but this PR does not change parser or fetch behavior. +- Web-session billing/usage history, when available, is mapped into the shared inline usage dashboard. -If the billing-history endpoint is unavailable but normal Coding Plan quota data is present, CodexBar still shows the +If the billing-history endpoint is unavailable but normal quota data is present, CodexBar still shows the quota card and omits the chart instead of treating the whole provider as failed. ## Key files diff --git a/docs/providers.md b/docs/providers.md index 18f523522..64fce3275 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -35,7 +35,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | | z.ai | API token from config/env → quota API (`api`). | | Manus | Browser `session_id` cookie (auto/manual/env) → credits API (`web`). | -| MiniMax | Manual/browser session via Coding Plan web path (`web`), or Coding Plan API token (`api`). | +| MiniMax | Token Plan subscription/API key (`api`) or web session from configured/manual/browser sources (`web`), including existing browser login sessions. | | Kimi | Auth token from `kimi-auth` cookie/manual token/env → usage API (`web`). | | Kilo | API token from config/env → usage API (`api`); auto falls back to CLI session auth (`cli`). | | Copilot | Device-flow/env/config token → `copilot_internal` API (`api`). | @@ -114,9 +114,9 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Status: none yet. ## MiniMax -- Coding Plan API token or web session from configured/manual/browser sources. +- Token Plan subscription/API key or web session from configured/manual/browser sources (including existing browser login sessions). - Supports global and China mainland hosts via provider region settings and environment overrides. -- Web-session billing history can render 30-day token charts plus top model/method breakdowns when MiniMax exposes it. +- Web-session billing/usage history is shown when MiniMax exposes it. - Status: none yet. - Details: `docs/minimax.md`.