diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5f685af23..42e3fc469 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -649,6 +649,8 @@ struct UsageMenuCardExtraUsageSectionView: View { // MARK: - Model factory extension UsageMenuCardView.Model { + private static let miniMaxMetricLimit = 3 + struct Input { let provider: UsageProvider let metadata: ProviderMetadata @@ -771,6 +773,13 @@ extension UsageMenuCardView.Model { } private static func usageNotes(input: Input) -> [String] { + if input.provider == .minimax { + return self.miniMaxUsageNotes( + displayEntries: self.miniMaxDisplayEntries(snapshot: input.snapshot), + style: input.resetTimeDisplayStyle, + now: input.now) + } + if input.provider == .kilo { var notes = Self.kiloLoginDetails(snapshot: input.snapshot) let resolvedSource = input.sourceLabel? @@ -798,6 +807,67 @@ extension UsageMenuCardView.Model { } } + private struct MiniMaxDisplayEntries { + let metricEntries: [MiniMaxModelUsageEntry] + let noteEntries: [MiniMaxModelUsageEntry] + } + + private static func miniMaxDisplayEntries(snapshot: UsageSnapshot?) -> MiniMaxDisplayEntries { + guard let modelEntries = snapshot?.minimaxUsage?.modelEntries, !modelEntries.isEmpty else { + return MiniMaxDisplayEntries(metricEntries: [], noteEntries: []) + } + + var metricEntries: [MiniMaxModelUsageEntry] = [] + var noteEntries: [MiniMaxModelUsageEntry] = [] + + // Note: model entries that cannot compute usage or lack a reset time are silently skipped. + // This is intentional — such entries typically indicate invalid data from the backend and need not be shown. + for entry in modelEntries { + if metricEntries.count < Self.miniMaxMetricLimit, + entry.normalizedSessionUsage() != nil + { + metricEntries.append(entry) + } else { + noteEntries.append(entry) + } + } + + return MiniMaxDisplayEntries(metricEntries: metricEntries, noteEntries: noteEntries) + } + + private static func miniMaxUsageNotes( + displayEntries: MiniMaxDisplayEntries, + style: ResetTimeDisplayStyle, + now: Date) -> [String] + { + displayEntries.noteEntries.compactMap { entry in + guard let resetText = entry.resetText(style: style, now: now) else { return nil } + return "\(entry.modelName): \(resetText)" + } + } + + private static func miniMaxMetrics(input: Input, displayEntries: MiniMaxDisplayEntries) -> [Metric] { + guard !displayEntries.metricEntries.isEmpty else { return [] } + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left + + return displayEntries.metricEntries.enumerated().compactMap { index, entry in + guard let usage = entry.normalizedSessionUsage() else { return nil } + let value = input.usageBarsShowUsed ? usage.used : usage.remaining + let percent = self.clamped((Double(value) / Double(usage.total)) * 100) + return Metric( + id: "minimax-\(index)", + title: entry.modelName, + percent: percent, + percentStyle: percentStyle, + resetText: entry.resetText(style: input.resetTimeDisplayStyle, now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + } + } + private static func email( for provider: UsageProvider, snapshot: UsageSnapshot?, @@ -932,6 +1002,14 @@ extension UsageMenuCardView.Model { private static func metrics(input: Input) -> [Metric] { guard let snapshot = input.snapshot else { return [] } + if input.provider == .minimax { + let miniMaxMetrics = self.miniMaxMetrics( + input: input, + displayEntries: self.miniMaxDisplayEntries(snapshot: input.snapshot)) + if !miniMaxMetrics.isEmpty { + return miniMaxMetrics + } + } if input.provider == .antigravity { return Self.antigravityMetrics(input: input, snapshot: snapshot) } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..5717979cf 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -342,7 +342,9 @@ struct MenuDescriptor { .appendActionMenuEntries(context: actionContext, entries: &entries) } - if metadata?.dashboardURL != nil { + if let targetProvider, + Self.dashboardURL(for: targetProvider, store: store) != nil + { entries.append(.action("Usage Dashboard", .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { @@ -407,6 +409,14 @@ struct MenuDescriptor { return false } + private static func dashboardURL(for provider: UsageProvider, store: UsageStore) -> URL? { + let context = ProviderDashboardContext( + provider: provider, + settings: store.settings, + store: store) + return ProviderCatalog.implementation(for: provider)?.dashboardURL(context: context) + } + private static func appendRateWindow( entries: inout [Entry], title: String, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index f3d5bc112..d9bfec230 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -263,7 +263,7 @@ struct ProvidersPane: View { } func menuBarMetricPicker(for provider: UsageProvider) -> ProviderSettingsPickerDescriptor? { - if provider == .zai { return nil } + if provider == .zai || provider == .minimax { return nil } let options: [ProviderSettingsPickerOption] if provider == .openrouter { options = [ diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift index eb198478e..9d2c2b107 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift @@ -23,6 +23,11 @@ struct AlibabaCodingPlanProviderImplementation: ProviderImplementation { _ = settings.alibabaCodingPlanAPIRegion } + @MainActor + func dashboardURL(context: ProviderDashboardContext) -> URL? { + context.settings.alibabaCodingPlanAPIRegion.dashboardURL + } + @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { _ = context diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index de1bdffc7..8ce77874f 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -1,5 +1,6 @@ import CodexBarCore import CodexBarMacroSupport +import Foundation import SwiftUI @ProviderImplementationRegistration @@ -28,6 +29,27 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeWebExtrasEnabled } + @MainActor + func dashboardURL(context: ProviderDashboardContext) -> URL? { + let meta = context.store.metadata(for: context.provider) + let snapshot = context.store.snapshot(for: context.provider) + let loginMethod = snapshot?.loginMethod(for: context.provider) + let sourceLabel = context.store.sourceLabel(for: context.provider) + // For Claude, route subscription users to claude.ai/settings/usage instead of console billing + let urlString = if Self.prefersClaudeAppDashboard( + loginMethod: loginMethod, + sourceLabel: sourceLabel, + providerCost: snapshot?.providerCost) + { + meta.subscriptionDashboardURL ?? meta.dashboardURL + } else { + meta.dashboardURL + } + + guard let urlString else { return nil } + return URL(string: urlString) + } + @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { .claude(context.settings.claudeSettingsSnapshot(tokenOverride: context.tokenOverride)) @@ -233,4 +255,39 @@ struct ClaudeProviderImplementation: ProviderImplementation { } return false } + + /// Login methods that indicate a consumer/web-based Claude session + private static let consumerLoginMethods: Set = [ + ClaudeUsageDataSource.web.rawValue, + "profile", + "browser profile", + ] + + private static func normalized(_ text: String?) -> String { + text?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + } + + private static func prefersClaudeAppDashboard( + loginMethod: String?, + sourceLabel: String, + providerCost: ProviderCostSnapshot? = nil) -> Bool + { + if ClaudePlan.isSubscriptionLoginMethod(loginMethod) { + return true + } + + let normalizedLogin = Self.normalized(loginMethod) + if Self.consumerLoginMethods.contains(normalizedLogin) { + return true + } + + // Quota currency code is a strong signal of a subscription plan + if providerCost?.currencyCode == "Quota" { + return true + } + + let normalizedSource = Self.normalized(sourceLabel) + return normalizedSource == ClaudeUsageDataSource.web.rawValue + || normalizedSource == "oauth" + } } diff --git a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift index 6b7432edc..39efbb427 100644 --- a/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift +++ b/Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift @@ -15,6 +15,11 @@ struct MiniMaxProviderImplementation: ProviderImplementation { } } + @MainActor + func dashboardURL(context: ProviderDashboardContext) -> URL? { + context.settings.minimaxAPIRegion.codingPlanURL + } + @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.minimaxCookieSource @@ -111,6 +116,15 @@ struct MiniMaxProviderImplementation: ProviderImplementation { context.settings.minimaxAuthMode() } + let openDashboard: @MainActor () -> Void = { + let dashboardContext = ProviderDashboardContext( + provider: context.provider, + settings: context.settings, + store: context.store) + guard let url = self.dashboardURL(context: dashboardContext) else { return } + NSWorkspace.shared.open(url) + } + return [ ProviderSettingsFieldDescriptor( id: "minimax-api-token", @@ -125,9 +139,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { title: "Open Coding Plan", style: .link, isVisible: nil, - perform: { - NSWorkspace.shared.open(context.settings.minimaxAPIRegion.codingPlanURL) - }), + perform: openDashboard), ], isVisible: nil, onActivate: { context.settings.ensureMiniMaxAPITokenLoaded() }), @@ -144,9 +156,7 @@ struct MiniMaxProviderImplementation: ProviderImplementation { title: "Open Coding Plan", style: .link, isVisible: nil, - perform: { - NSWorkspace.shared.open(context.settings.minimaxAPIRegion.codingPlanURL) - }), + perform: openDashboard), ], isVisible: { authMode().allowsCookies && context.settings.minimaxCookieSource == .manual @@ -154,4 +164,24 @@ struct MiniMaxProviderImplementation: ProviderImplementation { onActivate: { context.settings.ensureMiniMaxCookieLoaded() }), ] } + + @MainActor + func appendUsageMenuEntries(context: ProviderMenuUsageContext, entries: inout [ProviderMenuEntry]) { + guard let modelEntries = context.snapshot?.minimaxUsage?.modelEntries, !modelEntries.isEmpty else { return } + let resetStyle = context.settings.resetTimeDisplayStyle + + for modelEntry in modelEntries { + guard let line = Self.modelUsageLine(modelEntry, style: resetStyle) else { continue } + entries.append(.text(line, .secondary)) + } + } + + private static func modelUsageLine( + _ entry: MiniMaxModelUsageEntry, + style: ResetTimeDisplayStyle, + now: Date = Date()) -> String? + { + guard let detail = entry.resetText(style: style, now: now) else { return nil } + return "\(entry.modelName): \(detail)" + } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderContext.swift b/Sources/CodexBar/Providers/Shared/ProviderContext.swift index 8b0069a6a..669a1302a 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderContext.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderContext.swift @@ -26,6 +26,12 @@ struct ProviderSourceModeContext { let settings: SettingsStore } +struct ProviderDashboardContext { + let provider: UsageProvider + let settings: SettingsStore + let store: UsageStore +} + struct ProviderVersionContext { let provider: UsageProvider let browserDetection: BrowserDetection diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift index 7d5e22bd2..0a9b004f5 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementation.swift @@ -28,6 +28,9 @@ protocol ProviderImplementation: Sendable { @MainActor func sourceMode(context: ProviderSourceModeContext) -> ProviderSourceMode + @MainActor + func dashboardURL(context: ProviderDashboardContext) -> URL? + func detectVersion(context: ProviderVersionContext) async -> String? func makeRuntime() -> (any ProviderRuntime)? @@ -110,6 +113,14 @@ extension ProviderImplementation { .auto } + @MainActor + func dashboardURL(context: ProviderDashboardContext) -> URL? { + let meta = context.store.metadata(for: context.provider) + let urlString = meta.dashboardURL + guard let urlString else { return nil } + return URL(string: urlString) + } + func detectVersion(context: ProviderVersionContext) async -> String? { let detector = ProviderDescriptorRegistry.descriptor(for: self.id).cli.versionDetector return detector?(context.browserDetection) diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index ad65b0879..45034eefc 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -40,19 +40,11 @@ extension StatusItemController { } func dashboardURL(for provider: UsageProvider) -> URL? { - if provider == .alibaba { - return self.settings.alibabaCodingPlanAPIRegion.dashboardURL - } - - let meta = self.store.metadata(for: provider) - let urlString: String? = if provider == .claude, self.store.isClaudeSubscription() { - meta.subscriptionDashboardURL ?? meta.dashboardURL - } else { - meta.dashboardURL - } - - guard let urlString else { return nil } - return URL(string: urlString) + let context = ProviderDashboardContext( + provider: provider, + settings: self.settings, + store: self.store) + return ProviderCatalog.implementation(for: provider)?.dashboardURL(context: context) } @objc func openCreditsPurchase() { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 93edc6d6c..254d1fe14 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -8,11 +8,19 @@ public struct MiniMaxUsageFetcher: Sendable { 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 remainsEnrichmentDelayNanoseconds: UInt64 = 50_000_000 + private static let remainsEnrichmentTimeoutNanoseconds: UInt64 = 200_000_000 + private struct RemainsContext { let authorizationToken: String? let groupID: String? } + private enum RemainsAwaitOutcome { + case completed(Result) + case timedOut + } + public static func fetchUsage( cookieHeader: String, authorizationToken: String? = nil, @@ -25,25 +33,60 @@ public struct MiniMaxUsageFetcher: Sendable { throw MiniMaxUsageError.invalidCredentials } + let remainsContext = RemainsContext( + authorizationToken: authorizationToken, + groupID: groupID) + let remainsTask = Task { () -> Result in + do { + try await Task.sleep(nanoseconds: Self.remainsEnrichmentDelayNanoseconds) + try Task.checkCancellation() + let snapshot = try await self.fetchCodingPlanRemains( + cookie: cookie, + remainsContext: remainsContext, + region: region, + environment: environment, + now: now) + return .success(snapshot) + } catch { + return .failure(error) + } + } + do { - return try await self.fetchCodingPlanHTML( + let htmlSnapshot = try await self.fetchCodingPlanHTML( cookie: cookie, authorizationToken: authorizationToken, region: region, environment: environment, now: now) + + guard htmlSnapshot.modelEntries.isEmpty else { + remainsTask.cancel() + return htmlSnapshot + } + // Wait for the remains enrichment task with a timeout + let remainsSnapshot = await self.awaitRemainsWithTimeout( + remainsTask: remainsTask, + timeoutNanoseconds: Self.remainsEnrichmentTimeoutNanoseconds) + if case let .completed(.success(remainsSnapshot)) = remainsSnapshot { + return self.mergedSnapshot(remains: remainsSnapshot, html: htmlSnapshot) + } + return htmlSnapshot } catch let error as MiniMaxUsageError { if case .parseFailed = error { Self.log.debug("MiniMax coding plan HTML parse failed, trying remains API") - return try await self.fetchCodingPlanRemains( - cookie: cookie, - remainsContext: RemainsContext( - authorizationToken: authorizationToken, - groupID: groupID), - region: region, - environment: environment, - now: now) + switch await self.awaitRemains(remainsTask: remainsTask) { + case let .success(snapshot): + return snapshot + case let .failure(remainsError): + remainsTask.cancel() + throw remainsError + } } + remainsTask.cancel() + throw error + } catch { + remainsTask.cancel() throw error } } @@ -109,6 +152,56 @@ public struct MiniMaxUsageFetcher: Sendable { return try MiniMaxUsageParser.parseCodingPlanRemains(data: data, now: now) } + /// NOTE: When the timeout wins, the underlying URLSession request in remainsTask may still be in-flight. + /// remainsTask.cancel() sets the cancellation flag but cannot abort an already-started network request; + /// the response will simply be discarded. + private static func awaitRemains( + remainsTask: Task, Never>) async -> Result + { + await remainsTask.value + } + + private static func awaitRemainsWithTimeout( + remainsTask: Task, Never>, + timeoutNanoseconds: UInt64) async -> RemainsAwaitOutcome + { + await withTaskGroup(of: RemainsAwaitOutcome.self) { group in + group.addTask { + await .completed(self.awaitRemains(remainsTask: remainsTask)) + } + group.addTask { + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + return .timedOut + } + // The first task to complete wins — either the remains result or the timeout + let first = await group.next() + remainsTask.cancel() + group.cancelAll() + switch first { + case let .some(result): + return result + case .none: + return .timedOut + } + } + } + + private static func mergedSnapshot( + remains: MiniMaxUsageSnapshot, + html: MiniMaxUsageSnapshot) -> MiniMaxUsageSnapshot + { + MiniMaxUsageSnapshot( + planName: html.planName ?? remains.planName, + availablePrompts: html.availablePrompts, + currentPrompts: html.currentPrompts, + remainingPrompts: html.remainingPrompts, + windowMinutes: html.windowMinutes, + usedPercent: html.usedPercent, + resetsAt: html.resetsAt, + updatedAt: html.updatedAt, + modelEntries: remains.modelEntries.isEmpty ? html.modelEntries : remains.modelEntries) + } + private static func fetchCodingPlanHTML( cookie: String, authorizationToken: String?, @@ -382,27 +475,45 @@ struct MiniMaxComboCard: Decodable { } struct MiniMaxModelRemains: Decodable { + let modelName: String? let currentIntervalTotalCount: Int? let currentIntervalUsageCount: Int? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? let startTime: Int? let endTime: Int? let remainsTime: 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 currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" case startTime = "start_time" case endTime = "end_time" case remainsTime = "remains_time" + 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.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) } } @@ -442,6 +553,36 @@ enum MiniMaxDecoding { } enum MiniMaxUsageParser { + private struct NormalizedModelUsage { + let modelName: String + let sessionTotal: Int? + let sessionUsed: Int? + let sessionRemaining: Int? + let weeklyTotal: Int? + let weeklyUsed: Int? + let weeklyRemaining: Int? + let isWeeklyUnlimited: Bool + let windowMinutes: Int? + let resetsAt: Date? + + var hasSessionQuotaData: Bool { + self.sessionTotal != nil && self.sessionRemaining != nil + } + + var entry: MiniMaxModelUsageEntry { + MiniMaxModelUsageEntry( + modelName: self.modelName, + sessionTotal: self.sessionTotal, + sessionUsed: self.sessionUsed, + sessionRemaining: self.sessionRemaining, + sessionResetsAt: self.resetsAt, + weeklyTotal: self.weeklyTotal, + weeklyUsed: self.weeklyUsed, + weeklyRemaining: self.weeklyRemaining, + isWeeklyUnlimited: self.isWeeklyUnlimited) + } + } + static func decodePayload(data: Data) throws -> MiniMaxCodingPlanPayload { let decoder = JSONDecoder() return try decoder.decode(MiniMaxCodingPlanPayload.self, from: data) @@ -498,53 +639,134 @@ enum MiniMaxUsageParser { throw MiniMaxUsageError.apiError(message) } - guard let first = payload.data.modelRemains.first else { + let validModels = self.orderedValidModels(from: payload.data.modelRemains, now: now) + guard let primaryModel = validModels.first else { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } - let total = first.currentIntervalTotalCount - let remaining = first.currentIntervalUsageCount + let total = primaryModel.sessionTotal + let remaining = primaryModel.sessionRemaining let usedPercent = self.usedPercent(total: total, remaining: remaining) - - 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) - if planName == nil, total == nil, usedPercent == nil { - throw MiniMaxUsageError.parseFailed("Missing coding plan data.") - } - - let currentPrompts: Int? = if let total, let remaining { - max(0, total - remaining) - } else { - nil - } - return MiniMaxUsageSnapshot( planName: planName, availablePrompts: total, - currentPrompts: currentPrompts, + currentPrompts: primaryModel.sessionUsed, remainingPrompts: remaining, - windowMinutes: windowMinutes, + windowMinutes: primaryModel.windowMinutes, usedPercent: usedPercent, - resetsAt: resetsAt, - updatedAt: now) + resetsAt: primaryModel.resetsAt, + updatedAt: now, + modelEntries: validModels.map(\.entry)) } private static func usedPercent(total: Int?, remaining: Int?) -> Double? { - guard let total, total > 0, let remaining else { return nil } - let used = max(0, total - remaining) + guard let total, total > 0, let remaining = self.normalizedRemaining(total: total, remaining: remaining) else { + return nil + } + let used = total - remaining let percent = Double(used) / Double(total) * 100 return min(100, max(0, percent)) } + private static func orderedValidModels( + from models: [MiniMaxModelRemains], + now: Date) -> [NormalizedModelUsage] + { + let normalized = models.compactMap { self.normalizeModelUsage($0, now: now) } + guard let primaryIndex = self.primaryModelIndex(in: normalized) else { + return normalized + } + + var ordered = [normalized[primaryIndex]] + ordered.append(contentsOf: normalized.enumerated().compactMap { index, model in + index == primaryIndex ? nil : model + }) + return ordered + } + + private static func primaryModelIndex(in models: [NormalizedModelUsage]) -> Int? { + if let index = models.firstIndex(where: { self.isPrimaryTextModel($0.modelName) && $0.hasSessionQuotaData }) { + return index + } + if let index = models.firstIndex(where: \.hasSessionQuotaData) { + return index + } + return models.firstIndex(where: { self.isPrimaryTextModel($0.modelName) }) + } + + private static func normalizeModelUsage(_ model: MiniMaxModelRemains, now: Date) -> NormalizedModelUsage? { + guard self.isValidModel(model) else { return nil } + + let modelName = self.normalizedModelName(model.modelName) + let sessionTotal = self.normalizedTotal(model.currentIntervalTotalCount) + // MiniMax API: current_interval_usage_count is the remaining (unused) count, not the used count + let sessionRemaining = self.normalizedRemaining(total: sessionTotal, remaining: model.currentIntervalUsageCount) + let sessionUsed = self.usedCount(total: sessionTotal, remaining: sessionRemaining) + + let weeklyUnlimited = self.isWeeklyUnlimited(model) + let weeklyTotal = self.normalizedTotal(model.currentWeeklyTotalCount) + // MiniMax API: current_weekly_usage_count is likewise the remaining (unused) count + let weeklyRemaining = self.normalizedRemaining(total: weeklyTotal, remaining: model.currentWeeklyUsageCount) + let weeklyUsed = self.usedCount(total: weeklyTotal, remaining: weeklyRemaining) + + return NormalizedModelUsage( + modelName: modelName, + sessionTotal: sessionTotal, + sessionUsed: sessionUsed, + sessionRemaining: sessionRemaining, + weeklyTotal: weeklyUnlimited ? nil : weeklyTotal, + weeklyUsed: weeklyUnlimited ? nil : weeklyUsed, + weeklyRemaining: weeklyUnlimited ? nil : weeklyRemaining, + isWeeklyUnlimited: weeklyUnlimited, + windowMinutes: self.windowMinutes( + start: self.dateFromEpoch(model.startTime), + end: self.dateFromEpoch(model.endTime)), + resetsAt: self.resetsAt( + end: self.dateFromEpoch(model.endTime), + remains: model.remainsTime, + now: now)) + } + + private static func isValidModel(_ model: MiniMaxModelRemains) -> Bool { + let counters = [ + model.currentIntervalTotalCount, + model.currentIntervalUsageCount, + model.currentWeeklyTotalCount, + model.currentWeeklyUsageCount, + ] + return counters.contains { ($0 ?? 0) > 0 } + } + + private static func isWeeklyUnlimited(_ model: MiniMaxModelRemains) -> Bool { + (model.currentWeeklyTotalCount ?? 0) == 0 && (model.currentWeeklyUsageCount ?? 0) == 0 + } + + private static func normalizedModelName(_ raw: String?) -> String { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "MiniMax" : trimmed + } + + private static func isPrimaryTextModel(_ modelName: String) -> Bool { + modelName.lowercased().hasPrefix("minimax-m") + } + + private static func normalizedTotal(_ total: Int?) -> Int? { + guard let total, total > 0 else { return nil } + return total + } + + private static func normalizedRemaining(total: Int?, remaining: Int?) -> Int? { + guard let total, total > 0, let remaining else { return nil } + return min(max(remaining, 0), total) + } + + private static func usedCount(total: Int?, remaining: Int?) -> Int? { + guard let total, total > 0, let remaining else { return nil } + return max(0, total - remaining) + } + private static func dateFromEpoch(_ value: Int?) -> Date? { guard let raw = value else { return nil } if raw > 1_000_000_000_000 { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 09ed671e2..a453f99db 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -1,5 +1,60 @@ import Foundation +public struct MiniMaxModelUsageEntry: Sendable { + public let modelName: String + public let sessionTotal: Int? + public let sessionUsed: Int? + public let sessionRemaining: Int? + public let sessionResetsAt: Date? + public let weeklyTotal: Int? + public let weeklyUsed: Int? + public let weeklyRemaining: Int? + public let isWeeklyUnlimited: Bool + + public init( + modelName: String, + sessionTotal: Int?, + sessionUsed: Int?, + sessionRemaining: Int?, + sessionResetsAt: Date? = nil, + weeklyTotal: Int?, + weeklyUsed: Int?, + weeklyRemaining: Int?, + isWeeklyUnlimited: Bool) + { + self.modelName = modelName + self.sessionTotal = sessionTotal + self.sessionUsed = sessionUsed + self.sessionRemaining = sessionRemaining + self.sessionResetsAt = sessionResetsAt + self.weeklyTotal = weeklyTotal + self.weeklyUsed = weeklyUsed + self.weeklyRemaining = weeklyRemaining + self.isWeeklyUnlimited = isWeeklyUnlimited + } + + public func resetText(style: ResetTimeDisplayStyle, now: Date = Date()) -> String? { + guard let resetsAt = self.sessionResetsAt else { return nil } + let window = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: nil) + return UsageFormatter.resetLine(for: window, style: style, now: now) + } + + public func normalizedSessionUsage() -> (used: Int, remaining: Int, total: Int)? { + guard let total = self.sessionTotal, total > 0 else { return nil } + guard self.sessionUsed != nil || self.sessionRemaining != nil else { return nil } + let remaining = self.sessionRemaining.map { min(max($0, 0), total) } + let used = self.sessionUsed.map { min(max($0, 0), total) } + ?? remaining.map { total - $0 } + let finalRemaining = remaining ?? used.map { max(0, total - $0) } + guard let used, let finalRemaining else { return nil } + return (used: used, remaining: finalRemaining, total: total) + } +} + public struct MiniMaxUsageSnapshot: Sendable { public let planName: String? public let availablePrompts: Int? @@ -9,6 +64,7 @@ public struct MiniMaxUsageSnapshot: Sendable { public let usedPercent: Double? public let resetsAt: Date? public let updatedAt: Date + public let modelEntries: [MiniMaxModelUsageEntry] public init( planName: String?, @@ -18,7 +74,8 @@ public struct MiniMaxUsageSnapshot: Sendable { windowMinutes: Int?, usedPercent: Double?, resetsAt: Date?, - updatedAt: Date) + updatedAt: Date, + modelEntries: [MiniMaxModelUsageEntry] = []) { self.planName = planName self.availablePrompts = availablePrompts @@ -28,6 +85,7 @@ public struct MiniMaxUsageSnapshot: Sendable { self.usedPercent = usedPercent self.resetsAt = resetsAt self.updatedAt = updatedAt + self.modelEntries = modelEntries } } diff --git a/Tests/CodexBarTests/ClaudeDashboardTests.swift b/Tests/CodexBarTests/ClaudeDashboardTests.swift new file mode 100644 index 000000000..f4f1dbce3 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeDashboardTests.swift @@ -0,0 +1,212 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +struct ClaudeDashboardTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + private func makeSettings() -> SettingsStore { + let suite = "ClaudeDashboardTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @Test + func `claude subscription dashboard URL prefers subscription page`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro") + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date(), identity: identity), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let dashboardURL = controller.dashboardURL(for: .claude)?.absoluteString + let expectedURL = ProviderDescriptorRegistry + .descriptor(for: .claude) + .metadata + .subscriptionDashboardURL + #expect(dashboardURL == expectedURL) + } + + @Test(arguments: ["web", "Profile", "Browser profile"]) + func `claude consumer dashboard URL prefers claude app page`(loginMethod: String) { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date(), identity: identity), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let dashboardURL = controller.dashboardURL(for: .claude)?.absoluteString + let expectedURL = ProviderDescriptorRegistry + .descriptor(for: .claude) + .metadata + .subscriptionDashboardURL + #expect(dashboardURL == expectedURL) + } + + @Test + func `claude web source dashboard URL prefers claude app page when login method missing`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.lastSourceLabels[.claude] = "web" + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let dashboardURL = controller.dashboardURL(for: .claude)?.absoluteString + let expectedURL = ProviderDescriptorRegistry + .descriptor(for: .claude) + .metadata + .subscriptionDashboardURL + #expect(dashboardURL == expectedURL) + } + + @Test + func `claude quota cost routes to subscription page even without login method`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let cost = ProviderCostSnapshot( + used: 50, + limit: 100, + currencyCode: "Quota", + updatedAt: Date()) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, providerCost: cost, updatedAt: Date()), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let dashboardURL = controller.dashboardURL(for: .claude)?.absoluteString + let expectedURL = ProviderDescriptorRegistry + .descriptor(for: .claude) + .metadata + .subscriptionDashboardURL + #expect(dashboardURL == expectedURL) + } + + @Test + func `claude api user routes to console dashboard`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "api") + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date(), identity: identity), + provider: .claude) + store.lastSourceLabels[.claude] = "cli" + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let dashboardURL = controller.dashboardURL(for: .claude)?.absoluteString + let expectedURL = ProviderDescriptorRegistry + .descriptor(for: .claude) + .metadata + .dashboardURL + #expect(dashboardURL == expectedURL) + // Ensure it does NOT route to the subscription page + let subscriptionURL = ProviderDescriptorRegistry + .descriptor(for: .claude) + .metadata + .subscriptionDashboardURL + #expect(dashboardURL != subscriptionURL) + } + + @Test + func `claude oauth source routes to subscription page when login method missing`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.lastSourceLabels[.claude] = "oauth" + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let dashboardURL = controller.dashboardURL(for: .claude)?.absoluteString + let expectedURL = ProviderDescriptorRegistry + .descriptor(for: .claude) + .metadata + .subscriptionDashboardURL + #expect(dashboardURL == expectedURL) + } +} diff --git a/Tests/CodexBarTests/MenuCardMiniMaxTests.swift b/Tests/CodexBarTests/MenuCardMiniMaxTests.swift new file mode 100644 index 000000000..975f8f21a --- /dev/null +++ b/Tests/CodexBarTests/MenuCardMiniMaxTests.swift @@ -0,0 +1,182 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardMiniMaxTests { + @Test + func `minimax card model renders model metrics with per model reset times`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1500, + currentPrompts: 50, + remainingPrompts: 1450, + windowMinutes: 300, + usedPercent: Double(50) / Double(1500) * 100, + resetsAt: now.addingTimeInterval(12 * 60 * 60), + updatedAt: now, + modelEntries: [ + MiniMaxModelUsageEntry( + modelName: "MiniMax-M*", + sessionTotal: 1500, + sessionUsed: 50, + sessionRemaining: 1450, + sessionResetsAt: now.addingTimeInterval(3 * 60 * 60), + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + isWeeklyUnlimited: true), + MiniMaxModelUsageEntry( + modelName: "speech-hd", + sessionTotal: 4000, + sessionUsed: 0, + sessionRemaining: 4000, + sessionResetsAt: now.addingTimeInterval(5 * 60 * 60), + weeklyTotal: 28000, + weeklyUsed: 0, + weeklyRemaining: 28000, + isWeeklyUnlimited: false), + MiniMaxModelUsageEntry( + modelName: "image-01", + sessionTotal: 50, + sessionUsed: 0, + sessionRemaining: 50, + sessionResetsAt: now.addingTimeInterval(26 * 60 * 60), + weeklyTotal: 350, + weeklyUsed: 0, + weeklyRemaining: 350, + isWeeklyUnlimited: false), + ]) + .toUsageSnapshot() + + 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.metrics.map(\.title) == ["MiniMax-M*", "speech-hd", "image-01"]) + #expect(model.metrics.count == 3) + #expect(model.usageNotes.isEmpty) + #expect(model.metrics.allSatisfy { $0.percentStyle == .used }) + + let primary = try #require(model.metrics.first) + #expect(abs(primary.percent - (Double(50) / Double(1500) * 100)) < 0.01) + #expect(primary.percentLabel.contains("used")) + #expect(primary.resetText == "Resets in 3h") + #expect(primary.detailLeftText == nil) + #expect(primary.detailRightText == nil) + + let speech = try #require(model.metrics.dropFirst().first) + #expect(speech.resetText == "Resets in 5h") + #expect(speech.detailLeftText == nil) + #expect(speech.detailRightText == nil) + + let image = try #require(model.metrics.last) + #expect(image.resetText == "Resets in 1d 2h") + #expect(image.detailLeftText == nil) + #expect(image.detailRightText == nil) + #expect(!model.metrics.contains(where: { $0.title == "Prompts" })) + } + + @Test + func `minimax card model shows overflow model reset details in usage notes`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1500, + currentPrompts: 50, + remainingPrompts: 1450, + windowMinutes: 300, + usedPercent: Double(50) / Double(1500) * 100, + resetsAt: now.addingTimeInterval(12 * 60 * 60), + updatedAt: now, + modelEntries: [ + MiniMaxModelUsageEntry( + modelName: "MiniMax-M*", + sessionTotal: 1500, + sessionUsed: 50, + sessionRemaining: 1450, + sessionResetsAt: now.addingTimeInterval(3 * 60 * 60), + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + isWeeklyUnlimited: true), + MiniMaxModelUsageEntry( + modelName: "speech-hd", + sessionTotal: 4000, + sessionUsed: 0, + sessionRemaining: 4000, + sessionResetsAt: now.addingTimeInterval(5 * 60 * 60), + weeklyTotal: 28000, + weeklyUsed: 0, + weeklyRemaining: 28000, + isWeeklyUnlimited: false), + MiniMaxModelUsageEntry( + modelName: "image-01", + sessionTotal: 50, + sessionUsed: 0, + sessionRemaining: 50, + sessionResetsAt: now.addingTimeInterval(26 * 60 * 60), + weeklyTotal: 350, + weeklyUsed: 0, + weeklyRemaining: 350, + isWeeklyUnlimited: false), + MiniMaxModelUsageEntry( + modelName: "video-01", + sessionTotal: 100, + sessionUsed: 10, + sessionRemaining: 90, + sessionResetsAt: now.addingTimeInterval(7 * 60 * 60), + weeklyTotal: 700, + weeklyUsed: 10, + weeklyRemaining: 690, + isWeeklyUnlimited: false), + ]) + .toUsageSnapshot() + + 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: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["MiniMax-M*", "speech-hd", "image-01"]) + #expect(model.metrics.allSatisfy { $0.percentStyle == .left }) + let primary = try #require(model.metrics.first) + #expect(abs(primary.percent - (Double(1450) / Double(1500) * 100)) < 0.01) + #expect(primary.percentLabel.contains("left")) + #expect(model.usageNotes == ["video-01: Resets in 7h"]) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorMiniMaxTests.swift b/Tests/CodexBarTests/MenuDescriptorMiniMaxTests.swift new file mode 100644 index 000000000..26371176c --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorMiniMaxTests.swift @@ -0,0 +1,89 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorMiniMaxTests { + @Test + func `minimax menu renders model reset lines`() throws { + let suite = "MenuDescriptorMiniMaxTests-model-details" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let now = Date() + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let minimaxSnapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1500, + currentPrompts: 50, + remainingPrompts: 1450, + windowMinutes: 300, + usedPercent: Double(50) / Double(1500) * 100, + resetsAt: now.addingTimeInterval(12 * 60 * 60), + updatedAt: now, + modelEntries: [ + MiniMaxModelUsageEntry( + modelName: "MiniMax-M*", + sessionTotal: 1500, + sessionUsed: 50, + sessionRemaining: 1450, + sessionResetsAt: now.addingTimeInterval(3 * 60 * 60), + weeklyTotal: nil, + weeklyUsed: nil, + weeklyRemaining: nil, + isWeeklyUnlimited: true), + MiniMaxModelUsageEntry( + modelName: "speech-hd", + sessionTotal: 4000, + sessionUsed: 0, + sessionRemaining: 4000, + sessionResetsAt: now.addingTimeInterval(5 * 60 * 60), + weeklyTotal: 28000, + weeklyUsed: 0, + weeklyRemaining: 28000, + isWeeklyUnlimited: false), + MiniMaxModelUsageEntry( + modelName: "image-01", + sessionTotal: 50, + sessionUsed: 0, + sessionRemaining: 50, + sessionResetsAt: now.addingTimeInterval(26 * 60 * 60), + weeklyTotal: 350, + weeklyUsed: 0, + weeklyRemaining: 350, + isWeeklyUnlimited: false), + ]) + store._setSnapshotForTesting(minimaxSnapshot.toUsageSnapshot(), provider: .minimax) + + let descriptor = MenuDescriptor.build( + provider: .minimax, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains(where: { $0.hasPrefix("MiniMax-M*: Resets in ") })) + #expect(textLines.contains(where: { $0.hasPrefix("speech-hd: Resets in ") })) + #expect(textLines.contains(where: { $0.hasPrefix("image-01: Resets in ") })) + #expect(!textLines.contains(where: { $0.contains("weekly ") })) + } +} diff --git a/Tests/CodexBarTests/MiniMaxCookieFetchTests.swift b/Tests/CodexBarTests/MiniMaxCookieFetchTests.swift new file mode 100644 index 000000000..fbd9dd1cb --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxCookieFetchTests.swift @@ -0,0 +1,353 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct MiniMaxCookieFetchTests { + @Test + func `enriches html snapshot with remains data when html parse succeeds`() async throws { + let registered = URLProtocol.registerClass(MiniMaxCookieFetchStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiniMaxCookieFetchStubURLProtocol.self) + } + MiniMaxCookieFetchStubURLProtocol.handler = nil + MiniMaxCookieFetchStubURLProtocol.requests = [] + } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + + MiniMaxCookieFetchStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + switch url.path { + case let path where path.contains("user-center/payment/coding-plan"): + // Simulate a slow HTML response so the remains task completes first + DispatchQueue.global().sync { Thread.sleep(forTimeInterval: 0.25) } + let html = """ +
Coding Plan
+
Max
+
Available usage: 1,500 prompts / 5 hours
+
Current Usage
+
0% Used
+
Resets in 4 min
+ """ + return Self.makeHTMLResponse(url: url, body: html) + + case let path where path.contains("v1/api/openplatform/coding_plan/remains"): + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "current_interval_total_count": 4000, + "current_interval_usage_count": 4000, + "model_name": "speech-hd", + "current_weekly_total_count": 28000, + "current_weekly_usage_count": 28000, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + }, + { + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "MiniMax-Hailuo-2.3-Fast-6s-768p", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + }, + { + "current_interval_total_count": 1500, + "current_interval_usage_count": 1450, + "model_name": "MiniMax-M*", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + }, + { + "current_interval_total_count": 50, + "current_interval_usage_count": 50, + "model_name": "image-01", + "current_weekly_total_count": 350, + "current_weekly_usage_count": 350, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + return Self.makeJSONResponse(url: url, body: json) + + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) + } + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "session=test-cookie", + region: .global, + environment: [:], + now: now) + + #expect(MiniMaxCookieFetchStubURLProtocol.requests.count == 2) + #expect( + MiniMaxCookieFetchStubURLProtocol.requests.contains { + $0.url?.path.contains("user-center/payment/coding-plan") == true + }) + #expect( + MiniMaxCookieFetchStubURLProtocol.requests.contains { + $0.url?.path.contains("v1/api/openplatform/coding_plan/remains") == true + }) + #expect(snapshot.planName == "Max") + #expect(snapshot.availablePrompts == 1500) + #expect(snapshot.currentPrompts == nil) + #expect(snapshot.remainingPrompts == nil) + #expect(snapshot.windowMinutes == 300) + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == now.addingTimeInterval(240)) + #expect(snapshot.modelEntries.map(\.modelName) == ["MiniMax-M*", "speech-hd", "image-01"]) + } + + @Test + func `returns html snapshot without waiting for slow remains enrichment`() async throws { + let registered = URLProtocol.registerClass(MiniMaxCookieFetchStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiniMaxCookieFetchStubURLProtocol.self) + } + MiniMaxCookieFetchStubURLProtocol.handler = nil + MiniMaxCookieFetchStubURLProtocol.requests = [] + } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + + MiniMaxCookieFetchStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + switch url.path { + case let path where path.contains("user-center/payment/coding-plan"): + let html = """ +
Coding Plan
+
Max
+
Available usage: 1,500 prompts / 5 hours
+
Current Usage
+
0% Used
+
Resets in 4 min
+ """ + return Self.makeHTMLResponse(url: url, body: html) + + case let path where path.contains("v1/api/openplatform/coding_plan/remains"): + // Simulate a slow remains response that exceeds the enrichment timeout (200ms) + DispatchQueue.global().sync { Thread.sleep(forTimeInterval: 1.0) } + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "current_interval_total_count": 1500, + "current_interval_usage_count": 1450, + "model_name": "MiniMax-M*", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "start_time": 1700000000000, + "end_time": 1700018000000, + "remains_time": 240000 + } + ] + } + """ + return Self.makeJSONResponse(url: url, body: json) + + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) + } + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "session=test-cookie", + region: .global, + environment: [:], + now: now) + + // HTML returned successfully, but remains was too slow — no model entries merged + #expect(snapshot.planName == "Max") + #expect(snapshot.availablePrompts == 1500) + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == now.addingTimeInterval(240)) + #expect(snapshot.modelEntries.isEmpty) + + // Allow the slow remains background task to drain so URLProtocol cleanup is safe + try? await Task.sleep(nanoseconds: 1_100_000_000) + } + + @Test + func `awaits remains fallback without timeout when html parse fails`() async throws { + let registered = URLProtocol.registerClass(MiniMaxCookieFetchStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiniMaxCookieFetchStubURLProtocol.self) + } + MiniMaxCookieFetchStubURLProtocol.handler = nil + MiniMaxCookieFetchStubURLProtocol.requests = [] + } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + + MiniMaxCookieFetchStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + switch url.path { + case let path where path.contains("user-center/payment/coding-plan"): + let html = """ +
unexpected html that no longer matches parser
+ """ + return Self.makeHTMLResponse(url: url, body: html) + + case let path where path.contains("v1/api/openplatform/coding_plan/remains"): + // Simulate a slow remains response that exceeds the enrichment timeout. + DispatchQueue.global().sync { Thread.sleep(forTimeInterval: 1.0) } + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "current_interval_total_count": 1500, + "current_interval_usage_count": 1450, + "model_name": "MiniMax-M*", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + return Self.makeJSONResponse(url: url, body: json) + + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) + } + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "session=test-cookie", + region: .global, + environment: [:], + now: now) + + #expect(snapshot.planName == "Max") + #expect(snapshot.availablePrompts == 1500) + #expect(snapshot.currentPrompts == 50) + #expect(snapshot.remainingPrompts == 1450) + #expect(snapshot.windowMinutes == 300) + #expect(snapshot.usedPercent == (Double(50) / Double(1500) * 100)) + #expect(snapshot.modelEntries.map(\.modelName) == ["MiniMax-M*"]) + } + + @Test + func `propagates remains API failure when html parse fallback is used`() async throws { + let registered = URLProtocol.registerClass(MiniMaxCookieFetchStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiniMaxCookieFetchStubURLProtocol.self) + } + MiniMaxCookieFetchStubURLProtocol.handler = nil + MiniMaxCookieFetchStubURLProtocol.requests = [] + } + + MiniMaxCookieFetchStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + switch url.path { + case let path where path.contains("user-center/payment/coding-plan"): + return Self.makeHTMLResponse(url: url, body: "
unexpected html that no longer matches parser
") + + case let path where path.contains("v1/api/openplatform/coding_plan/remains"): + return Self.makeJSONResponse(url: url, body: "{\"error\":\"upstream outage\"}", statusCode: 503) + + default: + return Self.makeJSONResponse(url: url, body: "{}", statusCode: 404) + } + } + + await #expect(throws: MiniMaxUsageError.apiError("HTTP 503")) { + _ = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "session=test-cookie", + region: .global, + environment: [:], + now: Date(timeIntervalSince1970: 1_700_000_000)) + } + } + + private static func makeHTMLResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html; charset=utf-8"])! + return (response, Data(body.utf8)) + } + + private static func makeJSONResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +final class MiniMaxCookieFetchStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + nonisolated(unsafe) static var requests: [URLRequest] = [] + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host?.lowercased() else { return false } + return host == "platform.minimax.io" || host == "platform.minimaxi.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 7cf0524bc..8a3365deb 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -151,6 +151,141 @@ struct MiniMaxUsageParserTests { #expect(snapshot.resetsAt == expectedReset) } + @Test + func `parses and filters multi model coding plan remains`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let speechEnd = start + 5 * 60 * 60 * 1000 + let primaryEnd = start + 3 * 60 * 60 * 1000 + let imageEnd = start + 7 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "current_interval_total_count": 4000, + "current_interval_usage_count": 4000, + "model_name": "speech-hd", + "current_weekly_total_count": 28000, + "current_weekly_usage_count": 28000, + "start_time": \(start), + "end_time": \(speechEnd), + "remains_time": 240000 + }, + { + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "MiniMax-Hailuo-2.3-Fast-6s-768p", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "start_time": \(start), + "end_time": \(speechEnd), + "remains_time": 240000 + }, + { + "current_interval_total_count": 1500, + "current_interval_usage_count": 1450, + "model_name": "MiniMax-M*", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "start_time": \(start), + "end_time": \(primaryEnd), + "remains_time": 240000 + }, + { + "current_interval_total_count": 50, + "current_interval_usage_count": 50, + "model_name": "image-01", + "current_weekly_total_count": 350, + "current_weekly_usage_count": 350, + "start_time": \(start), + "end_time": \(imageEnd), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let expectedPrimaryReset = Date(timeIntervalSince1970: TimeInterval(primaryEnd) / 1000) + let expectedSpeechReset = Date(timeIntervalSince1970: TimeInterval(speechEnd) / 1000) + let expectedImageReset = Date(timeIntervalSince1970: TimeInterval(imageEnd) / 1000) + + #expect(snapshot.planName == "Max") + #expect(snapshot.availablePrompts == 1500) + #expect(snapshot.currentPrompts == 50) + #expect(snapshot.remainingPrompts == 1450) + #expect(abs((snapshot.usedPercent ?? 0) - (Double(50) / Double(1500) * 100)) < 0.01) + #expect(snapshot.resetsAt == expectedPrimaryReset) + #expect(snapshot.modelEntries.map(\.modelName) == ["MiniMax-M*", "speech-hd", "image-01"]) + + let primary = try #require(snapshot.modelEntries.first) + #expect(primary.sessionUsed == 50) + #expect(primary.sessionTotal == 1500) + #expect(primary.sessionResetsAt == expectedPrimaryReset) + #expect(primary.weeklyTotal == nil) + #expect(primary.weeklyUsed == nil) + #expect(primary.isWeeklyUnlimited) + + let speech = try #require(snapshot.modelEntries.first(where: { $0.modelName == "speech-hd" })) + #expect(speech.sessionUsed == 0) + #expect(speech.sessionTotal == 4000) + #expect(speech.sessionResetsAt == expectedSpeechReset) + #expect(speech.weeklyUsed == 0) + #expect(speech.weeklyTotal == 28000) + #expect(!speech.isWeeklyUnlimited) + + let image = try #require(snapshot.modelEntries.first(where: { $0.modelName == "image-01" })) + #expect(image.sessionUsed == 0) + #expect(image.sessionTotal == 50) + #expect(image.sessionResetsAt == expectedImageReset) + #expect(image.weeklyUsed == 0) + #expect(image.weeklyTotal == 350) + } + + @Test + func `prefers model with session quota data as primary remains entry`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "model_name": "MiniMax-M*", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_weekly_total_count": 3000, + "current_weekly_usage_count": 2500, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + }, + { + "model_name": "speech-hd", + "current_interval_total_count": 1000, + "current_interval_usage_count": 900, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + + #expect(snapshot.availablePrompts == 1000) + #expect(snapshot.currentPrompts == 100) + #expect(snapshot.remainingPrompts == 900) + #expect(snapshot.modelEntries.map(\.modelName) == ["speech-hd", "MiniMax-M*"]) + } + @Test func `parses coding plan from next data`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index d5542e293..66bfeab6a 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -72,6 +72,15 @@ struct ProvidersPaneCoverageTests { #expect(!ids.contains(MenuBarMetricPreference.tertiary.rawValue)) } + @Test + func `minimax menu bar metric picker is hidden`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-minimax-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + #expect(pane._test_menuBarMetricPicker(for: .minimax) == nil) + } + @Test func `provider detail plan row formats open router as balance`() { let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 45956f0b6..6035ec075 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -262,6 +262,29 @@ struct StatusMenuTests { } } + @Test + func `minimax dashboard URL follows configured region`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.minimaxAPIRegion = .chinaMainland + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let dashboardURL = controller.dashboardURL(for: .minimax)?.absoluteString + let expectedURL = settings.minimaxAPIRegion.codingPlanURL.absoluteString + #expect(dashboardURL == expectedURL) + } + @Test func `merged switcher includes overview tab when multiple providers enabled`() { self.disableMenuCardsForTesting()