diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d957bd5d5..89a7f97bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added - Agent Sessions: list and focus live local or SSH-discovered Codex and Claude Code sessions from the menu and CLI. +- MiniMax: fetch Token Plan recharge credit balance from the console when a web session cookie is available, including MiniMax Agent desktop cookies and explicit manual or env cookies on API-token refreshes. Thanks @Yuxin-Qiao! +- MiniMax: show console usage-summary KPIs, cost trends, and token usage details in the menu dashboard. Thanks @Yuxin-Qiao! ### Fixed - Ollama: recognize current WorkOS AuthKit sessions during browser-cookie discovery and manual cookie validation. Thanks @joeVenner! diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 2577ae6bae..9b7c30eb08 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -68,6 +68,14 @@ extension UsageMenuCardView.Model { return notes } + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData + { + return Self.minimaxUsageSummaryNotes(usageSummary) + } + if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, let billing = input.snapshot?.minimaxUsage?.billingSummary @@ -183,7 +191,21 @@ extension UsageMenuCardView.Model { } private static func resolveInlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + input.tokenCostInlineDashboardEnabled, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData, + let costSnapshot = primaryCostHistorySnapshot(input: input), + !costSnapshot.daily.isEmpty + { + return self.minimaxCostAndUsageSummaryInlineDashboard( + snapshot: costSnapshot, + usage: usageSummary, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) + } if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider), + input.provider != .minimax || input.tokenCostInlineDashboardEnabled, let tokenSnapshot = primaryCostHistorySnapshot(input: input), !tokenSnapshot.daily.isEmpty { @@ -212,6 +234,13 @@ extension UsageMenuCardView.Model { { return Self.zaiInlineDashboard(modelUsage: modelUsage, now: input.now) } + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData + { + return Self.minimaxUsageSummaryInlineDashboard(usageSummary) + } if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, let billing = input.snapshot?.minimaxUsage?.billingSummary, @@ -246,7 +275,7 @@ extension UsageMenuCardView.Model { } static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { - provider == .openai || provider == .mistral + provider == .openai || provider == .mistral || provider == .minimax } static func primaryCostHistorySnapshot(input: Input) -> CostUsageTokenSnapshot? { @@ -261,6 +290,11 @@ extension UsageMenuCardView.Model { return projected } return input.snapshot == nil ? input.tokenSnapshot : nil + case .minimax: + if let projected = input.snapshot?.minimaxUsage?.usageSummary?.toCostUsageTokenSnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil default: return input.tokenSnapshot } @@ -396,7 +430,9 @@ extension UsageMenuCardView.Model { emphasis: false), .init( title: tokenHistoryTitle, - value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", + value: snapshot.last30DaysTokens.map { + Self.tokenCountString($0, provider: provider) + } ?? "—", emphasis: false), ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest), points: points, @@ -638,6 +674,214 @@ extension UsageMenuCardView.Model { detailLines: details) } + private static func minimaxUsageSummaryInlineDashboard(_ usage: MiniMaxUsageSummary) -> InlineUsageDashboardModel { + let trendDays = usage.trendDays(last: 7) + let points = trendDays.map { + let tokenText = Self.minimaxTokenString($0.totalToken) + let cacheText = UsageFormatter.optionalPercentString($0.cacheHitPercent) + let cacheLine = String(format: L("Cache hit: %@"), cacheText) + return InlineUsageDashboardModel.Point( + id: $0.date, + label: Self.shortDayLabel($0.date), + value: Double($0.totalToken), + accessibilityValue: "\($0.date): \(tokenText) \(L("tokens")) · \(cacheLine)") + } + let cacheText = UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent) + let todayTitle = usage.lastUpdateTime + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap { $0.isEmpty ? nil : String(format: L("%@ usage"), $0) } + ?? L("Today") + + return InlineUsageDashboardModel( + accessibilityLabel: L("MiniMax 7 day token usage trend"), + valueStyle: .tokens, + kpis: [ + .init( + title: todayTitle, + value: Self.minimaxTokenString(usage.latestSnapshotTokens), + emphasis: true), + .init( + title: L("7d tokens"), + value: Self.minimaxTokenString(usage.last7DaysTokens), + emphasis: false), + .init( + title: L("Cache hit"), + value: cacheText, + emphasis: false), + .init( + title: L("30d tokens"), + value: Self.minimaxTokenString(usage.last30DaysTokens), + emphasis: false), + ], + points: points, + detailLines: Self.minimaxUsageSummaryCardDetailLines(usage)) + } + + private static func minimaxLatestUsageKPITitle(_ usage: MiniMaxUsageSummary) -> String { + if let time = usage.lastUpdateTime?.trimmingCharacters(in: .whitespacesAndNewlines), !time.isEmpty { + return String(format: L("%@ usage"), time) + } + return L("Latest usage") + } + + private static func minimaxCostAndUsageSummaryInlineDashboard( + snapshot: CostUsageTokenSnapshot, + usage: MiniMaxUsageSummary, + comparisonPeriodsEnabled: Bool) -> InlineUsageDashboardModel + { + let base = self.costHistoryInlineDashboard( + provider: .minimax, + snapshot: snapshot, + comparisonPeriodsEnabled: comparisonPeriodsEnabled) + let cacheText = UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent) + return InlineUsageDashboardModel( + accessibilityLabel: base.accessibilityLabel, + valueStyle: base.valueStyle, + kpis: [ + base.kpis[0], + base.kpis[1], + .init( + title: self.minimaxLatestUsageKPITitle(usage), + value: self.minimaxTokenString(usage.latestSnapshotTokens), + emphasis: false), + .init( + title: L("7d tokens"), + value: self.minimaxTokenString(usage.last7DaysTokens), + emphasis: false), + .init( + title: L("Cache hit"), + value: cacheText, + emphasis: false), + .init( + title: L("30d tokens"), + value: self.minimaxTokenString(usage.last30DaysTokens), + emphasis: false), + ], + points: base.points, + detailLines: self.minimaxCostDashboardDetailLines(snapshot: snapshot, usage: usage)) + } + + private static func minimaxCostDashboardDetailLines( + snapshot: CostUsageTokenSnapshot, + usage: MiniMaxUsageSummary) -> [String] + { + var details: [String] = [] + if let topModel = Self.topCostModel(from: snapshot.daily) { + details.append("\(L("Top model")): \(Self.shortModelName(topModel))") + } + if let day = usage.snapshotDay, + let todayLine = Self.minimaxTodayBreakdownLine(day) + { + details.append(todayLine) + } + if let hint = Self.tokenUsageHint(provider: .minimax) { + details.append(hint) + } + details.append(L("Language models only; data may be delayed")) + return details + } + + private static func minimaxUsageSummaryCardDetailLines(_ usage: MiniMaxUsageSummary) -> [String] { + var details: [String] = [] + if let day = usage.snapshotDay, + let todayLine = Self.minimaxTodayBreakdownLine(day) + { + details.append(todayLine) + } + if let total = usage.totalTokenConsumed, !total.isEmpty { + details.append("\(L("Total consumed")): \(total)") + } + if let cache = usage.snapshotDay?.cacheHitPercent { + details.append( + String(format: L("Cache hit: %@"), UsageFormatter.optionalPercentString(cache))) + } + if let activeDays = usage.activeDays { + details.append(String(format: L("Active days: %@"), "\(activeDays)")) + } + if let streak = usage.currentConsecutiveDays { + details.append(String(format: L("Streak: %@ days"), "\(streak)")) + } + if let rank = usage.usageRankingPercent { + details.append( + String( + format: L("Usage rank: top %@%%"), + UsageFormatter.decimalString(rank))) + } + if let updated = usage.lastUpdateTime, !updated.isEmpty { + details.append("\(L("Updated")): \(updated)") + } + if let topModel = Self.topMiniMaxUsageSummaryModel(from: usage.days) { + details.append("\(L("Top model")): \(Self.shortModelName(topModel.model)) (\(topModel.tokens))") + } + details.append(L("Language models only; data may be delayed")) + return details + } + + private static func minimaxTodayBreakdownLine(_ day: MiniMaxUsageSummaryDay) -> String? { + if let topModel = day.models.max(by: { lhs, rhs in + if lhs.totalToken == rhs.totalToken { return lhs.model > rhs.model } + return lhs.totalToken < rhs.totalToken + }) { + let cacheText = UsageFormatter.optionalPercentString(topModel.cacheHitPercent) + return String( + format: L("%@ · %@ input · %@ output · %@ cache hit"), + Self.shortModelName(topModel.model), + Self.minimaxTokenString(topModel.inputToken), + Self.minimaxTokenString(topModel.outputToken), + cacheText) + } + guard day.totalToken > 0 else { return nil } + let cacheText = UsageFormatter.optionalPercentString(day.cacheHitPercent) + return String( + format: L("%@ input · %@ output · %@ cache hit"), + Self.minimaxTokenString(day.totalInputToken), + Self.minimaxTokenString(day.totalOutputToken), + cacheText) + } + + private static func minimaxTokenString(_ value: Int) -> String { + UsageFormatter.tokenCountString(value, fractionDigits: 2) + } + + private static func tokenCountString(_ value: Int, provider: UsageProvider) -> String { + if provider == .minimax { + return self.minimaxTokenString(value) + } + return UsageFormatter.tokenCountString(value) + } + + private static func minimaxUsageSummaryNotes(_ usage: MiniMaxUsageSummary) -> [String] { + [ + String( + format: L("Today: %@ tokens"), + self.minimaxTokenString(usage.latestSnapshotTokens)), + String( + format: L("Last 7 days: %@ tokens"), + self.minimaxTokenString(usage.last7DaysTokens)), + String( + format: L("Last 30 days: %@ tokens"), + self.minimaxTokenString(usage.last30DaysTokens)), + ] + self.minimaxUsageSummaryCardDetailLines(usage) + } + + private static func topMiniMaxUsageSummaryModel( + from days: [MiniMaxUsageSummaryDay]) -> (model: String, tokens: String)? + { + var totals: [String: Int] = [:] + for day in days { + for model in day.models { + totals[model.model, default: 0] += model.totalToken + } + } + guard let top = totals.max(by: { lhs, rhs in + if lhs.value == rhs.value { return lhs.key > rhs.key } + return lhs.value < rhs.value + }) else { + return nil + } + return (top.key, Self.minimaxTokenString(top.value)) + } + private static func deepseekInlineDashboard(_ usage: DeepSeekUsageSummary) -> InlineUsageDashboardModel { let symbol = usage.currency == "CNY" ? "¥" : "$" let points = usage.daily.suffix(30).map { @@ -810,7 +1054,8 @@ struct InlineUsageDashboardContent: View { Text(line) .font(.caption) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index b7d5fdbf37..9298fd7dfb 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -182,6 +182,8 @@ extension UsageMenuCardView.Model { L("Reported by OpenAI Admin API organization usage.") case .mistral: L("Reported by Mistral billing usage.") + case .minimax: + L("Estimated from MiniMax usage summary and published pay-as-you-go pricing.") default: nil } @@ -304,11 +306,15 @@ extension UsageMenuCardView.Model { if provider == .minimax, cost.period == "MiniMax points balance" { let balance = String(format: "%.0f", cost.used) + let expiresLine = cost.resetsAt.map { + String(format: L("Expires: %@"), UsageFormatter.preciseDateTimeDescription(from: $0)) + } return ProviderCostSection( title: L("Credits"), percentUsed: nil, spendLine: "\(L("Balance")): \(balance)", - percentLine: nil) + percentLine: nil, + personalSpendLine: expiresLine) } if provider == .openai || provider == .claude || provider == .litellm, cost.limit <= 0 { diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index 22e6882289..1d6575cc26 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -30,7 +30,7 @@ extension UsageMenuCardView.Model { percent: displayPercent, percentStyle: percentStyle, statusText: service.isUnlimited ? "∞ Unlimited" : nil, - resetText: Self.localizedMiniMaxResetDescription(service.resetDescription), + resetText: Self.miniMaxResetText(for: service, input: input), detailText: nil, detailLeftText: usageLabel, detailRightText: nil, @@ -112,6 +112,24 @@ extension UsageMenuCardView.Model { return trimmed.isEmpty ? windowType : trimmed } + private static func miniMaxResetText(for service: MiniMaxServiceUsage, input: Input) -> String { + if service.isUnlimited { + return self.localizedMiniMaxResetDescription(service.resetDescription) + } + if let resetsAt = service.resetsAt { + switch input.resetTimeDisplayStyle { + case .absolute: + let text = UsageFormatter.resetDescription(from: resetsAt, now: input.now) + return L("Resets %@", text) + case .countdown: + let phrase = MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: input.now) + if phrase == "now" { return L("Resets now") } + return L("Resets in %@", phrase) + } + } + return Self.localizedMiniMaxResetDescription(service.resetDescription) + } + private static func localizedMiniMaxResetDescription(_ text: String) -> String { let prefix = "Resets in " guard text.hasPrefix(prefix) else { return text } diff --git a/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift b/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift new file mode 100644 index 0000000000..aded0f1ced --- /dev/null +++ b/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift @@ -0,0 +1,706 @@ +import Charts +import CodexBarCore +import SwiftUI + +@MainActor +struct MiniMaxUsageSummaryChartMenuView: View { + private struct Point: Identifiable { + let id: String + let dateKey: String + let date: Date + let totalTokens: Int + let cacheHitPercent: Double? + let day: MiniMaxUsageSummaryDay + } + + private struct DetailRow: Identifiable { + let id: String + let title: String + let subtitle: String? + let modeSubtitle: String? + let accentColor: Color + } + + private struct KPI: Identifiable { + let id: String + let title: String + let value: String + } + + private struct Model { + let points: [Point] + let pointsByDateKey: [String: Point] + let dateKeys: [(key: String, date: Date)] + let axisDates: [Date] + let barColor: Color + } + + private let usage: MiniMaxUsageSummary + private let width: CGFloat + private let showsSummaryKPIs: Bool + private let onHeightChange: ((CGFloat) -> Void)? + @State private var selectedDateKey: String? + @State private var windowDays: WindowDays = .seven + + private enum WindowDays: Int, CaseIterable { + case seven = 7 + case thirty = 30 + } + + init( + usage: MiniMaxUsageSummary, + showsSummaryKPIs: Bool = true, + onHeightChange: ((CGFloat) -> Void)? = nil, + width: CGFloat) + { + self.usage = usage + self.showsSummaryKPIs = showsSummaryKPIs + self.onHeightChange = onHeightChange + self.width = width + } + + var body: some View { + let model = Self.makeModel(usage: self.usage, windowDays: self.windowDays.rawValue) + let selectedDateKey = self.effectiveSelectedDateKey(model: model) + let detail = self.detailContent(selectedDateKey: selectedDateKey, model: model) + + VStack(alignment: .leading, spacing: Self.outerSpacing) { + if self.showsSummaryKPIs { + self.kpiGrid(Self.summaryKPIs(usage: self.usage)) + } else { + self.usageStatsSection(windowDays: self.windowDays) + } + + self.trendHeaderSection + + if model.points.isEmpty { + Text(L("No data available")) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Chart { + ForEach(model.points) { point in + BarMark( + x: .value(L("Day"), point.date, unit: .day), + y: .value(L("tokens"), point.totalTokens)) + .foregroundStyle(model.barColor) + } + } + .chartYAxis(.hidden) + .chartXAxis(.hidden) + .chartLegend(.hidden) + .frame(height: Self.chartHeight) + .accessibilityLabel( + self.windowDays == .seven + ? L("MiniMax 7 day token usage trend") + : L("MiniMax 30 day token usage trend")) + .chartOverlay { proxy in + GeometryReader { geo in + ZStack(alignment: .topLeading) { + if let rect = self.selectionBandRect( + selectedDateKey: selectedDateKey, + model: model, + proxy: proxy, + geo: geo) + { + Rectangle() + .fill(Self.selectionBandColor) + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + .allowsHitTesting(false) + } + MouseLocationReader { location in + self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + } + } + + if model.axisDates.count == 2 { + HStack { + Text(model.axisDates[0], format: .dateTime.month(.abbreviated).day()) + Spacer() + Text(model.axisDates[1], format: .dateTime.month(.abbreviated).day()) + } + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .frame(height: Self.axisLabelAreaHeight) + .padding(.top, -Self.outerSpacing) + } + + VStack(alignment: .leading, spacing: Self.detailSpacing) { + Text(detail.primary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.tail) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + + if !detail.rows.isEmpty { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: Self.detailSpacing) { + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame( + width: 2, + height: Self.accentHeight(for: row)) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.detailTitleLineHeight, alignment: .leading) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + if let modeSubtitle = row.modeSubtitle { + Text(modeSubtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + } + } + .frame(height: Self.detailRowHeight(for: row), alignment: .leading) + } + } + } + .scrollIndicators( + Self.detailRowsNeedScrolling(itemCount: detail.rows.count) ? .visible : .hidden) + .frame( + height: Self.detailRowsViewportHeight(rows: detail.rows), + alignment: .topLeading) + .id(selectedDateKey) + } + } + .frame( + height: Self.detailBlockHeight(rows: detail.rows), + alignment: .topLeading) + } + + ForEach(Self.footerLines(usage: self.usage), id: \.self) { line in + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(height: Self.footerLineHeight, alignment: .leading) + } + } + .padding(.horizontal, 16) + .padding(.vertical, Self.verticalPadding) + .frame(minWidth: self.width, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onChange(of: self.windowDays) { _, newValue in + self.selectedDateKey = nil + let updatedModel = Self.makeModel(usage: self.usage, windowDays: newValue.rawValue) + self.notifyHeightChange(model: updatedModel) + } + } + + private var trendHeaderSection: some View { + VStack(alignment: .leading, spacing: Self.trendHeaderSpacing) { + Text(L("Usage trend")) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + self.windowToggle + } + .frame(height: Self.trendHeaderBlockHeight, alignment: .topLeading) + } + + private var windowToggle: some View { + Picker( + selection: Binding( + get: { self.windowDays.rawValue }, + set: { self.windowDays = WindowDays(rawValue: $0) ?? .seven })) + { + Text(L("Last 7 days")).tag(WindowDays.seven.rawValue) + Text(L("Last 30 days")).tag(WindowDays.thirty.rawValue) + } label: { + EmptyView() + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityLabel(L("Usage trend")) + } + + static let maxVisibleDetailLines = 4 + private static let kpiTitleLineHeight: CGFloat = 13 + private static let kpiValueLineHeight: CGFloat = 20 + private static let kpiCellInnerSpacing: CGFloat = 1 + private static let kpiRowHeight: CGFloat = 36 + private static let kpiGridRowSpacing: CGFloat = 6 + private static let kpiGridHeight: CGFloat = 78 + private static let trendHeaderSpacing: CGFloat = 6 + private static let trendHeaderTitleHeight: CGFloat = 14 + private static let trendHeaderPickerHeight: CGFloat = 24 + private static var trendHeaderBlockHeight: CGFloat { + self.trendHeaderTitleHeight + self.trendHeaderSpacing + self.trendHeaderPickerHeight + } + + private static let footerLineHeight: CGFloat = 28 + private static let detailPrimaryLineHeight: CGFloat = 32 + private static let detailTitleLineHeight: CGFloat = 16 + private static let detailSubtitleLineHeight: CGFloat = 13 + private static let compactDetailRowHeight: CGFloat = 36 + private static let expandedDetailRowHeight: CGFloat = 44 + private static let detailSpacing: CGFloat = 6 + private static let chartHeight: CGFloat = 114 + private static let axisLabelAreaHeight: CGFloat = 16 + private static let outerSpacing: CGFloat = 10 + static let verticalPadding: CGFloat = 10 + private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) + + private static func totalCardHeight( + rows: [DetailRow], + hasChart: Bool) -> CGFloat + { + var height = self.verticalPadding * 2 + height += self.kpiGridHeight + height += self.outerSpacing + self.trendHeaderBlockHeight + if hasChart { + height += self.chartHeight + height += self.axisLabelAreaHeight + height += self.outerSpacing + height += self.detailBlockHeight(rows: rows) + } else { + height += 20 + } + height += self.outerSpacing + height += self.footerLineHeight + return height + } + + private static func detailBlockHeight(rows: [DetailRow]) -> CGFloat { + guard !rows.isEmpty else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + self.detailSpacing + self.detailRowsViewportHeight(rows: rows) + } + + private static func detailRowsViewportHeight(rows: [DetailRow]) -> CGFloat { + let visibleRows = Array(rows.prefix(self.maxVisibleDetailLines)) + guard !visibleRows.isEmpty else { return 0 } + + let rowHeights = visibleRows.reduce(CGFloat(0)) { total, row in + total + self.detailRowHeight(for: row) + } + let spacing = CGFloat(max(visibleRows.count - 1, 0)) * self.detailSpacing + return rowHeights + spacing + } + + private static func detailRowHeight(for row: DetailRow) -> CGFloat { + self.detailRowHeight(hasModeSubtitle: row.modeSubtitle != nil) + } + + private static func detailRowHeight(hasModeSubtitle: Bool) -> CGFloat { + hasModeSubtitle ? self.expandedDetailRowHeight : self.compactDetailRowHeight + } + + private static func accentHeight(for row: DetailRow) -> CGFloat { + row.subtitle == nil && row.modeSubtitle == nil ? 14 : self.detailRowHeight(for: row) + } + + static func detailViewportRowCount(itemCount: Int) -> Int { + min(max(itemCount, 0), self.maxVisibleDetailLines) + } + + static func detailRowsNeedScrolling(itemCount: Int) -> Bool { + itemCount > self.maxVisibleDetailLines + } + + private func effectiveSelectedDateKey(model: Model) -> String? { + self.selectedDateKey ?? model.dateKeys.last?.key + } + + private func notifyHeightChange(model: Model) { + guard let onHeightChange = self.onHeightChange else { return } + let detail = self.detailContent( + selectedDateKey: self.effectiveSelectedDateKey(model: model), + model: model) + onHeightChange(Self.totalCardHeight( + rows: detail.rows, + hasChart: !model.points.isEmpty)) + } + + private func usageStatsSection(windowDays: WindowDays) -> some View { + self.kpiGrid(Self.usageStatsKPIs(usage: self.usage, windowDays: windowDays)) + } + + private static func usageStatsKPIs(usage: MiniMaxUsageSummary, windowDays: WindowDays) -> [KPI] { + var kpis: [KPI] = [] + if let total = usage.totalTokenConsumed?.trimmingCharacters(in: .whitespacesAndNewlines), + !total.isEmpty + { + kpis.append(KPI(id: "total", title: L("Total consumed"), value: total)) + } + if let peak = self.peakDayTokens(usage: usage) { + kpis.append(KPI( + id: "peak", + title: L("Daily peak"), + value: self.minimaxTokenString(peak))) + } + if let activeDays = usage.activeDays { + kpis.append(KPI(id: "active", title: L("Active days"), value: "\(activeDays)")) + } + if let spend = usage.projectedCostUSD(lastDays: windowDays.rawValue) { + kpis.append(KPI( + id: "spend", + title: self.windowSpendTitle(windowDays: windowDays), + value: UsageFormatter.usdString(spend))) + } + return kpis + } + + private static func peakDayTokens(usage: MiniMaxUsageSummary) -> Int? { + let dayPeak = usage.days.map(\.totalToken).max() ?? 0 + let trendPeak = usage.dailyTokenUsage.max() ?? 0 + let peak = max(dayPeak, trendPeak) + return peak > 0 ? peak : nil + } + + private func kpiGrid(_ kpis: [KPI]) -> some View { + VStack(alignment: .leading, spacing: Self.kpiGridRowSpacing) { + ForEach(Array(stride(from: 0, to: kpis.count, by: 2)), id: \.self) { rowStart in + HStack(alignment: .top, spacing: 0) { + ForEach(kpis[rowStart.. some View { + VStack(alignment: .leading, spacing: Self.kpiCellInnerSpacing) { + Text(kpi.title) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.kpiTitleLineHeight, alignment: .leading) + Text(kpi.value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.72) + .frame(height: Self.kpiValueLineHeight, alignment: .leading) + } + .frame(height: Self.kpiRowHeight, alignment: .topLeading) + } + + private static func summaryKPIs(usage: MiniMaxUsageSummary) -> [KPI] { + let latestTitle: String = { + if let time = usage.lastUpdateTime?.trimmingCharacters(in: .whitespacesAndNewlines), !time.isEmpty { + return String(format: L("%@ usage"), time) + } + return L("Latest usage") + }() + return [ + KPI( + id: "latest", + title: latestTitle, + value: Self.minimaxTokenString(usage.latestSnapshotTokens)), + KPI( + id: "7d", + title: L("7d tokens"), + value: Self.minimaxTokenString(usage.last7DaysTokens)), + KPI( + id: "30d", + title: L("30d tokens"), + value: Self.minimaxTokenString(usage.last30DaysTokens)), + KPI( + id: "cache", + title: L("Cache hit"), + value: UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent)), + ] + } + + static func summaryKPIValues(usage: MiniMaxUsageSummary) -> [String] { + self.summaryKPIs(usage: usage).map(\.value) + } + + private static func footerLines(usage: MiniMaxUsageSummary) -> [String] { + [L("Language models only; data may be delayed")] + } + + private func detailContent(selectedDateKey: String?, model: Model) -> (primary: String, rows: [DetailRow]) { + guard let key = selectedDateKey, + let point = model.pointsByDateKey[key], + let date = Self.date(from: key) + else { + return (L("Hover a bar for details"), []) + } + + let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + let primary = Self.dayDetailPrimary(dayLabel: dayLabel, point: point, usage: self.usage) + return (primary, Self.detailRows(for: point, accentColor: model.barColor, usage: self.usage)) + } + + private static func windowSpendTitle(windowDays: WindowDays) -> String { + switch windowDays { + case .seven: L("7d spend") + case .thirty: L("30d spend") + } + } + + private static func dayDetailPrimary(dayLabel: String, point: Point, usage: MiniMaxUsageSummary) -> String { + let cacheText = UsageFormatter.optionalPercentString(point.cacheHitPercent) + var parts = [ + "\(dayLabel): \(Self.minimaxTokenString(point.totalTokens)) \(L("tokens"))", + String(format: L("Cache hit: %@"), cacheText), + ] + if let costUSD = usage.projectedCostUSD(for: point.day) { + parts.append(UsageFormatter.usdString(costUSD)) + } + return parts.joined(separator: " · ") + } + + private static func makeModel(usage: MiniMaxUsageSummary, windowDays: Int) -> Model { + let sorted = usage.trendDays(last: windowDays).compactMap { day -> Point? in + guard let date = Self.date(from: day.date), day.totalToken >= 0 else { return nil } + return Point( + id: day.date, + dateKey: day.date, + date: date, + totalTokens: day.totalToken, + cacheHitPercent: day.cacheHitPercent, + day: day) + } + let axisDates: [Date] = { + guard let first = sorted.first?.date, let last = sorted.last?.date else { return [] } + if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } + return [first, last] + }() + let pointsByDateKey = Dictionary(uniqueKeysWithValues: sorted.map { ($0.dateKey, $0) }) + let brand = ProviderDescriptorRegistry.descriptor(for: .minimax).branding.color + return Model( + points: sorted, + pointsByDateKey: pointsByDateKey, + dateKeys: sorted.map { ($0.dateKey, $0.date) }, + axisDates: axisDates, + barColor: Color(red: brand.red, green: brand.green, blue: brand.blue)) + } + + private static func date(from key: String) -> Date? { + let parts = key.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) else { return nil } + var comps = DateComponents() + comps.calendar = Calendar.current + comps.timeZone = TimeZone.current + comps.year = year + comps.month = month + comps.day = day + comps.hour = 12 + return comps.date + } + + private static func detailRows( + for point: Point, + accentColor: Color, + usage: MiniMaxUsageSummary) -> [DetailRow] + { + let breakdownByModel = Dictionary( + usage.projectedModelBreakdowns(for: point.day).map { ($0.modelName, $0) }, + uniquingKeysWith: { first, _ in first }) + if !point.day.models.isEmpty { + return point.day.models + .sorted { lhs, rhs in + if lhs.totalToken == rhs.totalToken { return lhs.model < rhs.model } + return lhs.totalToken > rhs.totalToken + } + .enumerated() + .map { index, model in + let breakdown = breakdownByModel[model.model] + let subtitle = breakdown.flatMap { + UsageFormatter.modelCostDetail( + $0.modelName, + costUSD: $0.costUSD, + totalTokens: $0.totalTokens) + } ?? Self.modelTokenSubtitle(model) + return DetailRow( + id: "\(model.model)#\(index)", + title: Self.shortModelName(model.model), + subtitle: subtitle, + modeSubtitle: + "\(L("cache-hit input")): \(Self.minimaxTokenString(model.cacheReadToken)) · " + + "\(L("output")): \(Self.minimaxTokenString(model.outputToken))", + accentColor: accentColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + } + + let cacheMiss = max(0, point.day.totalInputToken - point.day.totalCacheReadToken) + let breakdown = breakdownByModel["Day totals"] + let subtitle = breakdown.flatMap { + UsageFormatter.modelCostDetail( + $0.modelName, + costUSD: $0.costUSD, + totalTokens: $0.totalTokens) + } ?? + "\(L("cache-hit input")): \(Self.minimaxTokenString(point.day.totalCacheReadToken)) · " + + "\(L("cache-miss input")): \(Self.minimaxTokenString(cacheMiss))" + return [ + DetailRow( + id: "summary", + title: L("Day totals"), + subtitle: subtitle, + modeSubtitle: + "\(L("output")): \(Self.minimaxTokenString(point.day.totalOutputToken)) · " + + "\(L("Caches")): \(Self.minimaxTokenString(point.day.totalCacheCreateToken))", + accentColor: accentColor), + ] + } + + private static func modelTokenSubtitle(_ model: MiniMaxUsageSummaryModel) -> String { + let cacheText = UsageFormatter.optionalPercentString(model.cacheHitPercent) + return "\(Self.minimaxTokenString(model.totalToken)) \(L("tokens")) · " + + String(format: L("Cache hit: %@"), cacheText) + } + + private static func breakdownAccentOpacity(for index: Int) -> Double { + max(0.35, 1.0 - (Double(index) * 0.15)) + } + + private static func shortModelName(_ name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 26 else { return trimmed } + return String(trimmed.prefix(25)) + "…" + } + + private static func minimaxTokenString(_ value: Int) -> String { + UsageFormatter.tokenCountString(value, fractionDigits: 2) + } + + private func selectionBandRect( + selectedDateKey: String?, + model: Model, + proxy: ChartProxy, + geo: GeometryProxy) -> CGRect? + { + guard let key = selectedDateKey, + let index = model.dateKeys.firstIndex(where: { $0.key == key }), + let plotAnchor = proxy.plotFrame + else { return nil } + let plotFrame = geo[plotAnchor] + let date = model.dateKeys[index].date + guard let x = proxy.position(forX: date) else { return nil } + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20) + let slotWidth = abs(nextDayX - x) + let halfWidth = slotWidth * 0.25 + 2 + return CGRect( + x: plotFrame.origin.x + x - halfWidth, + y: plotFrame.origin.y, + width: halfWidth * 2, + height: plotFrame.height) + } + + private func updateSelection( + location: CGPoint?, + model: Model, + proxy: ChartProxy, + geo: GeometryProxy) + { + guard let location, + let plotAnchor = proxy.plotFrame + else { return } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { return } + let xInPlot = location.x - plotFrame.origin.x + guard let date: Date = proxy.value(atX: xInPlot) else { return } + guard let nearest = self.nearestDateKey(to: date, model: model) else { return } + + if self.selectedDateKey != nearest { + self.selectedDateKey = nearest + self.notifyHeightChange(model: model) + } + } + + private func nearestDateKey(to date: Date, model: Model) -> String? { + model.dateKeys.min(by: { + abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) + })?.key + } +} + +extension MiniMaxUsageSummaryChartMenuView { + static func _dayDetailPrimaryForTesting(usage: MiniMaxUsageSummary, dateKey: String) -> String? { + guard let day = usage.days.first(where: { $0.date == dateKey }), + let date = Self.date(from: dateKey) + else { + return nil + } + let point = Point( + id: day.date, + dateKey: day.date, + date: date, + totalTokens: day.totalToken, + cacheHitPercent: day.cacheHitPercent, + day: day) + let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + return Self.dayDetailPrimary(dayLabel: dayLabel, point: point, usage: usage) + } + + static func _detailRowCountForTesting(usage: MiniMaxUsageSummary, dateKey: String) -> Int { + let view = Self(usage: usage, width: 320) + let model = Self.makeModel(usage: usage, windowDays: 30) + return view.detailContent(selectedDateKey: dateKey, model: model).rows.count + } + + static func _detailViewportHeightForTesting(modeSubtitlePresence: [Bool]) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.detailRowsViewportHeight(rows: rows) + } + + static func _detailBlockHeightForTesting(modeSubtitlePresence: [Bool]) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.detailBlockHeight(rows: rows) + } + + static func _totalCardHeightForTesting(modeSubtitlePresence: [Bool], hasChart: Bool) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.totalCardHeight(rows: rows, hasChart: hasChart) + } +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 788e792f41..f9177cb73b 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(النظام)"; +"7d" = "7 يومًا"; "30d" = "30 يومًا"; "A managed Codex login is already running. Wait for it to finish before adding " = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة "; "API key" = "مفتاح API"; @@ -53,6 +54,26 @@ "CLI paths" = "CLI المسارات"; "CLI sessions" = "جلسات CLI"; "Caches" = "التخزين المؤقت"; +"Cache hit: %@" = "معدل إصابة التخزين المؤقت: %@"; +"Cache hit" = "إصابة التخزين المؤقت"; +"%@ usage" = "استخدام %@"; +"Latest usage" = "أحدث استخدام"; +"7d tokens" = "استخدام 7 أيام"; +"Last 7 days: %@ tokens" = "آخر 7 أيام: %@ رمز"; +"Last 7 days" = "آخر 7 أيام"; +"Usage trend" = "اتجاه الاستخدام"; +"MiniMax 7 day token usage trend" = "اتجاه استخدام MiniMax لمدة 7 أيام"; +"Language models only; data may be delayed" = "نماذج اللغة فقط؛ قد تتأخر البيانات"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ إدخال · %@ إخراج · إصابة تخزين %@"; +"%@ input · %@ output · %@ cache hit" = "%@ إدخال · %@ إخراج · إصابة تخزين %@"; +"Total consumed" = "إجمالي الاستهلاك"; +"Daily peak" = "ذروة اليوم"; +"Active days" = "أيام النشاط"; +"Active days: %@" = "أيام النشاط: %@"; +"Streak: %@ days" = "سلسلة: %@ يومًا"; +"Usage rank: top %@%%" = "ترتيب الاستخدام: أفضل %@%%"; +"Token usage details" = "تفاصيل استخدام الرموز"; +"Day totals" = "إجمالي اليوم"; "Cancel" = "إلغاء"; "Check for Updates…" = "تحقق من التحديثات..."; "Check for updates automatically" = "تحقق تلقائيا من التحديثات"; @@ -124,6 +145,7 @@ "Enabled" = "مفعل"; "Error" = "خطأ"; "Error simulation" = "محاكاة الخطأ"; +"Expires: %@" = "تنتهي الصلاحية %@"; "Expose troubleshooting tools in the Debug tab." = "اعرض أدوات استكشاف الأخطاء في تبويب التصحيح."; "Failed" = "فشل"; "False" = "خطأ"; @@ -764,6 +786,14 @@ "Top method" = "الطريقة العليا"; "30d cash" = "30d نقدا"; "30d billing history from MiniMax web session" = "30d تاريخ الفوترة من جلسة الويب MiniMax"; +"MiniMax web session expired" = "انتهت صلاحية جلسة الويب MiniMax"; +"MiniMax web session account does not match" = "جلسة الويب MiniMax لا تطابق حساب API"; +"No MiniMax browser session found" = "لم يتم العثور على جلسة متصفح MiniMax"; +"MiniMax web data endpoints are unavailable" = "نقاط نهاية بيانات الويب MiniMax غير متاحة"; +"MiniMax web session unavailable" = "جلسة الويب MiniMax غير متاحة"; +"Re-import MiniMax session from browser" = "إعادة استيراد جلسة MiniMax من المتصفح"; +"Open MiniMax login page" = "فتح صفحة تسجيل الدخول MiniMax"; +"MiniMax web session: %@" = "جلسة الويب MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS قد تتأخر فوترة Cost Explorer."; "Rate limit: %d / %@" = "الحد الأقصى للسعر: %d / %@"; "Key remaining" = "المفتاح المتبقي"; @@ -928,6 +958,7 @@ "Weekly" = "الأسبوعي"; "not detected" = "لم يتم اكتشافه"; "Estimated from local Codex logs for the selected account." = "تم التقدير من سجلات Codex المحلية للحساب المختار."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "مُقدَّر من ملخص استخدام MiniMax وأسعار الدفع حسب الاستخدام المنشورة · قد يختلف عن الفاتورة"; "minimax_usage_amount_format" = "الاستخدام: %@ / %@"; "minimax_used_percent_format" = "المستخدم %@"; "minimax_service_text_generation" = "توليد النصوص"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 54770860a1..63d17b1bb5 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " proveïdors"; "(System)" = "(Sistema)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espera que acabi abans d'afegir "; "API key" = "Clau d'API"; @@ -1030,6 +1031,7 @@ "Est. total (%@): %@" = "Total estimat (%@): %@"; "Est. total (30d): %@" = "Total estimat (30 d): %@"; "Estimated from local Codex logs for the selected account." = "Estimat a partir dels registres locals de Codex per al compte seleccionat."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimat a partir del resum d'ús de MiniMax i els preus publicats de pagament per ús · pot diferir de la factura"; "Google accounts" = "Comptes de Google"; "Google OAuth" = "Google OAuth"; "Hourly Tokens" = "Tokens per hora"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 1750dbb461..8db519b40c 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "Anbieter"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Eine verwaltete Codex-Anmeldung läuft bereits. Warten Sie, bis der Vorgang abgeschlossen ist, bevor Sie ihn hinzufügen"; "API key" = "API-Schlüssel"; @@ -921,6 +922,7 @@ "Weekly" = "Wöchentlich"; "not detected" = "nicht erkannt"; "Estimated from local Codex logs for the selected account." = "Geschätzt aus lokalen Codex-Protokollen für das ausgewählte Konto."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Geschätzt anhand der MiniMax-Nutzungsübersicht und veröffentlichter Pay-as-you-go-Preise · kann von der Rechnung abweichen"; "minimax_usage_amount_format" = "Verwendung: %@ / %@"; "minimax_used_percent_format" = "Verbraucht %@"; "minimax_service_text_generation" = "Textgenerierung"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 4b5b472f22..062037dfaa 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; "API key" = "API key"; @@ -53,6 +54,26 @@ "CLI paths" = "CLI paths"; "CLI sessions" = "CLI sessions"; "Caches" = "Caches"; +"Cache hit: %@" = "Cache hit: %@"; +"Cache hit" = "Cache hit"; +"%@ usage" = "%@ usage"; +"Latest usage" = "Latest usage"; +"7d tokens" = "7d tokens"; +"Last 7 days: %@ tokens" = "Last 7 days: %@ tokens"; +"Last 7 days" = "Last 7 days"; +"Usage trend" = "Usage trend"; +"MiniMax 7 day token usage trend" = "MiniMax 7 day token usage trend"; +"Language models only; data may be delayed" = "Language models only; data may be delayed"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ cache hit"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ cache hit"; +"Total consumed" = "Total consumed"; +"Daily peak" = "Daily peak"; +"Active days" = "Active days"; +"Active days: %@" = "Active days: %@"; +"Streak: %@ days" = "Streak: %@ days"; +"Usage rank: top %@%%" = "Usage rank: top %@%%"; +"Token usage details" = "Token usage details"; +"Day totals" = "Day totals"; "Cancel" = "Cancel"; "Check for Updates…" = "Check for Updates…"; "Check for updates automatically" = "Check for updates automatically"; @@ -124,6 +145,7 @@ "Enabled" = "Enabled"; "Error" = "Error"; "Error simulation" = "Error simulation"; +"Expires: %@" = "Expires: %@"; "Expose troubleshooting tools in the Debug tab." = "Expose troubleshooting tools in the Debug tab."; "Failed" = "Failed"; "False" = "False"; @@ -764,6 +786,14 @@ "Top method" = "Top method"; "30d cash" = "30d cash"; "30d billing history from MiniMax web session" = "30d billing history from MiniMax web session"; +"MiniMax web session expired" = "MiniMax web session expired"; +"MiniMax web session account does not match" = "MiniMax web session account does not match"; +"No MiniMax browser session found" = "No MiniMax browser session found"; +"MiniMax web data endpoints are unavailable" = "MiniMax web data endpoints are unavailable"; +"MiniMax web session unavailable" = "MiniMax web session unavailable"; +"Re-import MiniMax session from browser" = "Re-import MiniMax session from browser"; +"Open MiniMax login page" = "Open MiniMax login page"; +"MiniMax web session: %@" = "MiniMax web session: %@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer billing can lag."; "Rate limit: %d / %@" = "Rate limit: %d / %@"; "Key remaining" = "Key remaining"; @@ -929,6 +959,7 @@ "Weekly" = "Weekly"; "not detected" = "not detected"; "Estimated from local Codex logs for the selected account." = "Estimated from local Codex logs for the selected account."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimated from MiniMax usage summary and published pay-as-you-go pricing."; "minimax_usage_amount_format" = "Usage: %@ / %@"; "minimax_used_percent_format" = "Used %@"; "minimax_service_text_generation" = "Text Generation"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 6df1177e20..3cb9b9d1c0 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " proveedores"; "(System)" = "(Sistema)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir "; "API key" = "Clave de API"; @@ -677,6 +678,7 @@ /* Cost estimation */ "cost_header_estimated" = "Coste (estimado)"; "cost_estimate_hint" = "Estimado a partir de registros locales · puede diferir de tu factura"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimado a partir del resumen de uso de MiniMax y los precios publicados de pago por uso · puede diferir de la factura"; /* Popup panels */ "No usage configured." = "No hay uso configurado."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index e62f320240..486e7c3032 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(سیستم)"; +"7d" = "7 روز"; "30d" = "30 روز"; "A managed Codex login is already running. Wait for it to finish before adding " = "یک ورود Codex مدیریت شده هم اکنون در حال اجرا است. صبر کنید تا تمام شود و بعد را اضافه کنید"; "API key" = "کلید API"; @@ -53,6 +54,26 @@ "CLI paths" = "مسیرهای CLI"; "CLI sessions" = "جلسات CLI"; "Caches" = "کش ها"; +"Cache hit: %@" = "نرخ اصابت کش: %@"; +"Cache hit" = "اصابت کش"; +"%@ usage" = "مصرف %@"; +"Latest usage" = "آخرین مصرف"; +"7d tokens" = "مصرف ۷ روز"; +"Last 7 days: %@ tokens" = "۷ روز گذشته: %@ توکن"; +"Last 7 days" = "۷ روز گذشته"; +"Usage trend" = "روند مصرف"; +"MiniMax 7 day token usage trend" = "روند ۷ روزه توکن MiniMax"; +"Language models only; data may be delayed" = "فقط مدل‌های زبانی؛ داده ممکن است با تأخیر باشد"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ ورودی · %@ خروجی · اصابت کش %@"; +"%@ input · %@ output · %@ cache hit" = "%@ ورودی · %@ خروجی · اصابت کش %@"; +"Total consumed" = "کل مصرف"; +"Daily peak" = "اوج روزانه"; +"Active days" = "روزهای فعال"; +"Active days: %@" = "روزهای فعال: %@"; +"Streak: %@ days" = "پیوستگی: %@ روز"; +"Usage rank: top %@%%" = "رتبه مصرف: %@%% برتر"; +"Token usage details" = "جزئیات مصرف توکن"; +"Day totals" = "جمع روز"; "Cancel" = "لغو"; "Check for Updates…" = "به روزرسانی ها را بررسی کنید..."; "Check for updates automatically" = "به طور خودکار به روزرسانی ها را بررسی کنید"; @@ -124,6 +145,7 @@ "Enabled" = "فعال"; "Error" = "خطا"; "Error simulation" = "شبیه سازی خطا"; +"Expires: %@" = "انقضا: %@"; "Expose troubleshooting tools in the Debug tab." = "ابزارهای عیب یابی را در تب اشکال زدایی آشکار کنید."; "Failed" = "شکست خورد"; "False" = "نادرست"; @@ -764,6 +786,14 @@ "Top method" = "روش تاپ"; "30d cash" = "30d پول نقد"; "30d billing history from MiniMax web session" = "تاریخچه صورتحساب 30d از جلسه وب MiniMax"; +"MiniMax web session expired" = "جلسه وب MiniMax منقضی شده است"; +"MiniMax web session account does not match" = "جلسه وب MiniMax با حساب API مطابقت ندارد"; +"No MiniMax browser session found" = "جلسه مرورگر MiniMax یافت نشد"; +"MiniMax web data endpoints are unavailable" = "نقاط پایانی داده وب MiniMax در دسترس نیستند"; +"MiniMax web session unavailable" = "جلسه وب MiniMax در دسترس نیست"; +"Re-import MiniMax session from browser" = "وارد کردن مجدد جلسه MiniMax از مرورگر"; +"Open MiniMax login page" = "باز کردن صفحه ورود MiniMax"; +"MiniMax web session: %@" = "جلسه وب MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS صورتحساب اکسپلورر هزینه ممکن است کند شود."; "Rate limit: %d / %@" = "محدودیت نرخ: %d / %@"; "Key remaining" = "کلید باقی مانده"; @@ -928,6 +958,7 @@ "Weekly" = "هفتگی"; "not detected" = "شناسایی نشد"; "Estimated from local Codex logs for the selected account." = "برآورد شده از لاگ های محلی Codex حساب انتخاب شده."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "برآورد شده از خلاصه مصرف MiniMax و قیمت‌گذاری رسمی pay-as-you-go · ممکن است با صورتحساب متفاوت باشد"; "minimax_usage_amount_format" = "استفاده: %@ / %@"; "minimax_used_percent_format" = "%@ استفاده شده"; "minimax_service_text_generation" = "تولید متن"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 5dfecb3073..2911d5b577 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " fournisseurs"; "(System)" = "(System)"; +"7d" = "7j"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez qu'il soit terminé avant d'ajouter"; "API key" = "Clé API"; @@ -923,6 +924,7 @@ "Weekly" = "Hebdomadaire"; "not detected" = "non détecté"; "Estimated from local Codex logs for the selected account." = "Estimé à partir des journaux Codex locaux pour le compte sélectionné."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimé à partir du résumé d'utilisation MiniMax et des tarifs pay-as-you-go publiés · peut différer de la facture"; "minimax_usage_amount_format" = "Utilisation : %@ / %@"; "minimax_used_percent_format" = "Utilisé %@"; "minimax_service_text_generation" = "Génération de texte"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index c716d497ee..54e44372e1 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1149,3 +1149,34 @@ "usage_percent_suffix_used" = "usado"; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Non se atopou o token da API de z.ai. Define apiKey en ~/.codexbar/config.json ou Z_AI_API_KEY."; "≈ %d%% run-out risk" = "≈ %d%% de risco de esgotamento"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ cache hit"; +"%@ usage" = "%@ usage"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ cache hit"; +"7d" = "7d"; +"7d tokens" = "7d tokens"; +"Active days" = "Active days"; +"Active days: %@" = "Active days: %@"; +"Cache hit" = "Cache hit"; +"Cache hit: %@" = "Cache hit: %@"; +"Daily peak" = "Daily peak"; +"Day totals" = "Day totals"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimated from MiniMax usage summary and published pay-as-you-go pricing."; +"Expires: %@" = "Expires: %@"; +"Language models only; data may be delayed" = "Language models only; data may be delayed"; +"Last 7 days" = "Last 7 days"; +"Last 7 days: %@ tokens" = "Last 7 days: %@ tokens"; +"Latest usage" = "Latest usage"; +"MiniMax 7 day token usage trend" = "MiniMax 7 day token usage trend"; +"MiniMax web data endpoints are unavailable" = "MiniMax web data endpoints are unavailable"; +"MiniMax web session account does not match" = "MiniMax web session account does not match"; +"MiniMax web session expired" = "MiniMax web session expired"; +"MiniMax web session unavailable" = "MiniMax web session unavailable"; +"MiniMax web session: %@" = "MiniMax web session: %@"; +"No MiniMax browser session found" = "No MiniMax browser session found"; +"Open MiniMax login page" = "Open MiniMax login page"; +"Re-import MiniMax session from browser" = "Re-import MiniMax session from browser"; +"Streak: %@ days" = "Streak: %@ days"; +"Token usage details" = "Token usage details"; +"Total consumed" = "Total consumed"; +"Usage rank: top %@%%" = "Usage rank: top %@%%"; +"Usage trend" = "Usage trend"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 368a6480d3..00147f6cf0 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " penyedia"; "(System)" = "(Sistem)"; +"7d" = "7 hari"; "30d" = "30 hari"; "A managed Codex login is already running. Wait for it to finish before adding " = "Login Codex terkelola sudah berjalan. Tunggu hingga selesai sebelum menambahkan "; "API key" = "Kunci API"; @@ -53,6 +54,26 @@ "CLI paths" = "Path CLI"; "CLI sessions" = "Sesi CLI"; "Caches" = "Cache"; +"Cache hit: %@" = "Hit cache: %@"; +"Cache hit" = "Hit cache"; +"%@ usage" = "Penggunaan %@"; +"Latest usage" = "Penggunaan terbaru"; +"7d tokens" = "Token 7 hari"; +"Last 7 days: %@ tokens" = "7 hari terakhir: %@ token"; +"Last 7 days" = "7 hari terakhir"; +"Usage trend" = "Tren penggunaan"; +"MiniMax 7 day token usage trend" = "Tren penggunaan token MiniMax 7 hari"; +"Language models only; data may be delayed" = "Hanya model bahasa; data mungkin tertunda"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · hit cache %@"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · hit cache %@"; +"Total consumed" = "Total dikonsumsi"; +"Daily peak" = "Puncak harian"; +"Active days" = "Hari aktif"; +"Active days: %@" = "Hari aktif: %@"; +"Streak: %@ days" = "Beruntun: %@ hari"; +"Usage rank: top %@%%" = "Peringkat penggunaan: %@%% teratas"; +"Token usage details" = "Detail penggunaan token"; +"Day totals" = "Total harian"; "Cancel" = "Batal"; "Check for Updates…" = "Periksa Pembaruan…"; "Check for updates automatically" = "Periksa pembaruan secara otomatis"; @@ -124,6 +145,7 @@ "Enabled" = "Aktif"; "Error" = "Error"; "Error simulation" = "Simulasi error"; +"Expires: %@" = "Berlaku hingga: %@"; "Expose troubleshooting tools in the Debug tab." = "Tampilkan alat pemecahan masalah di tab Debug."; "Failed" = "Gagal"; "False" = "Salah"; @@ -766,6 +788,14 @@ "Top method" = "Metode teratas"; "30d cash" = "Kas 30 hari"; "30d billing history from MiniMax web session" = "Riwayat tagihan 30 hari dari sesi web MiniMax"; +"MiniMax web session expired" = "Sesi web MiniMax kedaluwarsa"; +"MiniMax web session account does not match" = "Sesi web MiniMax tidak cocok dengan akun API"; +"No MiniMax browser session found" = "Tidak ada sesi browser MiniMax ditemukan"; +"MiniMax web data endpoints are unavailable" = "Endpoint data web MiniMax tidak tersedia"; +"MiniMax web session unavailable" = "Sesi web MiniMax tidak tersedia"; +"Re-import MiniMax session from browser" = "Impor ulang sesi MiniMax dari browser"; +"Open MiniMax login page" = "Buka halaman login MiniMax"; +"MiniMax web session: %@" = "Sesi web MiniMax: %@"; "AWS Cost Explorer billing can lag." = "Tagihan AWS Cost Explorer dapat tertunda."; "Rate limit: %d / %@" = "Batas rate: %d / %@"; "Key remaining" = "Sisa kunci"; @@ -930,6 +960,7 @@ "Weekly" = "Mingguan"; "not detected" = "tidak terdeteksi"; "Estimated from local Codex logs for the selected account." = "Diperkirakan dari log Codex lokal untuk akun yang dipilih."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Diperkirakan dari ringkasan penggunaan MiniMax dan harga pay-as-you-go yang dipublikasikan · dapat berbeda dari tagihan"; "minimax_usage_amount_format" = "Penggunaan: %@ / %@"; "minimax_used_percent_format" = "Terpakai %@"; "minimax_service_text_generation" = "Pembuatan Teks"; @@ -985,7 +1016,7 @@ "CodexBar could not save the current system account before switching." = "CodexBar tidak dapat menyimpan akun sistem saat ini sebelum beralih."; "CodexBar could not update managed account storage." = "CodexBar tidak dapat memperbarui penyimpanan akun terkelola."; "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar menemukan akun terkelola lain yang sudah menggunakan akun sistem saat ini. Selesaikan akun duplikat sebelum beralih."; -"CodexBar will ask macOS Keychain for \U201c%@\U201d so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk \U201c%@\U201d agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk “%@” agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk token OAuth Claude Code agar dapat mengambil penggunaan Claude Anda. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Amp Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Augment Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 32ca01b05b..06569ccf72 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " provider"; "(System)" = "(Sistema)"; +"7d" = "7 g"; "30d" = "30 g"; "A managed Codex login is already running. Wait for it to finish before adding " = "È già in corso un accesso gestito a Codex. Attendi che termini prima di aggiungere "; "API key" = "Chiave API"; @@ -53,6 +54,26 @@ "CLI paths" = "Percorsi CLI"; "CLI sessions" = "Sessioni CLI"; "Caches" = "Cache"; +"Cache hit: %@" = "Hit della cache: %@"; +"Cache hit" = "Hit della cache"; +"%@ usage" = "Utilizzo %@"; +"Latest usage" = "Ultimo utilizzo"; +"7d tokens" = "Token 7g"; +"Last 7 days: %@ tokens" = "Ultimi 7 giorni: %@ token"; +"Last 7 days" = "Ultimi 7 giorni"; +"Usage trend" = "Andamento utilizzo"; +"MiniMax 7 day token usage trend" = "Trend utilizzo token MiniMax 7 giorni"; +"Language models only; data may be delayed" = "Solo modelli linguistici; i dati possono essere in ritardo"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ hit cache"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ hit cache"; +"Total consumed" = "Totale consumato"; +"Daily peak" = "Picco giornaliero"; +"Active days" = "Giorni attivi"; +"Active days: %@" = "Giorni attivi: %@"; +"Streak: %@ days" = "Serie: %@ giorni"; +"Usage rank: top %@%%" = "Classifica utilizzo: top %@%%"; +"Token usage details" = "Dettagli utilizzo token"; +"Day totals" = "Totali giornalieri"; "Cancel" = "Annulla"; "Check for Updates…" = "Controlla aggiornamenti…"; "Check for updates automatically" = "Controlla automaticamente gli aggiornamenti"; @@ -124,6 +145,7 @@ "Enabled" = "Attivo"; "Error" = "Errore"; "Error simulation" = "Simulazione errore"; +"Expires: %@" = "Scade: %@"; "Expose troubleshooting tools in the Debug tab." = "Espone gli strumenti di risoluzione problemi nella scheda Debug."; "Failed" = "Fallito"; "False" = "Falso"; @@ -766,6 +788,14 @@ "Top method" = "Metodo principale"; "30d cash" = "Spesa 30 g"; "30d billing history from MiniMax web session" = "Cronologia di fatturazione degli ultimi 30 giorni dalla sessione web MiniMax"; +"MiniMax web session expired" = "Sessione web MiniMax scaduta"; +"MiniMax web session account does not match" = "La sessione web MiniMax non corrisponde all'account API"; +"No MiniMax browser session found" = "Nessuna sessione browser MiniMax trovata"; +"MiniMax web data endpoints are unavailable" = "Gli endpoint dati web MiniMax non sono disponibili"; +"MiniMax web session unavailable" = "Sessione web MiniMax non disponibile"; +"Re-import MiniMax session from browser" = "Reimporta la sessione MiniMax dal browser"; +"Open MiniMax login page" = "Apri la pagina di accesso MiniMax"; +"MiniMax web session: %@" = "Sessione web MiniMax: %@"; "AWS Cost Explorer billing can lag." = "La fatturazione di AWS Cost Explorer può avere ritardi."; "Rate limit: %d / %@" = "Limite di richiesta: %d / %@"; "Key remaining" = "Residuo chiave"; @@ -930,6 +960,7 @@ "Weekly" = "Settimanale"; "not detected" = "non rilevato"; "Estimated from local Codex logs for the selected account." = "Stima basata sui log locali di Codex per l'account selezionato."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Stimato dal riepilogo utilizzo MiniMax e dai prezzi pay-as-you-go pubblicati · può differire dalla fattura"; "minimax_usage_amount_format" = "Utilizzo: %@ / %@"; "minimax_used_percent_format" = "Usato %@"; "minimax_service_text_generation" = "Generazione testo"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 44b6d8e5ea..635755fa4a 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 件のプロバイダ"; "(System)" = "(システム)"; +"7d" = "7日"; "30d" = "30日"; "A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; "API key" = "API キー"; @@ -920,6 +921,7 @@ "Weekly" = "週間"; "not detected" = "未検出"; "Estimated from local Codex logs for the selected account." = "選択したアカウントのローカル Codex ログから推定しています。"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "用量サマリーと公式従量課金料金から推定 · 請求額と異なる場合があります"; "minimax_usage_amount_format" = "使用量: %@ / %@"; "minimax_used_percent_format" = "使用済み %@"; "minimax_service_text_generation" = "テキスト生成"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 1e603e1cd6..8e64541f70 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 공급자"; "(System)" = "(시스템)"; +"7d" = "7일"; "30d" = "30일"; "A managed Codex login is already running. Wait for it to finish before adding " = "관리되는 Codex 로그인이 이미 실행 중입니다. 추가하기 전에 완료될 때까지 기다리세요. "; "API key" = "API 키"; @@ -890,6 +891,7 @@ "Weekly" = "주간"; "not detected" = "감지되지 않음"; "Estimated from local Codex logs for the selected account." = "선택한 계정의 로컬 Codex 로그를 기반으로 추정됨."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "사용 요약 및 공식 종량제 요금 기준 추정 · 청구서와 다를 수 있음"; "minimax_usage_amount_format" = "사용량: %@ / %@"; "minimax_used_percent_format" = "%@ 사용됨"; "minimax_service_text_generation" = "텍스트 생성"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index fb53ca9c9e..88fb3f5291 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(Systeem)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat je het toevoegt"; "API key" = "API-sleutel"; @@ -923,6 +924,7 @@ "Weekly" = "Wekelijks"; "not detected" = "niet gedetecteerd"; "Estimated from local Codex logs for the selected account." = "Geschat op basis van lokale Codex-logboeken voor het geselecteerde account."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Geschat op basis van het MiniMax-gebruiksoverzicht en gepubliceerde pay-as-you-go-tarieven · kan afwijken van de factuur"; "minimax_usage_amount_format" = "Gebruik: %@ / %@"; "minimax_used_percent_format" = "Gebruikt %@"; "minimax_service_text_generation" = "Tekst genereren"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 09c348868d..15eee86e60 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Trwa już zarządzane logowanie Codex. Poczekaj na jego zakończenie, zanim dodasz "; "API key" = "Klucz API"; @@ -53,6 +54,26 @@ "CLI paths" = "Ścieżki CLI"; "CLI sessions" = "Sesje CLI"; "Caches" = "Pamięci podręczne"; +"Cache hit: %@" = "Trafienia cache: %@"; +"Cache hit" = "Trafienia cache"; +"%@ usage" = "Użycie %@"; +"Latest usage" = "Ostatnie użycie"; +"7d tokens" = "Tokeny 7d"; +"Last 7 days: %@ tokens" = "Ostatnie 7 dni: %@ tokenów"; +"Last 7 days" = "Ostatnie 7 dni"; +"Usage trend" = "Trend użycia"; +"MiniMax 7 day token usage trend" = "Trend użycia tokenów MiniMax (7 dni)"; +"Language models only; data may be delayed" = "Tylko modele językowe; dane mogą być opóźnione"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ wejście · %@ wyjście · %@ trafień cache"; +"%@ input · %@ output · %@ cache hit" = "%@ wejście · %@ wyjście · %@ trafień cache"; +"Total consumed" = "Łącznie zużyto"; +"Daily peak" = "Dzienny szczyt"; +"Active days" = "Aktywne dni"; +"Active days: %@" = "Aktywne dni: %@"; +"Streak: %@ days" = "Seria: %@ dni"; +"Usage rank: top %@%%" = "Ranking użycia: top %@%%"; +"Token usage details" = "Szczegóły użycia tokenów"; +"Day totals" = "Sumy dzienne"; "Cancel" = "Anuluj"; "Check for Updates…" = "Sprawdź aktualizacje…"; "Check for updates automatically" = "Sprawdzaj aktualizacje automatycznie"; @@ -124,6 +145,7 @@ "Enabled" = "Włączone"; "Error" = "Błąd"; "Error simulation" = "Symulacja błędu"; +"Expires: %@" = "Wygasa: %@"; "Expose troubleshooting tools in the Debug tab." = "Udostępnia narzędzia diagnostyczne na karcie Debug."; "Failed" = "Niepowodzenie"; "False" = "Fałsz"; @@ -766,6 +788,14 @@ "Top method" = "Najlepsza metoda"; "30d cash" = "Gotówka 30 dni"; "30d billing history from MiniMax web session" = "30-dniowa historia rozliczeń z sesji webowej MiniMax"; +"MiniMax web session expired" = "Sesja webowa MiniMax wygasła"; +"MiniMax web session account does not match" = "Sesja webowa MiniMax nie pasuje do konta API"; +"No MiniMax browser session found" = "Nie znaleziono sesji przeglądarki MiniMax"; +"MiniMax web data endpoints are unavailable" = "Punkty końcowe danych webowych MiniMax są niedostępne"; +"MiniMax web session unavailable" = "Sesja webowa MiniMax jest niedostępna"; +"Re-import MiniMax session from browser" = "Ponownie zaimportuj sesję MiniMax z przeglądarki"; +"Open MiniMax login page" = "Otwórz stronę logowania MiniMax"; +"MiniMax web session: %@" = "Sesja webowa MiniMax: %@"; "AWS Cost Explorer billing can lag." = "Dane rozliczeń AWS Cost Explorer mogą być opóźnione."; "Rate limit: %d / %@" = "Limit żądań: %d / %@"; "Key remaining" = "Pozostało klucza"; @@ -930,6 +960,7 @@ "Weekly" = "Tydzień"; "not detected" = "nie wykryto"; "Estimated from local Codex logs for the selected account." = "Oszacowano na podstawie lokalnych logów Codex dla wybranego konta."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Oszacowano na podstawie podsumowania użycia MiniMax i opublikowanych cen pay-as-you-go · może różnić się od rachunku"; "minimax_usage_amount_format" = "Użycie: %@ / %@"; "minimax_used_percent_format" = "Wykorzystano %@"; "minimax_service_text_generation" = "Generowanie tekstu"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 73c4a73438..e4dd18657d 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar "; "API key" = "Chave de API"; @@ -920,6 +921,7 @@ "Weekly" = "Semanal"; "not detected" = "não detectado"; "Estimated from local Codex logs for the selected account." = "Estimado a partir de logs locais do Codex para a conta selecionada."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimado a partir do resumo de uso do MiniMax e dos preços publicados de pay-as-you-go · pode diferir da fatura"; "minimax_usage_amount_format" = "Uso: %@ / %@"; "minimax_used_percent_format" = "Usado %@"; "minimax_service_text_generation" = "Geração de texto"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 03428d64ac..6304616454 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " leverantörer"; "(System)" = "(System)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till "; "API key" = "API-nyckel"; @@ -848,6 +849,7 @@ "Weekly" = "Vecka"; "not detected" = "inte hittad"; "Estimated from local Codex logs for the selected account." = "Uppskattat från lokala Codex-loggar för valt konto."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Uppskattat från MiniMax-användningssammanfattning och publicerade pay-as-you-go-priser · kan skilja sig från fakturan"; "minimax_usage_amount_format" = "Användning: %@ / %@"; "minimax_used_percent_format" = "Förbrukat %@"; "minimax_service_text_generation" = "Textgenerering"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index f537c2d1b9..d3abe0ce91 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "ผู้ให้บริการ "; "(System)" = "(ระบบ)"; +"7d" = "7 วัน"; "30d" = "30 วัน"; "A managed Codex login is already running. Wait for it to finish before adding " = "การเข้าสู่ระบบ Codex ที่มีการจัดการกําลังทํางานอยู่แล้ว รอให้เสร็จก่อนที่จะเพิ่ม "; "API key" = "ปุ่ม API"; @@ -53,6 +54,26 @@ "CLI paths" = "เส้นทาง CLI"; "CLI sessions" = "CLI เซสชัน"; "Caches" = "แคช"; +"Cache hit: %@" = "อัตราแคชฮิต: %@"; +"Cache hit" = "แคชฮิต"; +"%@ usage" = "การใช้งาน %@"; +"Latest usage" = "การใช้งานล่าสุด"; +"7d tokens" = "การใช้งาน 7 วัน"; +"Last 7 days: %@ tokens" = "7 วันที่ผ่านมา: %@ โทเค็น"; +"Last 7 days" = "7 วันที่ผ่านมา"; +"Usage trend" = "แนวโน้มการใช้งาน"; +"MiniMax 7 day token usage trend" = "แนวโน้มโทเค็น MiniMax 7 วัน"; +"Language models only; data may be delayed" = "นับเฉพาะโมเดลภาษา ข้อมูลอาจล่าช้า"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ อินพุต · %@ เอาต์พุต · แคชฮิต %@"; +"%@ input · %@ output · %@ cache hit" = "%@ อินพุต · %@ เอาต์พุต · แคชฮิต %@"; +"Total consumed" = "ใช้ไปทั้งหมด"; +"Daily peak" = "สูงสุดรายวัน"; +"Active days" = "วันที่ใช้งาน"; +"Active days: %@" = "วันที่ใช้งาน: %@"; +"Streak: %@ days" = "ใช้ต่อเนื่อง: %@ วัน"; +"Usage rank: top %@%%" = "อันดับการใช้งาน: สูงสุด %@%%"; +"Token usage details" = "รายละเอียดการใช้โทเค็น"; +"Day totals" = "รวมรายวัน"; "Cancel" = "ยกเลิก"; "Check for Updates…" = "ตรวจสอบการอัปเดต..."; "Check for updates automatically" = "ตรวจสอบการอัปเดตโดยอัตโนมัติ"; @@ -124,6 +145,7 @@ "Enabled" = "เปิดใช้งาน"; "Error" = "ข้อผิดพลาด"; "Error simulation" = "การจําลองข้อผิดพลาด"; +"Expires: %@" = "หมดอายุ %@"; "Expose troubleshooting tools in the Debug tab." = "แสดงเครื่องมือการแก้ไขปัญหาในแท็บ แก้ไขข้อบกพร่อง"; "Failed" = "ล้มเหลว"; "False" = "เท็จ"; @@ -764,6 +786,14 @@ "Top method" = "วิธียอดนิยม"; "30d cash" = "30d เงินสด"; "30d billing history from MiniMax web session" = "30d ประวัติการเรียกเก็บเงินจากเซสชันเว็บ MiniMax"; +"MiniMax web session expired" = "เซสชันเว็บ MiniMax หมดอายุแล้ว"; +"MiniMax web session account does not match" = "เซสชันเว็บ MiniMax ไม่ตรงกับบัญชี API"; +"No MiniMax browser session found" = "ไม่พบเซสชันเบราว์เซอร์ MiniMax"; +"MiniMax web data endpoints are unavailable" = "เอ็นด์พอยต์ข้อมูลเว็บ MiniMax ไม่พร้อมใช้งาน"; +"MiniMax web session unavailable" = "เซสชันเว็บ MiniMax ไม่พร้อมใช้งาน"; +"Re-import MiniMax session from browser" = "นำเข้าเซสชัน MiniMax จากเบราว์เซอร์อีกครั้ง"; +"Open MiniMax login page" = "เปิดหน้าเข้าสู่ระบบ MiniMax"; +"MiniMax web session: %@" = "เซสชันเว็บ MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS การเรียกเก็บเงิน Cost Explorer อาจล่าช้า"; "Rate limit: %d / %@" = "จํากัดอัตรา: %d / %@"; "Key remaining" = "กุญแจที่เหลืออยู่"; @@ -928,6 +958,7 @@ "Weekly" = "รายสัปดาห์"; "not detected" = "ตรวจไม่พบ"; "Estimated from local Codex logs for the selected account." = "ประมาณการจากบันทึก Codex ท้องถิ่นสําหรับบัญชีที่เลือก"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "ประมาณการจากสรุปการใช้งาน MiniMax และราคาแบบจ่ายตามการใช้งานที่เผยแพร่ · อาจแตกต่างจากใบแจ้งหนี้"; "minimax_usage_amount_format" = "การใช้งาน: %@ / %@"; "minimax_used_percent_format" = "ใช้ไป %@"; "minimax_service_text_generation" = "การสร้างข้อความ"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 308b2740a1..27e986d511 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " sağlayıcı"; "(System)" = "(Sistem)"; +"7d" = "7 gün"; "30d" = "30 gün"; "A managed Codex login is already running. Wait for it to finish before adding " = "Yönetilen bir Codex girişi zaten çalışıyor. Eklemeden önce bitmesini bekleyin "; "API key" = "API anahtarı"; @@ -53,6 +54,26 @@ "CLI paths" = "CLI yolları"; "CLI sessions" = "CLI oturumları"; "Caches" = "Önbellekler"; +"Cache hit: %@" = "Önbellek isabeti: %@"; +"Cache hit" = "Önbellek isabeti"; +"%@ usage" = "%@ kullanımı"; +"Latest usage" = "Son kullanım"; +"7d tokens" = "7g token"; +"Last 7 days: %@ tokens" = "Son 7 gün: %@ token"; +"Last 7 days" = "Son 7 gün"; +"Usage trend" = "Kullanım trendi"; +"MiniMax 7 day token usage trend" = "MiniMax 7 günlük token kullanım trendi"; +"Language models only; data may be delayed" = "Yalnızca dil modelleri; veriler gecikebilir"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ girdi · %@ çıktı · %@ önbellek isabeti"; +"%@ input · %@ output · %@ cache hit" = "%@ girdi · %@ çıktı · %@ önbellek isabeti"; +"Total consumed" = "Toplam tüketim"; +"Daily peak" = "Günlük zirve"; +"Active days" = "Aktif günler"; +"Active days: %@" = "Aktif günler: %@"; +"Streak: %@ days" = "Seri: %@ gün"; +"Usage rank: top %@%%" = "Kullanım sırası: ilk %@%%"; +"Token usage details" = "Token kullanım detayları"; +"Day totals" = "Günlük toplamlar"; "Cancel" = "İptal"; "Check for Updates…" = "Güncellemeleri Denetle…"; "Check for updates automatically" = "Güncellemeleri otomatik denetle"; @@ -124,6 +145,7 @@ "Enabled" = "Etkin"; "Error" = "Hata"; "Error simulation" = "Hata simülasyonu"; +"Expires: %@" = "Sona erer: %@"; "Expose troubleshooting tools in the Debug tab." = "Sorun giderme araçlarını Hata Ayıklama sekmesinde göster."; "Failed" = "Başarısız"; "False" = "Yanlış"; @@ -762,6 +784,14 @@ "Top method" = "En çok kullanılan yöntem"; "30d cash" = "30 günlük nakit"; "30d billing history from MiniMax web session" = "MiniMax web oturumundan 30 günlük faturalandırma geçmişi"; +"MiniMax web session expired" = "MiniMax web oturumu süresi doldu"; +"MiniMax web session account does not match" = "MiniMax web oturumu API hesabıyla eşleşmiyor"; +"No MiniMax browser session found" = "MiniMax tarayıcı oturumu bulunamadı"; +"MiniMax web data endpoints are unavailable" = "MiniMax web veri uç noktaları kullanılamıyor"; +"MiniMax web session unavailable" = "MiniMax web oturumu kullanılamıyor"; +"Re-import MiniMax session from browser" = "MiniMax oturumunu tarayıcıdan yeniden içe aktar"; +"Open MiniMax login page" = "MiniMax giriş sayfasını aç"; +"MiniMax web session: %@" = "MiniMax web oturumu: %@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer faturalandırması gecikmeli olabilir."; "Rate limit: %d / %@" = "Hız limiti: %d / %@"; "Key remaining" = "Anahtar kalan"; @@ -925,6 +955,7 @@ "Weekly" = "Haftalık"; "not detected" = "algılanmadı"; "Estimated from local Codex logs for the selected account." = "Seçili hesap için yerel Codex günlüklerinden tahmin edildi."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "MiniMax kullanım özetinden ve yayımlanan kullandıkça öde fiyatlarından tahmin edildi · faturadan farklı olabilir"; "minimax_usage_amount_format" = "Kullanım: %@ / %@"; "minimax_used_percent_format" = "%@ kullanıldı"; "minimax_service_text_generation" = "Metin Üretimi"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index e0bf19ce91..dba9967229 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "провайдерів"; "(System)" = "(Система)"; +"7d" = "7д"; "30d" = "30д"; "A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; "API key" = "Ключ API"; @@ -923,6 +924,7 @@ "Weekly" = "Щотижня"; "not detected" = "не виявлено"; "Estimated from local Codex logs for the selected account." = "Оцінено з локальних журналів Codex для вибраного облікового запису."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Оцінено за підсумком використання MiniMax і опублікованими тарифами pay-as-you-go · може відрізнятися від рахунку"; "minimax_usage_amount_format" = "Використання: %@ / %@"; "minimax_used_percent_format" = "Використаний %@"; "minimax_service_text_generation" = "Генерація тексту"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index ecc7afed71..6433d84c40 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "nhà cung cấp"; "(System)" = "(Hệ thống)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình này hoàn tất trước khi thêm"; "API key" = "API khóa"; @@ -919,6 +920,7 @@ "Weekly" = "Không phát hiện được"; "not detected" = "hàng tuần"; "Estimated from local Codex logs for the selected account." = "Được ước tính từ nhật ký Codex cục bộ cho tài khoản đã chọn."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Ước tính từ tóm tắt sử dụng MiniMax và bảng giá trả theo mức dùng đã công bố · có thể khác hóa đơn"; "minimax_usage_amount_format" = "Mức sử dụng : %@ / %@"; "minimax_used_percent_format" = "Đã sử dụng %@"; "minimax_service_text_generation" = "Tạo văn bản"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 777c2f83b3..bcb1f3259b 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 提供商"; "(System)" = "(System)"; +"7d" = "7 天"; "30d" = "30 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; "API key" = "API 密钥"; @@ -55,6 +56,26 @@ "CLI paths" = "CLI 路径"; "CLI sessions" = "CLI 会话"; "Caches" = "缓存"; +"Cache hit: %@" = "缓存命中率:%@"; +"Cache hit" = "缓存命中"; +"%@ usage" = "%@ 调用量"; +"Latest usage" = "最新调用量"; +"7d tokens" = "近 7 天调用量"; +"Last 7 days: %@ tokens" = "近 7 天:%@ token"; +"Last 7 days" = "近 7 天"; +"Usage trend" = "调用趋势图"; +"MiniMax 7 day token usage trend" = "MiniMax 近 7 天 token 趋势"; +"Language models only; data may be delayed" = "当前仅统计语言模型,数据可能有延迟"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ 输入 · %@ 输出 · 缓存命中 %@"; +"%@ input · %@ output · %@ cache hit" = "%@ 输入 · %@ 输出 · 缓存命中 %@"; +"Total consumed" = "累计调用量"; +"Daily peak" = "单日峰值"; +"Active days" = "活跃天数"; +"Active days: %@" = "活跃天数:%@"; +"Streak: %@ days" = "连续使用:%@ 天"; +"Usage rank: top %@%%" = "用量排名:前 %@%%"; +"Token usage details" = "Token 用量详情"; +"Day totals" = "当日汇总"; "Cancel" = "取消"; "Check for Updates…" = "检查更新…"; "Check for updates automatically" = "自动检查更新"; @@ -125,6 +146,7 @@ "Enabled" = "已启用"; "Error" = "错误"; "Error simulation" = "错误模拟"; +"Expires: %@" = "有效期至 %@"; "Expose troubleshooting tools in the Debug tab." = "在“调试”标签中显示故障排除工具。"; "Failed" = "失败"; "False" = "假"; @@ -657,6 +679,7 @@ "cost_header_estimated" = "费用(估算)"; "cost_estimate_hint" = "根据本地日志估算 · 可能与账单不同"; "Estimated from local Codex logs for the selected account." = "根据所选账户的本地 Codex 日志估算。"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "根据用量详情与官方按量定价估算 · 可能与账单不同"; "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "未检测到启用 AI Assistant 的 JetBrains IDE。请安装 JetBrains IDE 并启用 AI Assistant。"; "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "未配置 OpenRouter API 令牌。请设置 OPENROUTER_API_KEY 环境变量,或在“设置”中配置。"; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "未找到 z.ai API 令牌。请在 ~/.codexbar/config.json 中设置 apiKey,或设置 Z_AI_API_KEY。"; @@ -737,6 +760,14 @@ "Top method" = "主要方法"; "30d cash" = "近 30 天费用"; "30d billing history from MiniMax web session" = "来自 MiniMax Web 会话的近 30 天账单历史"; +"MiniMax web session expired" = "MiniMax 网页会话已过期"; +"MiniMax web session account does not match" = "MiniMax 网页会话与 API 账号不匹配"; +"No MiniMax browser session found" = "未找到 MiniMax 浏览器会话"; +"MiniMax web data endpoints are unavailable" = "MiniMax 网页数据接口不可用"; +"MiniMax web session unavailable" = "MiniMax 网页会话不可用"; +"Re-import MiniMax session from browser" = "从浏览器重新导入 MiniMax 会话"; +"Open MiniMax login page" = "打开 MiniMax 登录页"; +"MiniMax web session: %@" = "MiniMax 网页会话:%@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 账单数据可能延迟。"; "Rate limit: %d / %@" = "速率限制:%d / %@"; "Key remaining" = "密钥剩余额度"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index a9c37f979e..277f30b82e 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 提供者"; "(System)" = "(系統)"; +"7d" = "7 天"; "30d" = "30 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; "API key" = "API 金鑰"; @@ -56,6 +57,26 @@ "CLI paths" = "CLI 路徑"; "CLI sessions" = "CLI 工作階段"; "Caches" = "快取"; +"Cache hit: %@" = "快取命中:%@"; +"Cache hit" = "快取命中"; +"%@ usage" = "%@ 使用量"; +"Latest usage" = "最新使用量"; +"7d tokens" = "近 7 天 token"; +"Last 7 days: %@ tokens" = "近 7 天:%@ token"; +"Last 7 days" = "近 7 天"; +"Usage trend" = "使用量趨勢"; +"MiniMax 7 day token usage trend" = "MiniMax 近 7 天 token 趨勢"; +"Language models only; data may be delayed" = "目前僅統計語言模型,資料可能會有延遲"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ 輸入 · %@ 輸出 · 快取命中 %@"; +"%@ input · %@ output · %@ cache hit" = "%@ 輸入 · %@ 輸出 · 快取命中 %@"; +"Total consumed" = "累計使用量"; +"Daily peak" = "單日峰值"; +"Active days" = "活躍天數"; +"Active days: %@" = "活躍天數:%@"; +"Streak: %@ days" = "連續使用:%@ 天"; +"Usage rank: top %@%%" = "用量排名:前 %@%%"; +"Token usage details" = "Token 用量詳情"; +"Day totals" = "當日彙總"; "Cancel" = "取消"; "Check for Updates…" = "檢查更新…"; "Check for updates automatically" = "自動檢查更新"; @@ -669,6 +690,7 @@ "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列 App。CodexBar 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 CodexBar。"; "cost_header_estimated" = "費用(估算)"; "cost_estimate_hint" = "根據本機記錄估算 · 可能與帳單不同"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "根據用量詳情與官方按量定價估算 · 可能與帳單不同"; "copilot_device_code" = "裝置碼已複製到剪貼簿:%1$@\n\n請到以下網址驗證:%2$@"; "copilot_waiting_text" = "請在瀏覽器中完成登入。\n登入完成後,此視窗會自動關閉。"; "vertex_ai_login_instructions" = "要追蹤 Vertex AI 使用量,請透過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 依照瀏覽器提示登入\n4. 設定你的專案:gcloud config set project PROJECT_ID\n\n要現在開啟終端嗎?"; @@ -774,6 +796,14 @@ "Top method" = "主要方法"; "30d cash" = "近 30 天費用"; "30d billing history from MiniMax web session" = "來自 MiniMax 網頁工作階段的 30 天帳單記錄"; +"MiniMax web session expired" = "MiniMax 網頁工作階段已過期"; +"MiniMax web session account does not match" = "MiniMax 網頁工作階段與 API 帳號不相符"; +"No MiniMax browser session found" = "找不到 MiniMax 瀏覽器工作階段"; +"MiniMax web data endpoints are unavailable" = "MiniMax 網頁資料端點無法使用"; +"MiniMax web session unavailable" = "MiniMax 網頁工作階段無法使用"; +"Re-import MiniMax session from browser" = "從瀏覽器重新匯入 MiniMax 工作階段"; +"Open MiniMax login page" = "開啟 MiniMax 登入頁面"; +"MiniMax web session: %@" = "MiniMax 網頁工作階段:%@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 帳單資料可能延遲。"; "Rate limit: %d / %@" = "速率限制:%d / %@"; "Key remaining" = "金鑰剩餘額度"; @@ -1015,7 +1045,6 @@ "minimax_service_coding_plan_search" = "Coding Plan 搜尋"; "Clearing removes old plan-mode files." = "會移除舊的 plan-mode 檔案。"; "%.0f%% %@" = "%2$@ %1$.0f%%"; -"<1%% %@" = "%1$@ <1%%"; "%@: %@%% used" = "%@:已使用 %@%%"; "terminal_app_subtitle" = "「開啟終端」動作使用的終端"; "Admin API key" = "Admin API 金鑰"; diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 6fce93f050..d0dbd7c1a0 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -272,6 +272,21 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { NSWorkspace.shared.open(url) } + @objc func reimportMiniMaxWebSession() { + CookieHeaderCache.clear(provider: .minimax) + Task { + await self.performStoreRefresh( + for: .minimax, + refreshOpenMenusWhenComplete: true, + interaction: .userInitiated) + } + } + + @objc func openMiniMaxLoginPage() { + guard let url = self.dashboardURL(for: .minimax) else { return } + NSWorkspace.shared.open(url) + } + func dashboardURL( for provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index ed17647d6d..bf75e27935 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -16,6 +16,7 @@ extension StatusItemController { Self.creditsHistoryChartID, Self.costHistoryChartID, Self.usageHistoryChartID, + Self.miniMaxUsageSummaryChartID, Self.storageBreakdownID, Self.statusComponentsID, Self.zaiHourlyUsageChartID, @@ -86,6 +87,14 @@ extension StatusItemController { } else { false } + case Self.miniMaxUsageSummaryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendMiniMaxUsageSummaryChartItem(to: menu, provider: provider, width: width) + } else { + false + } case Self.storageBreakdownID: if let providerRawValue = placeholder.toolTip, let provider = UsageProvider(rawValue: providerRawValue) @@ -160,6 +169,12 @@ extension StatusItemController { } else { false } + case Self.miniMaxUsageSummaryChartID: + if let provider = identity.provider { + self.appendMiniMaxUsageSummaryChartItem(to: menu, provider: provider, width: width) + } else { + false + } case Self.storageBreakdownID: if let provider = identity.provider { self.appendStorageBreakdownItem(to: menu, provider: provider, width: width) @@ -237,6 +252,8 @@ extension StatusItemController { identity.provider.map(self.costHistoryRenderSignature(for:)) ?? "missing-provider" case Self.usageHistoryChartID: identity.provider.map(self.usageHistoryRenderSignature(for:)) ?? "missing-provider" + case Self.miniMaxUsageSummaryChartID: + identity.provider.map(self.miniMaxUsageSummaryRenderSignature(for:)) ?? "missing-provider" case Self.storageBreakdownID: identity.provider.map(self.storageBreakdownRenderSignature(for:)) ?? "missing-provider" case Self.statusComponentsID: @@ -254,6 +271,25 @@ extension StatusItemController { ].joined(separator: "|") } + private func miniMaxUsageSummaryRenderSignature(for provider: UsageProvider) -> String { + guard provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary + else { + return "none" + } + let days = usage.days.suffix(30).map { day in + let models = day.models.map { + "\($0.model):\($0.inputToken):\($0.outputToken):\($0.cacheReadToken):\($0.totalToken)" + }.joined(separator: ";") + return "\(day.date):\(day.totalToken):\(day.cacheHitPercent ?? -1):\(models)" + }.joined(separator: "|") + return [ + usage.lastUpdateTime ?? "", + usage.dailyTokenUsage.suffix(30).map(String.init).joined(separator: ","), + days, + ].joined(separator: "|") + } + private func costHistoryRenderSignature(for provider: UsageProvider) -> String { guard let snapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return "none" } return [ diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift new file mode 100644 index 0000000000..5462a17219 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift @@ -0,0 +1,5 @@ +import AppKit + +extension StatusItemController { + static let miniMaxUsageSummaryChartID = "miniMaxUsageSummaryChart" +} diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift new file mode 100644 index 0000000000..e508256db8 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift @@ -0,0 +1,94 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + @discardableResult + func addMiniMaxUsageSummaryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { + guard let submenu = self.makeMiniMaxUsageSummarySubmenu(provider: provider, width: width) else { return false } + let item = NSMenuItem(title: L("Token usage details"), action: nil, keyEquivalent: "") + item.isEnabled = true + item.representedObject = "miniMaxUsageSummarySubmenu" + item.submenu = submenu + menu.addItem(item) + return true + } + + func makeMiniMaxUsageSummarySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { + guard self.settings.showOptionalCreditsAndExtraUsage, + provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return nil + } + if let width { + return self.makeHostedSubviewPlaceholderMenu( + chartID: Self.miniMaxUsageSummaryChartID, + provider: provider, + width: width) + } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.miniMaxUsageSummaryChartID, provider: provider) + } + + func minimaxShowsUsageSummaryOnMainCard(provider: UsageProvider) -> Bool { + guard provider == .minimax, self.settings.showOptionalCreditsAndExtraUsage else { return false } + guard let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return false + } + return true + } + + @discardableResult + func appendMiniMaxUsageSummaryChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + guard self.settings.showOptionalCreditsAndExtraUsage, + provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return false + } + + if !self.menuCardRenderingEnabledForController { + let chartItem = NSMenuItem() + chartItem.isEnabled = true + chartItem.representedObject = Self.miniMaxUsageSummaryChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } + + final class HostingRelay { + weak var hosting: MenuHostingView? + var minimumHeight: CGFloat = 1 + } + let relay = HostingRelay() + let showsSummaryKPIs = !self.minimaxShowsUsageSummaryOnMainCard(provider: provider) + let chartView = MiniMaxUsageSummaryChartMenuView( + usage: usage, + showsSummaryKPIs: showsSummaryKPIs, + onHeightChange: { height in + let resolved = max(height, relay.minimumHeight) + relay.hosting?.applyMeasuredHeight(width: width, height: resolved) + }, + width: width) + let hosting = MenuHostingView(rootView: chartView) + relay.hosting = hosting + let fittedHeight = self.hostedSubviewFittingHeight(for: hosting, width: width) + relay.minimumHeight = fittedHeight + hosting.applyMeasuredHeight(width: width, height: fittedHeight) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = true + chartItem.representedObject = Self.miniMaxUsageSummaryChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift new file mode 100644 index 0000000000..dd7e9a3831 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift @@ -0,0 +1,74 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + func addMiniMaxUsageSummarySectionIfNeeded(to menu: NSMenu, context: MenuCardContext) { + let provider = context.currentProvider + let width = context.menuWidth + let addedSummary = self.addMiniMaxUsageSummaryMenuItemIfNeeded(to: menu, provider: provider, width: width) + let addedRecovery = self.addMiniMaxWebSessionRecoveryItemsIfNeeded(to: menu, provider: provider) + let addedSource = self.addMiniMaxWebSessionSourceIfNeeded(to: menu, provider: provider) + guard addedSummary || addedRecovery || addedSource else { return } + menu.addItem(.separator()) + } + + @discardableResult + private func addMiniMaxWebSessionSourceIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard provider == .minimax, + self.settings.showOptionalCreditsAndExtraUsage, + let usage = self.store.snapshot(for: provider)?.minimaxUsage, + case let .valid(sourceLabel) = usage.webSessionState + else { return false } + let item = NSMenuItem( + title: L("MiniMax web session: %@", sourceLabel), + action: nil, + keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + return true + } + + @discardableResult + private func addMiniMaxWebSessionRecoveryItemsIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard provider == .minimax, + self.settings.showOptionalCreditsAndExtraUsage, + let usage = self.store.snapshot(for: provider)?.minimaxUsage, + usage.usageSummary == nil, + usage.pointsBalance == nil, + usage.webSessionState != .notChecked + else { return false } + + let statusTitle = switch usage.webSessionState { + case .expired: + L("MiniMax web session expired") + case .accountMismatch: + L("MiniMax web session account does not match") + case .unavailable(reason: .noBrowserSession): + L("No MiniMax browser session found") + case .unavailable(reason: .keychainAccessDisabled): + L("Keychain access is disabled in Advanced, so browser cookie import is unavailable.") + case .unavailable(reason: .endpointsUnavailable): + L("MiniMax web data endpoints are unavailable") + case .notChecked, .valid: + L("MiniMax web session unavailable") + } + let status = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + + let reimport = NSMenuItem( + title: L("Re-import MiniMax session from browser"), + action: #selector(self.reimportMiniMaxWebSession), + keyEquivalent: "") + reimport.target = self + menu.addItem(reimport) + + let login = NSMenuItem( + title: L("Open MiniMax login page"), + action: #selector(self.openMiniMaxLoginPage), + keyEquivalent: "") + login.target = self + menu.addItem(login) + return true + } +} diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 46f8d241f1..543396b9f7 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -77,6 +77,9 @@ extension UsageStore { snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() case .mistral: snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + case .minimax: + snapshot?.minimaxUsage?.usageSummary? + .toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) default: nil } @@ -84,7 +87,7 @@ extension UsageStore { nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { switch provider { - case .mistral, .openai: + case .mistral, .openai, .minimax: true default: false diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index 56e77e2c75..e86d220bdd 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -8,7 +8,10 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { private static let codingPlanQuery = "cycle_type=3" private static let remainsPath = "v1/api/openplatform/coding_plan/remains" private static let tokenPlanRemainsPath = "v1/token_plan/remains" + private static let tokenPlanCreditPath = "backend/account/token_plan_credit" + private static let tokenPlanUsageSummaryPath = "backend/account/token_plan/usage_summary" private static let billingHistoryPath = "account/amount" + private static let usageDashboardPath = "console/usage" public var displayName: String { switch self { @@ -37,6 +40,23 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { } } + public var webBaseURLString: String { + switch self { + case .global: + "https://www.minimax.io" + case .chinaMainland: + "https://www.minimaxi.com" + } + } + + public var tokenPlanCreditURL: URL { + URL(string: self.webBaseURLString)!.appendingPathComponent(Self.tokenPlanCreditPath) + } + + public var tokenPlanUsageSummaryURL: URL { + URL(string: self.webBaseURLString)!.appendingPathComponent(Self.tokenPlanUsageSummaryPath) + } + public var codingPlanURL: URL { var components = URLComponents(string: self.baseURLString)! components.path = "/" + Self.codingPlanPath @@ -63,10 +83,7 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { } public var dashboardURL: URL { - var components = URLComponents(string: self.baseURLString)! - components.path = "/" + Self.codingPlanPath - components.query = Self.codingPlanQuery - return components.url! + URL(string: self.baseURLString)!.appendingPathComponent(Self.usageDashboardPath) } public func billingHistoryURL(page: Int, limit: Int) -> URL { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieImporter.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieImporter.swift index 5841de1783..71467f9f8f 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxCookieImporter.swift @@ -12,9 +12,11 @@ public enum MiniMaxCookieImporter { private static let cookieDomains = [ "platform.minimax.io", "openplatform.minimax.io", + "www.minimax.io", "minimax.io", "platform.minimaxi.com", "openplatform.minimaxi.com", + "www.minimaxi.com", "minimaxi.com", ] diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift new file mode 100644 index 0000000000..ccf19336d7 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift @@ -0,0 +1,272 @@ +import Foundation +#if os(macOS) +import CommonCrypto +import Security +import SQLite3 + +enum MiniMaxDesktopCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.minimaxCookie) + private static let sourceLabel = "MiniMax Agent" + private static let webCookieHosts: Set = [ + "www.minimaxi.com", + "www.minimax.io", + "platform.minimaxi.com", + "platform.minimax.io", + ] + private static let safeStorageLabels: [(service: String, account: String)] = [ + ("MiniMax Safe Storage", "MiniMax"), + ("Chromium Safe Storage", "MiniMax"), + ] + + static func cookiesDatabaseURL(fileManager: FileManager = .default) -> URL { + fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/MiniMax/Cookies") + } + + static func importSession( + databaseURL: URL? = nil, + fileManager: FileManager = .default, + decryptionKeys: [Data]? = nil) -> MiniMaxCookieImporter.SessionInfo? + { + let url = databaseURL ?? self.cookiesDatabaseURL(fileManager: fileManager) + guard fileManager.isReadableFile(atPath: url.path) else { return nil } + do { + let records = try self.loadRecords(from: url, decryptionKeys: decryptionKeys) + guard !records.isEmpty else { return nil } + let cookies = self.makeHTTPCookies(from: records) + guard !cookies.isEmpty else { return nil } + self.log.debug( + "Imported MiniMax desktop cookies", + metadata: ["count": "\(cookies.count)", "names": self.cookieNames(from: cookies)]) + return MiniMaxCookieImporter.SessionInfo(cookies: cookies, sourceLabel: self.sourceLabel) + } catch { + self.log.debug("MiniMax desktop cookie import failed: \(error.localizedDescription)") + return nil + } + } + + private struct Record { + let domain: String + let name: String + let value: String + } + + private static func loadRecords(from url: URL, decryptionKeys: [Data]? = nil) throws -> [Record] { + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw MiniMaxDesktopCookieImportError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let sql = """ + SELECT host_key, name, value, encrypted_value + FROM cookies + WHERE host_key LIKE '%minimax%' + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw MiniMaxDesktopCookieImportError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + var records: [Record] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let domain = self.columnText(stmt, index: 0), + let name = self.columnText(stmt, index: 1), + self.matchesMiniMaxDomain(domain) + else { + continue + } + let plain = self.columnText(stmt, index: 2) ?? "" + let value: String? = if !plain.isEmpty { + plain + } else if let encrypted = self.readBlob(stmt, index: 3) { + self.decrypt(encrypted, keys: decryptionKeys ?? self.derivedKeys()) + } else { + nil + } + guard let value, !value.isEmpty else { continue } + records.append(Record(domain: domain, name: name, value: value)) + } + return self.deduplicated(records) + } + + private static func deduplicated(_ records: [Record]) -> [Record] { + var merged: [String: Record] = [:] + for record in records { + let key = "\(record.name)|\(record.domain)" + merged[key] = record + } + return Array(merged.values).sorted { + if $0.name == $1.name { return $0.domain < $1.domain } + if $0.name == "_token" { return true } + if $1.name == "_token" { return false } + return $0.name < $1.name + } + } + + private static func matchesMiniMaxDomain(_ domain: String) -> Bool { + let normalized = domain.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + if self.webCookieHosts.contains(normalized) { return true } + return normalized == "minimaxi.com" || normalized == "minimax.io" + } + + private static func makeHTTPCookies(from records: [Record]) -> [HTTPCookie] { + records.compactMap { record in + let domain = record.domain.hasPrefix(".") ? String(record.domain.dropFirst()) : record.domain + guard let cookie = HTTPCookie(properties: [ + .domain: domain, + .name: record.name, + .path: "/", + .value: record.value, + .secure: "TRUE", + ]) else { + return nil + } + return cookie + } + } + + private static func cookieNames(from cookies: [HTTPCookie]) -> String { + cookies.map { "\($0.name)@\($0.domain)" }.sorted().joined(separator: ", ") + } + + private static func columnText(_ stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let value = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: value) + } + + private static func readBlob(_ stmt: OpaquePointer?, index: Int32) -> Data? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + let length = Int(sqlite3_column_bytes(stmt, index)) + guard length > 0, let bytes = sqlite3_column_blob(stmt, index) else { return nil } + return Data(bytes: bytes, count: length) + } + + private static func derivedKeys() -> [Data] { + var keys: [Data] = [] + for label in self.safeStorageLabels { + if let password = self.safeStoragePassword(service: label.service, account: label.account) { + keys.append(self.deriveKey(from: password)) + } + } + return keys + } + + private static func safeStoragePassword(service: String, account: String) -> String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + + var result: AnyObject? + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func deriveKey(from password: String) -> Data { + let salt = Data("saltysalt".utf8) + var key = Data(count: kCCKeySizeAES128) + let keyLength = key.count + _ = key.withUnsafeMutableBytes { keyBytes in + password.utf8CString.withUnsafeBytes { passBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passBytes.bindMemory(to: Int8.self).baseAddress, + passBytes.count - 1, + saltBytes.bindMemory(to: UInt8.self).baseAddress, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + keyBytes.bindMemory(to: UInt8.self).baseAddress, + keyLength) + } + } + } + return key + } + + private static func decrypt(_ encryptedValue: Data, keys: [Data]) -> String? { + for key in keys { + if let value = self.decrypt(encryptedValue, key: key) { + return value + } + } + return nil + } + + private static func decrypt(_ encryptedValue: Data, key: Data) -> String? { + guard encryptedValue.count > 3 else { return nil } + let prefix = String(data: encryptedValue.prefix(3), encoding: .utf8) + guard prefix == "v10" else { return nil } + + let payload = Data(encryptedValue.dropFirst(3)) + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var outLength = 0 + var out = Data(count: payload.count + kCCBlockSizeAES128) + let outCapacity = out.count + + let status = out.withUnsafeMutableBytes { outBytes in + payload.withUnsafeBytes { payloadBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + payloadBytes.baseAddress, + payload.count, + outBytes.baseAddress, + outCapacity, + &outLength) + } + } + } + } + + guard status == kCCSuccess else { return nil } + out.count = outLength + + if let value = String(data: out, encoding: .utf8), !value.isEmpty { + return value + } + if out.count > 32 { + let trimmed = out.dropFirst(32) + if let value = String(data: trimmed, encoding: .utf8), !value.isEmpty { + return value + } + } + return nil + } +} + +enum MiniMaxDesktopCookieImportError: LocalizedError { + case sqliteFailed(String) + + var errorDescription: String? { + switch self { + case let .sqliteFailed(message): + "MiniMax desktop cookie database read failed: \(message)" + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index fab28f9f9e..6142563eca 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -21,15 +21,15 @@ public enum MiniMaxProviderDescriptor { isPrimaryProvider: false, usesAccountFallback: false, browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, - dashboardURL: "https://platform.minimax.io/user-center/payment/coding-plan?cycle_type=3", + dashboardURL: "https://platform.minimax.io/console/usage", statusPageURL: nil), branding: ProviderBranding( iconStyle: .minimax, iconResourceName: "ProviderIcon-minimax", color: ProviderColor(red: 254 / 255, green: 96 / 255, blue: 60 / 255)), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "MiniMax cost summary is not supported." }), + supportsTokenCost: true, + noDataMessage: { "No priced MiniMax usage summary data yet." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web, .api], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), @@ -85,8 +85,63 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { guard let apiToken = ProviderTokenResolver.minimaxToken(environment: context.env) else { throw MiniMaxAPISettingsError.missingToken } - let region = context.settings?.minimax?.apiRegion ?? .global - let usage = try await MiniMaxUsageFetcher.fetchUsage(apiToken: apiToken, region: region) + let preferredRegion = context.settings?.minimax?.apiRegion ?? .global + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: apiToken, + region: preferredRegion) + var usage = apiResult.snapshot + let candidates = context.includeOptionalUsage + ? MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates(context: context) + : [] + var rejectedCredentials = false + var accountMismatch = false + for candidate in candidates { + let cookieOverride = candidate.override + guard let cookie = MiniMaxCookieHeader.normalized(from: cookieOverride.cookieHeader) else { continue } + let fetchContext = MiniMaxUsageFetcher.WebFetchContext( + cookie: cookie, + authorizationToken: cookieOverride.authorizationToken, + region: apiResult.resolvedRegion, + environment: context.env, + transport: ProviderHTTPClient.shared) + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: usage, + context: fetchContext, + groupID: cookieOverride.groupID) + usage = attempt.snapshot + if attempt.accountMismatch { + accountMismatch = true + if candidate.isCached { + CookieHeaderCache.clear(provider: .minimax) + } + continue + } + if attempt.receivedWebData { + MiniMaxWebEnrichmentResolver.cacheValidated(candidate) + usage = usage.withWebSessionState(.valid(sourceLabel: candidate.sourceLabel)) + break + } + if attempt.rejectedCredentials { + rejectedCredentials = true + if candidate.isCached { + CookieHeaderCache.clear(provider: .minimax) + } + } + } + if context.includeOptionalUsage, usage.webSessionState == .notChecked { + let state: MiniMaxWebSessionState = if accountMismatch { + .accountMismatch + } else if rejectedCredentials { + .expired + } else if KeychainAccessGate.isDisabled { + .unavailable(reason: .keychainAccessDisabled) + } else if candidates.isEmpty { + .unavailable(reason: .noBrowserSession) + } else { + .unavailable(reason: .endpointsUnavailable) + } + usage = usage.withWebSessionState(state) + } return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "api") @@ -115,6 +170,9 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { return true } #if os(macOS) + if MiniMaxDesktopCookieImporter.importSession() != nil { + return true + } if let cached = CookieHeaderCache.load(provider: .minimax), !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -152,6 +210,30 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { let tokenContext = Self.loadTokenContext(browserDetection: context.browserDetection) var lastError: Error? + if let session = MiniMaxDesktopCookieImporter.importSession() { + switch await Self.attemptFetch( + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel, + tokenContext: tokenContext, + logLabel: "desktop", + fetchContext: fetchContext) + { + case let .success(snapshot): + CookieHeaderCache.store( + provider: .minimax, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + case let .failure(error): + lastError = error + if !Self.shouldTryNextBrowser(for: error) { + throw error + } + } + } + if let cached = CookieHeaderCache.load(provider: .minimax), !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -247,9 +329,10 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { } private static func resolveCookieOverride(context: ProviderFetchContext) -> MiniMaxCookieOverride? { - if let settings = context.settings?.minimax { - guard settings.cookieSource == .manual else { return nil } - return MiniMaxCookieHeader.override(from: settings.manualCookieHeader) + if let settings = context.settings?.minimax, settings.cookieSource == .manual { + if let override = MiniMaxCookieHeader.override(from: settings.manualCookieHeader) { + return override + } } guard let raw = ProviderTokenResolver.minimaxCookie(environment: context.env) else { return nil @@ -306,6 +389,7 @@ struct MiniMaxCodingPlanFetchStrategy: ProviderFetchStrategy { let normalizedLabel = Self.normalizeStorageLabel(sourceLabel) let tokenCandidates = tokenContext.tokensByLabel[normalizedLabel] ?? [] let groupID = tokenContext.groupIDByLabel[normalizedLabel] + ?? MiniMaxCookieHeader.override(from: cookieHeader)?.groupID let cookieToken = Self.cookieValue(named: "HERTZ-SESSION", in: cookieHeader) var attempts: [String?] = tokenCandidates.map(\.self) if let cookieToken, !tokenCandidates.contains(cookieToken) { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift index 71101e8ca3..9588cab3cc 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxServiceUsage.swift @@ -204,22 +204,44 @@ extension MiniMaxServiceUsage { return nil } - public static func generateResetDescription(resetsAt: Date, now: Date = Date()) -> String { - let calendar = Calendar.current - let components = calendar.dateComponents([.hour, .minute], from: now, to: resetsAt) - - guard let hours = components.hour, let minutes = components.minute else { - return "Resets soon" + public static func resetCountdownPhrase(from resetsAt: Date, now: Date = Date()) -> String { + let seconds = max(0, resetsAt.timeIntervalSince(now)) + if seconds < 1 { return "now" } + if seconds < 60 { + let value = max(1, Int(ceil(seconds))) + return value == 1 ? "1 second" : "\(value) seconds" } - if hours > 0, minutes > 0 { - return "Resets in \(hours) hours \(minutes) minutes" - } else if hours > 0 { - return "Resets in \(hours) hour\(hours > 1 ? "s" : "")" - } else if minutes > 0 { - return "Resets in \(minutes) minute\(minutes > 1 ? "s" : "")" - } else { - return "Resets now" + let totalMinutes = max(1, Int(ceil(seconds / 60.0))) + let days = totalMinutes / (24 * 60) + let hours = (totalMinutes / 60) % 24 + let minutes = totalMinutes % 60 + + if days > 0 { + var parts: [String] = [] + parts.append(days == 1 ? "1 day" : "\(days) days") + if hours > 0 { + parts.append(hours == 1 ? "1 hour" : "\(hours) hours") + } + if minutes > 0 { + parts.append(minutes == 1 ? "1 minute" : "\(minutes) minutes") + } + return parts.joined(separator: " ") } + if hours > 0 { + if minutes > 0 { + let hourPart = hours == 1 ? "1 hour" : "\(hours) hours" + let minutePart = minutes == 1 ? "1 minute" : "\(minutes) minutes" + return "\(hourPart) \(minutePart)" + } + return hours == 1 ? "1 hour" : "\(hours) hours" + } + return totalMinutes == 1 ? "1 minute" : "\(totalMinutes) minutes" + } + + public static func generateResetDescription(resetsAt: Date, now: Date = Date()) -> String { + let phrase = self.resetCountdownPhrase(from: resetsAt, now: now) + if phrase == "now" { return "Resets now" } + return "Resets in \(phrase)" } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSettingsReader.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSettingsReader.swift index fe0e864f64..9d34dcdc29 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSettingsReader.swift @@ -11,12 +11,14 @@ public struct MiniMaxSettingsReader: Sendable { public static let hostKey = "MINIMAX_HOST" public static let codingPlanURLKey = "MINIMAX_CODING_PLAN_URL" public static let remainsURLKey = "MINIMAX_REMAINS_URL" + public static let tokenPlanCreditURLKey = "MINIMAX_TOKEN_PLAN_CREDIT_URL" public static let billingHistoryURLKey = "MINIMAX_BILLING_HISTORY_URL" public static let requireProviderEndpointOverridesKey = "MINIMAX_REQUIRE_PROVIDER_ENDPOINT_OVERRIDES" private static let endpointOverrideKeys = [ Self.hostKey, Self.codingPlanURLKey, Self.remainsURLKey, + Self.tokenPlanCreditURLKey, Self.billingHistoryURLKey, ] @@ -71,6 +73,14 @@ public struct MiniMaxSettingsReader: Sendable { policy: self.endpointOverrideHostPolicy(environment: environment)) } + public static func tokenPlanCreditURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { + self.endpointValidator.validatedURL( + self.cleaned(environment[self.tokenPlanCreditURLKey]), + policy: self.endpointOverrideHostPolicy(environment: environment)) + } + public static func billingHistoryURL( environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift index de2b787979..3e3510140a 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -271,4 +271,34 @@ extension MiniMaxUsageFetcher { return snapshot } } + + static func attachingTokenPlanCreditIfAvailable( + to snapshot: MiniMaxUsageSnapshot, + context: WebFetchContext, + groupID: String?) async throws -> MiniMaxUsageSnapshot + { + guard snapshot.pointsBalance == nil || snapshot.pointsBalanceExpiresAt == nil, + MiniMaxCookieHeader.normalized(from: context.cookie) != nil + else { + return snapshot + } + + let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID + do { + let credit = try await MiniMaxTokenPlanCreditFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + return snapshot.withPointsBalanceIfMissing(credit.balance, expiresAt: credit.expiresAt) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + Self.log.debug("MiniMax token plan credit unavailable: \(error.localizedDescription)") + return snapshot + } + } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift new file mode 100644 index 0000000000..aa67346a39 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift @@ -0,0 +1,183 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +enum MiniMaxTokenPlanCreditFetcher { + struct CreditSnapshot: Equatable { + let balance: Double? + let expiresAt: Date? + let groupIDs: Set + } + + static func fetch( + cookieHeader: String, + groupID: String?, + region: MiniMaxAPIRegion, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> CreditSnapshot + { + let effectiveRegion = Self.effectiveRegion(for: region, environment: environment) + let url = try self.resolveCreditURL(region: region, environment: environment) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + if let groupID = groupID?.trimmingCharacters(in: .whitespacesAndNewlines), !groupID.isEmpty { + request.setValue(groupID, forHTTPHeaderField: "x-group-id") + } + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "x-requested-with") + let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + request.setValue(userAgent, forHTTPHeaderField: "user-agent") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") + let origin = MiniMaxSubscriptionMetadataFetcher.platformOriginURL(region: effectiveRegion) + request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") + request.setValue(origin.absoluteString + "/", forHTTPHeaderField: "referer") + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") + } + return try self.parseSnapshot(data: response.data) + } + + static func parseBalance(data: Data) throws -> Double? { + try self.parseSnapshot(data: data).balance + } + + static func parseSnapshot(data: Data) throws -> CreditSnapshot { + let object = try JSONSerialization.jsonObject(with: data, options: []) + guard let payload = object as? [String: Any] else { + throw MiniMaxUsageError.parseFailed("MiniMax token plan credit payload was not an object.") + } + try self.validateBaseResponse(in: payload) + return CreditSnapshot( + balance: self.balance(from: payload), + expiresAt: self.earliestExpiry(from: payload), + groupIDs: self.groupIDs(from: payload)) + } + + static func resolveCreditURL(region: MiniMaxAPIRegion, environment: [String: String]) throws -> URL { + if let rejectedKey = MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: environment) { + throw ProviderEndpointOverrideError.minimax(rejectedKey) + } + if let override = MiniMaxSettingsReader.tokenPlanCreditURL(environment: environment) { + return override + } + if let host = MiniMaxSettingsReader.hostOverride(environment: environment) { + let lowered = host.lowercased() + if !lowered.contains("minimax.io"), !lowered.contains("minimaxi.com"), + let hostURL = MiniMaxUsageFetcher.url(from: host, path: "backend/account/token_plan_credit") + { + return hostURL + } + } + return Self.effectiveRegion(for: region, environment: environment).tokenPlanCreditURL + } + + static func effectiveRegion(for region: MiniMaxAPIRegion, environment: [String: String]) -> MiniMaxAPIRegion { + guard let host = MiniMaxSettingsReader.hostOverride(environment: environment)?.lowercased() else { + return region + } + if host.contains("minimaxi.com") { + return .chinaMainland + } + if host.contains("minimax.io") { + return .global + } + return region + } + + private static func validateBaseResponse(in payload: [String: Any]) throws { + guard let baseResp = payload["base_resp"] as? [String: Any] else { return } + let status = self.intValue(baseResp["status_code"]) ?? 0 + guard status != 0 else { return } + let message = (baseResp["status_msg"] as? String) ?? "MiniMax token plan credit error \(status)" + if status == 1004 || message.lowercased().contains("cookie") || message.lowercased().contains("login") { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError(message) + } + + private static func balance(from payload: [String: Any]) -> Double? { + if let balance = self.doubleValue(payload["remaining_credits"]), balance >= 0 { + return balance + } + if let breakdown = payload["balance_breakdown"] as? [String: Any], + let balance = self.doubleValue(breakdown["total_balance"]), + balance >= 0 + { + return balance + } + if let balance = self.doubleValue(payload["points_balance"]), balance >= 0 { + return balance + } + if let balance = self.doubleValue(payload["total_credits"]), + let used = self.doubleValue(payload["used_credits"]), + balance >= used + { + return balance - used + } + return nil + } + + private static func earliestExpiry(from payload: [String: Any]) -> Date? { + guard let breakdown = payload["balance_breakdown"] as? [String: Any], + let buckets = breakdown["buckets"] as? [[String: Any]] + else { + return nil + } + + return buckets.compactMap { bucket -> Date? in + guard let balance = self.doubleValue(bucket["balance"]), balance > 0 else { return nil } + guard let raw = self.doubleValue(bucket["expire_time_ms"]), raw > 0 else { return nil } + return Date(timeIntervalSince1970: raw / 1000.0) + }.min() + } + + private static func groupIDs(from payload: [String: Any]) -> Set { + guard let packages = payload["credit_packages_details"] as? [[String: Any]] else { return [] } + return Set(packages.compactMap { package in + if let value = package["group_id"] as? String { + let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + if let value = package["group_id"] as? NSNumber { + return value.stringValue + } + return nil + }) + } + + private static func doubleValue(_ value: Any?) -> Double? { + if let number = value as? Double { return number } + if let number = value as? Int { return Double(number) } + if let string = value as? String { + return Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + private static func intValue(_ value: Any?) -> Int? { + if let number = value as? Int { return number } + if let number = value as? Double { return Int(number) } + if let string = value as? String { + return Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} + +extension MiniMaxSubscriptionMetadataFetcher { + static func platformOriginURL(region: MiniMaxAPIRegion) -> URL { + switch region { + case .global: URL(string: "https://platform.minimax.io")! + case .chinaMainland: URL(string: "https://platform.minimaxi.com")! + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+APIToken.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+APIToken.swift new file mode 100644 index 0000000000..33d8063e4f --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+APIToken.swift @@ -0,0 +1,55 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension MiniMaxUsageFetcher { + static func fetchAPITokenUsage( + apiToken: String, + region: MiniMaxAPIRegion = .global, + now: Date = Date(), + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) + async throws -> (snapshot: MiniMaxUsageSnapshot, resolvedRegion: MiniMaxAPIRegion) + { + let cleaned = apiToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { + throw MiniMaxUsageError.invalidCredentials + } + + // Historically, MiniMax API token fetching used a China endpoint by default in some configurations. If the + // user has no persisted region and we default to `.global`, retry the China endpoint when the global host + // rejects the token so upgrades don't regress existing setups. + if region != .global { + let snapshot = try await self.fetchUsageOnce( + apiToken: cleaned, + region: region, + now: now, + transport: transport) + return (snapshot, region) + } + + do { + let snapshot = try await self.fetchUsageOnce( + apiToken: cleaned, + region: .global, + now: now, + transport: transport) + return (snapshot, .global) + } catch let error as MiniMaxUsageError { + guard case .invalidCredentials = error else { throw error } + Self.log.debug("MiniMax API token rejected for global host, retrying China mainland host") + do { + let snapshot = try await self.fetchUsageOnce( + apiToken: cleaned, + region: .chinaMainland, + now: now, + transport: transport) + return (snapshot, .chinaMainland) + } catch { + // Preserve the original invalid-credentials error so the fetch pipeline can fall back to web. + Self.log.debug("MiniMax China mainland retry failed, preserving global invalidCredentials") + throw MiniMaxUsageError.invalidCredentials + } + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift new file mode 100644 index 0000000000..41fca89f62 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift @@ -0,0 +1,114 @@ +import Foundation + +extension MiniMaxUsageFetcher { + struct WebEnrichmentAttempt { + let snapshot: MiniMaxUsageSnapshot + let rejectedCredentials: Bool + let receivedWebData: Bool + let accountMismatch: Bool + } + + static func attemptWebEnrichment( + of snapshot: MiniMaxUsageSnapshot, + context: WebFetchContext, + groupID: String?) async throws -> WebEnrichmentAttempt + { + let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID + var enriched = snapshot + var rejectedCredentials = false + var receivedWebData = false + var accountMismatch = false + var fetchedSummary: MiniMaxUsageSummary? + + do { + fetchedSummary = try await MiniMaxUsageSummaryFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch MiniMaxUsageError.invalidCredentials { + rejectedCredentials = true + } catch { + Self.log.debug("MiniMax usage summary unavailable: \(error.localizedDescription)") + } + + do { + let credit = try await MiniMaxTokenPlanCreditFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + if let resolvedGroupID, + !credit.groupIDs.isEmpty, + credit.groupIDs.contains(resolvedGroupID) + { + enriched = enriched.withPointsBalanceFromDedicatedEndpoint( + credit.balance, + expiresAt: credit.expiresAt) + if let fetchedSummary { + enriched = enriched.withUsageSummary(fetchedSummary) + } + receivedWebData = true + } else if let resolvedGroupID, + !credit.groupIDs.isEmpty, + !credit.groupIDs.contains(resolvedGroupID) + { + accountMismatch = true + } + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch MiniMaxUsageError.invalidCredentials { + rejectedCredentials = true + } catch { + Self.log.debug("MiniMax token plan credit unavailable: \(error.localizedDescription)") + } + + return WebEnrichmentAttempt( + snapshot: accountMismatch ? snapshot : enriched, + rejectedCredentials: rejectedCredentials, + receivedWebData: accountMismatch ? false : receivedWebData, + accountMismatch: accountMismatch) + } + + static func attachingUsageSummaryIfAvailable( + to snapshot: MiniMaxUsageSnapshot, + context: WebFetchContext, + groupID: String?) async throws -> MiniMaxUsageSnapshot + { + guard MiniMaxCookieHeader.normalized(from: context.cookie) != nil else { + return snapshot + } + + let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID + do { + let summary = try await MiniMaxUsageSummaryFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + return snapshot.withUsageSummary(summary) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch let error as MiniMaxUsageError { + Self.log.debug("MiniMax usage summary unavailable: \(error.localizedDescription)") + return snapshot + } catch let error as ProviderEndpointOverrideError { + Self.log.debug("MiniMax usage summary unavailable: \(error.localizedDescription)") + return snapshot + } catch { + Self.log.debug("MiniMax usage summary unavailable: \(error.localizedDescription)") + return snapshot + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 49d208ede8..ef65c44d07 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -66,6 +66,7 @@ public struct MiniMaxUsageFetcher: Sendable { return try await self.attachingBillingIfAvailable( to: snapshot, context: context, + groupID: groupID, includeBillingHistory: includeBillingHistory, now: now) } catch { @@ -83,6 +84,7 @@ public struct MiniMaxUsageFetcher: Sendable { return try await self.attachingBillingIfAvailable( to: snapshot, context: context, + groupID: groupID, includeBillingHistory: includeBillingHistory, now: now) } catch let error as MiniMaxUsageError { @@ -100,6 +102,7 @@ public struct MiniMaxUsageFetcher: Sendable { return try await self.attachingBillingIfAvailable( to: snapshot, context: context, + groupID: groupID, includeBillingHistory: includeBillingHistory, now: now) } @@ -113,42 +116,14 @@ public struct MiniMaxUsageFetcher: Sendable { now: Date = Date(), session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MiniMaxUsageSnapshot { - let cleaned = apiToken.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty else { - throw MiniMaxUsageError.invalidCredentials - } - - // Historically, MiniMax API token fetching used a China endpoint by default in some configurations. If the - // user has no persisted region and we default to `.global`, retry the China endpoint when the global host - // rejects the token so upgrades don't regress existing setups. - if region != .global { - return try await self.fetchUsageOnce(apiToken: cleaned, region: region, now: now, transport: transport) - } - - do { - return try await self.fetchUsageOnce( - apiToken: cleaned, - region: .global, - now: now, - transport: transport) - } catch let error as MiniMaxUsageError { - guard case .invalidCredentials = error else { throw error } - Self.log.debug("MiniMax API token rejected for global host, retrying China mainland host") - do { - return try await self.fetchUsageOnce( - apiToken: cleaned, - region: .chinaMainland, - now: now, - transport: transport) - } catch { - // Preserve the original invalid-credentials error so the fetch pipeline can fall back to web. - Self.log.debug("MiniMax China mainland retry failed, preserving global invalidCredentials") - throw MiniMaxUsageError.invalidCredentials - } - } + try await self.fetchAPITokenUsage( + apiToken: apiToken, + region: region, + now: now, + session: transport).snapshot } - private static func fetchUsageOnce( + static func fetchUsageOnce( apiToken: String, region: MiniMaxAPIRegion, now: Date, @@ -440,27 +415,42 @@ public struct MiniMaxUsageFetcher: Sendable { private static func attachingBillingIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, + groupID: String?, includeBillingHistory: Bool, now: Date) async throws -> MiniMaxUsageSnapshot { - guard includeBillingHistory else { return snapshot } - do { - let billing = try await self.fetchBillingSummary(context: context, now: now) - return snapshot.withBillingSummary(billing) - } catch is CancellationError { - throw CancellationError() - } catch let error as URLError where error.code == .cancelled { - throw error - } catch let error as MiniMaxUsageError { - if case .invalidCredentials = error, context.authorizationToken != nil { + let enrichedSnapshot: MiniMaxUsageSnapshot + if includeBillingHistory { + do { + let billing = try await self.fetchBillingSummary(context: context, now: now) + enrichedSnapshot = snapshot.withBillingSummary(billing) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { throw error + } catch let error as MiniMaxUsageError { + if case .invalidCredentials = error, context.authorizationToken != nil { + throw error + } + Self.log.debug("MiniMax billing history unavailable: \(error.localizedDescription)") + enrichedSnapshot = snapshot + } catch { + Self.log.debug("MiniMax billing history unavailable: \(error.localizedDescription)") + enrichedSnapshot = snapshot } - Self.log.debug("MiniMax billing history unavailable: \(error.localizedDescription)") - return snapshot - } catch { - Self.log.debug("MiniMax billing history unavailable: \(error.localizedDescription)") - return snapshot + } else { + enrichedSnapshot = snapshot } + + let summaryEnrichedSnapshot = try await self.attachingUsageSummaryIfAvailable( + to: enrichedSnapshot, + context: context, + groupID: groupID) + + return try await self.attachingTokenPlanCreditIfAvailable( + to: summaryEnrichedSnapshot, + context: context, + groupID: groupID) } private static func fetchBillingSummary(context: WebFetchContext, now: Date) async throws -> MiniMaxBillingSummary { @@ -985,14 +975,16 @@ enum MiniMaxUsageParser { remaining: remaining, remainingPercent: first?.currentIntervalRemainingPercent) - let windowMinutes = self.windowMinutes( - start: self.dateFromEpoch(first?.startTime), - end: self.dateFromEpoch(first?.endTime)) + let startDate = self.dateFromEpoch(first?.startTime) + let endDate = self.dateFromEpoch(first?.endTime) + let windowMinutes = self.windowMinutes(start: startDate, end: endDate) + let intervalWindowType = self.parseWindowInfo(startTime: startDate, endTime: endDate, now: now).windowType let resetsAt = self.resetsAt( - end: self.dateFromEpoch(first?.endTime), + end: endDate, remains: first?.remainsTime, - now: now) + now: now, + windowType: intervalWindowType) let planName = self.parsePlanName(data: payload.data) @@ -1040,21 +1032,6 @@ enum MiniMaxUsageParser { return nil } - private static func windowMinutes(start: Date?, end: Date?) -> Int? { - guard let start, let end else { return nil } - let minutes = Int(end.timeIntervalSince(start) / 60) - return minutes > 0 ? minutes : nil - } - - private static func resetsAt(end: Date?, remains: Int?, now: Date) -> Date? { - if let end, end > now { - return end - } - guard let remains, remains > 0 else { return nil } - let seconds: TimeInterval = remains > 1_000_000 ? TimeInterval(remains) / 1000 : TimeInterval(remains) - return now.addingTimeInterval(seconds) - } - private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { [ data.currentSubscribeTitle, @@ -1500,19 +1477,7 @@ enum MiniMaxUsageParser { resetsAt: Date?) -> String { if let resetsAt, resetsAt > now { - let interval = resetsAt.timeIntervalSince(now) - if interval < 60 { - return "Resets in \(Int(interval)) seconds" - } else if interval < 3600 { - let minutes = Int(interval / 60) - return "Resets in \(minutes) minute\(minutes == 1 ? "" : "s")" - } else if interval < 86400 { - let hours = Int(interval / 3600) - return "Resets in \(hours) hour\(hours == 1 ? "" : "s")" - } else { - let days = Int(interval / 86400) - return "Resets in \(days) day\(days == 1 ? "" : "s")" - } + return MiniMaxServiceUsage.generateResetDescription(resetsAt: resetsAt, now: now) } return "\(windowType): \(timeRange)" @@ -1592,7 +1557,11 @@ enum MiniMaxUsageParser { } let isUnlimited = self.isUnlimitedQuotaWindow(input, windowType: windowType) - let resetsAt = isUnlimited ? nil : self.resetsAt(end: endTime, remains: input.remainsTime, now: now) + let resetsAt = isUnlimited ? nil : self.resetsAt( + end: endTime, + remains: input.remainsTime, + now: now, + windowType: windowType) let resetDescription = if isUnlimited { "Unlimited" } else { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift new file mode 100644 index 0000000000..293163fdf1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift @@ -0,0 +1,61 @@ +import Foundation + +extension MiniMaxUsageParser { + static func windowMinutes(start: Date?, end: Date?) -> Int? { + guard let start, let end else { return nil } + let minutes = Int(end.timeIntervalSince(start) / 60) + return minutes > 0 ? minutes : nil + } + + static func resetsAt( + end: Date?, + remains: Int?, + now: Date, + windowType: String) -> Date? + { + let endReset: Date? = { + guard let end, end > now else { return nil } + return end + }() + + let remainsReset: Date? = { + guard let remains, remains > 0 else { return nil } + let seconds: TimeInterval = remains > 1_000_000 ? TimeInterval(remains) / 1000 : TimeInterval(remains) + let resetDate = now.addingTimeInterval(seconds) + guard resetDate > now else { return nil } + return resetDate + }() + + guard let remainsReset else { return endReset } + guard let endReset else { return remainsReset } + + if let maxRemain = self.maxReasonableRemainInterval(windowType: windowType), + remainsReset.timeIntervalSince(now) > maxRemain + { + return endReset + } + + // Prefer API countdown when it is close to the declared interval boundary. + if remainsReset <= endReset.addingTimeInterval(15 * 60) { + return remainsReset + } + return endReset + } + + static func maxReasonableRemainInterval(windowType: String) -> TimeInterval? { + let normalized = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized == "weekly" { + return 8 * 24 * 3600 + } + if normalized == "today" || normalized == "daily" { + return 26 * 3600 + } + if let hours = Int(normalized.split(separator: " ").first ?? ""), normalized.contains("hour") { + return TimeInterval(hours + 1) * 3600 + } + if normalized == "5 hours" || normalized == "5h" || normalized.contains("hour") { + return 6 * 3600 + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift new file mode 100644 index 0000000000..b26e4e7033 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift @@ -0,0 +1,177 @@ +import Foundation + +enum MiniMaxUsagePricing { + private struct TokenCounts { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let output: Int + } + + struct Pricing: Equatable { + let inputCostPerToken: Double + let outputCostPerToken: Double + let cacheReadInputCostPerToken: Double + let cacheCreationInputCostPerToken: Double + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + + init( + inputCostPerToken: Double, + outputCostPerToken: Double, + cacheReadInputCostPerToken: Double, + cacheCreationInputCostPerToken: Double = 0, + thresholdTokens: Int? = nil, + inputCostPerTokenAboveThreshold: Double? = nil, + outputCostPerTokenAboveThreshold: Double? = nil, + cacheReadInputCostPerTokenAboveThreshold: Double? = nil) + { + self.inputCostPerToken = inputCostPerToken + self.outputCostPerToken = outputCostPerToken + self.cacheReadInputCostPerToken = cacheReadInputCostPerToken + self.cacheCreationInputCostPerToken = cacheCreationInputCostPerToken + self.thresholdTokens = thresholdTokens + self.inputCostPerTokenAboveThreshold = inputCostPerTokenAboveThreshold + self.outputCostPerTokenAboveThreshold = outputCostPerTokenAboveThreshold + self.cacheReadInputCostPerTokenAboveThreshold = cacheReadInputCostPerTokenAboveThreshold + } + } + + private static let perMillion = 1_000_000.0 + private static let longContextThreshold = 512_000 + + private static let m27Standard = Pricing( + inputCostPerToken: 0.30 / perMillion, + outputCostPerToken: 1.20 / perMillion, + cacheReadInputCostPerToken: 0.06 / perMillion, + cacheCreationInputCostPerToken: 0.375 / perMillion) + + private static let m27Highspeed = Pricing( + inputCostPerToken: 0.60 / perMillion, + outputCostPerToken: 2.40 / perMillion, + cacheReadInputCostPerToken: 0.06 / perMillion, + cacheCreationInputCostPerToken: 0.375 / perMillion) + + private static let m25Legacy = Pricing( + inputCostPerToken: 0.30 / perMillion, + outputCostPerToken: 1.20 / perMillion, + cacheReadInputCostPerToken: 0.03 / perMillion, + cacheCreationInputCostPerToken: 0.375 / perMillion) + + private static let m3Standard = Pricing( + inputCostPerToken: 0.30 / perMillion, + outputCostPerToken: 1.20 / perMillion, + cacheReadInputCostPerToken: 0.06 / perMillion, + thresholdTokens: longContextThreshold, + inputCostPerTokenAboveThreshold: 0.60 / perMillion, + outputCostPerTokenAboveThreshold: 2.40 / perMillion, + cacheReadInputCostPerTokenAboveThreshold: 0.12 / perMillion) + + private static let includedInPlan = Pricing( + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheReadInputCostPerToken: 0, + cacheCreationInputCostPerToken: 0) + + static func minimaxCostUSD( + model: String, + inputToken: Int, + cacheReadToken: Int, + cacheCreateToken: Int, + outputToken: Int) -> Double? + { + guard let pricing = self.pricing(for: model, inputToken: inputToken) else { return nil } + return self.costUSD( + pricing: pricing, + tokens: TokenCounts( + input: inputToken, + cacheRead: cacheReadToken, + cacheCreate: cacheCreateToken, + output: outputToken), + applyLongContextThreshold: true) + } + + static func minimaxAggregateCostUSD( + model: String, + inputToken: Int, + cacheReadToken: Int, + cacheCreateToken: Int, + outputToken: Int) -> Double? + { + guard let pricing = self.pricing(for: model, inputToken: inputToken) else { return nil } + return self.costUSD( + pricing: pricing, + tokens: TokenCounts( + input: inputToken, + cacheRead: cacheReadToken, + cacheCreate: cacheCreateToken, + output: outputToken), + applyLongContextThreshold: false) + } + + static func pricing(for model: String, inputToken: Int) -> Pricing? { + let normalized = self.normalizeModel(model) + if normalized.contains("coding-plan") { + return self.includedInPlan + } + if normalized.contains("m3") { + return self.m3Standard + } + if normalized.contains("m2.7"), normalized.contains("highspeed") { + return self.m27Highspeed + } + if normalized.contains("m2.7") { + return self.m27Standard + } + if normalized.contains("m2.5"), normalized.contains("highspeed") { + return Pricing( + inputCostPerToken: 0.60 / self.perMillion, + outputCostPerToken: 2.40 / self.perMillion, + cacheReadInputCostPerToken: 0.03 / self.perMillion, + cacheCreationInputCostPerToken: 0.375 / self.perMillion) + } + if normalized.contains("m2.5") || normalized.contains("m2.1") || normalized == "m2" { + return self.m25Legacy + } + if normalized.contains("m2") { + return self.m25Legacy + } + return nil + } + + static func normalizeModel(_ model: String) -> String { + model + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "-") + } + + private static func costUSD( + pricing: Pricing, + tokens: TokenCounts, + applyLongContextThreshold: Bool) -> Double + { + let input = max(0, tokens.input) + let cacheRead = max(0, tokens.cacheRead) + let cacheCreate = max(0, tokens.cacheCreate) + let output = max(0, tokens.output) + + let usesLongContext = applyLongContextThreshold && (pricing.thresholdTokens.map { input > $0 } ?? false) + let inputRate = usesLongContext + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let outputRate = usesLongContext + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + let cacheReadRate = usesLongContext + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + + return (Double(input) * inputRate) + + (Double(cacheRead) * cacheReadRate) + + (Double(cacheCreate) * pricing.cacheCreationInputCostPerToken) + + (Double(output) * outputRate) + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift index d5048761b8..bb724eef30 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -17,9 +17,12 @@ extension MiniMaxUsageSnapshot { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, - subscriptionRenewsAt: self.subscriptionRenewsAt) + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) } func withSubscriptionMetadata(_ metadata: MiniMaxSubscriptionMetadata) -> MiniMaxUsageSnapshot { @@ -34,9 +37,83 @@ extension MiniMaxUsageSnapshot { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: metadata.subscriptionExpiresAt ?? self.subscriptionExpiresAt, - subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt) + subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt, + webSessionState: self.webSessionState) + } + + func withUsageSummary(_ usageSummary: MiniMaxUsageSummary?) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + usageSummary: usageSummary, + pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) + } + + func withPointsBalanceIfMissing(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot { + if let pointsBalance, pointsBalance >= 0, self.pointsBalance == nil { + return self.withPointsBalanceFromDedicatedEndpoint(pointsBalance, expiresAt: expiresAt) + } + if self.pointsBalanceExpiresAt == nil, let expiresAt { + return MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + usageSummary: self.usageSummary, + pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: expiresAt, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) + } + return self + } + + func withPointsBalanceFromDedicatedEndpoint(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot { + guard let pointsBalance, pointsBalance >= 0, + pointsBalance != self.pointsBalance || expiresAt != self.pointsBalanceExpiresAt + else { + return self + } + return MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + usageSummary: self.usageSummary, + pointsBalance: pointsBalance, + pointsBalanceExpiresAt: expiresAt ?? self.pointsBalanceExpiresAt, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index fe73270d0d..943d2a6a32 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -1,5 +1,19 @@ import Foundation +public enum MiniMaxWebSessionState: Sendable, Equatable { + case notChecked + case unavailable(reason: MiniMaxWebSessionUnavailableReason) + case expired + case accountMismatch + case valid(sourceLabel: String) +} + +public enum MiniMaxWebSessionUnavailableReason: Sendable, Equatable { + case noBrowserSession + case keychainAccessDisabled + case endpointsUnavailable +} + public struct MiniMaxUsageSnapshot: Sendable { public let planName: String? public let availablePrompts: Int? @@ -11,9 +25,12 @@ public struct MiniMaxUsageSnapshot: Sendable { public let updatedAt: Date public let services: [MiniMaxServiceUsage]? public let billingSummary: MiniMaxBillingSummary? + public let usageSummary: MiniMaxUsageSummary? public let pointsBalance: Double? + public let pointsBalanceExpiresAt: Date? public let subscriptionExpiresAt: Date? public let subscriptionRenewsAt: Date? + public let webSessionState: MiniMaxWebSessionState public var primaryService: MiniMaxServiceUsage? { self.orderedQuotaServices.first @@ -75,9 +92,12 @@ public struct MiniMaxUsageSnapshot: Sendable { updatedAt: Date, services: [MiniMaxServiceUsage]? = nil, billingSummary: MiniMaxBillingSummary? = nil, + usageSummary: MiniMaxUsageSummary? = nil, pointsBalance: Double? = nil, + pointsBalanceExpiresAt: Date? = nil, subscriptionExpiresAt: Date? = nil, - subscriptionRenewsAt: Date? = nil) + subscriptionRenewsAt: Date? = nil, + webSessionState: MiniMaxWebSessionState = .notChecked) { self.planName = planName self.availablePrompts = availablePrompts @@ -89,9 +109,12 @@ public struct MiniMaxUsageSnapshot: Sendable { self.updatedAt = updatedAt self.services = services self.billingSummary = billingSummary + self.usageSummary = usageSummary self.pointsBalance = pointsBalance + self.pointsBalanceExpiresAt = pointsBalanceExpiresAt self.subscriptionExpiresAt = subscriptionExpiresAt self.subscriptionRenewsAt = subscriptionRenewsAt + self.webSessionState = webSessionState } public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { @@ -106,9 +129,32 @@ public struct MiniMaxUsageSnapshot: Sendable { updatedAt: self.updatedAt, services: self.services, billingSummary: billingSummary, + usageSummary: self.usageSummary, + pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) + } + + func withWebSessionState(_ state: MiniMaxWebSessionState) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, - subscriptionRenewsAt: self.subscriptionRenewsAt) + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: state) } } @@ -240,6 +286,7 @@ extension MiniMaxUsageSnapshot { limit: 0, currencyCode: "Points", period: "MiniMax points balance", + resetsAt: self.pointsBalanceExpiresAt, updatedAt: self.updatedAt) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift new file mode 100644 index 0000000000..7e0ef98a60 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift @@ -0,0 +1,117 @@ +import Foundation + +extension MiniMaxUsageSummary { + public var hasProjectableCost: Bool { + self.days.contains { self.projectedCostUSD(for: $0) != nil } + } + + public func projectedCostUSD(for day: MiniMaxUsageSummaryDay) -> Double? { + let breakdown = self.projectedModelBreakdowns(for: day) + guard breakdown.contains(where: { $0.costUSD != nil }) else { return nil } + return breakdown.compactMap(\.costUSD).reduce(0, +) + } + + public func projectedCostUSD(lastDays: Int) -> Double? { + let days = self.trendDays(last: lastDays) + var total = 0.0 + var seen = false + for day in days { + guard let cost = self.projectedCostUSD(for: day) else { continue } + total += cost + seen = true + } + return seen ? total : nil + } + + public func projectedModelBreakdowns( + for day: MiniMaxUsageSummaryDay) -> [CostUsageDailyReport.ModelBreakdown] + { + if !day.models.isEmpty { + return day.models.compactMap { model in + guard let costUSD = MiniMaxUsagePricing.minimaxAggregateCostUSD( + model: model.model, + inputToken: model.inputToken, + cacheReadToken: model.cacheReadToken, + cacheCreateToken: model.cacheCreateToken, + outputToken: model.outputToken) + else { + return nil + } + return CostUsageDailyReport.ModelBreakdown( + modelName: model.model, + costUSD: costUSD, + totalTokens: model.totalToken) + } + } + + return [] + } + + public func toCostUsageTokenSnapshot( + historyDays: Int = 30, + now: Date = Date()) -> CostUsageTokenSnapshot? + { + guard self.hasDisplayableData else { return nil } + + let clampedHistoryDays = max(1, min(365, historyDays)) + let selectedDays = Array(self.days.suffix(clampedHistoryDays)) + guard !selectedDays.isEmpty else { return nil } + + var entries: [CostUsageDailyReport.Entry] = [] + entries.reserveCapacity(selectedDays.count) + var windowCost = 0.0 + var windowCostSeen = false + var windowTokens = 0 + + for day in selectedDays { + let breakdown = self.projectedModelBreakdowns(for: day) + let dayCost = breakdown.compactMap(\.costUSD).reduce(0, +) + let hasCost = breakdown.contains { $0.costUSD != nil } + if hasCost { + windowCost += dayCost + windowCostSeen = true + } + windowTokens += day.totalToken + + entries.append(CostUsageDailyReport.Entry( + date: day.date, + inputTokens: day.totalInputToken, + outputTokens: day.totalOutputToken, + cacheReadTokens: day.totalCacheReadToken > 0 ? day.totalCacheReadToken : nil, + cacheCreationTokens: day.totalCacheCreateToken > 0 ? day.totalCacheCreateToken : nil, + totalTokens: day.totalToken, + costUSD: hasCost ? dayCost : nil, + modelsUsed: breakdown.isEmpty ? nil : breakdown.map(\.modelName), + modelBreakdowns: breakdown.isEmpty ? nil : breakdown)) + } + + guard windowCostSeen else { return nil } + if windowCost == 0, self.isPlanIncludedOnly(days: selectedDays) { + return nil + } + + let latestEntry = entries.last + let sessionTokens = self.snapshotDay?.totalToken ?? latestEntry?.totalTokens + let sessionCostUSD = self.snapshotDay.flatMap { day in + entries.first(where: { $0.date == day.date })?.costUSD + } ?? latestEntry?.costUSD + + return CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: windowTokens > 0 ? windowTokens : nil, + last30DaysCostUSD: windowCostSeen ? windowCost : nil, + currencyCode: "USD", + historyDays: clampedHistoryDays, + historyLabel: nil, + daily: entries, + updatedAt: now) + } + + private func isPlanIncludedOnly(days: [MiniMaxUsageSummaryDay]) -> Bool { + let models = days.flatMap(\.models) + return !models.isEmpty && models.allSatisfy { + MiniMaxUsagePricing.normalizeModel($0.model).contains("coding-plan") + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift new file mode 100644 index 0000000000..e3efdddaef --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift @@ -0,0 +1,409 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct MiniMaxUsageSummary: Sendable, Equatable { + public let totalDays: Int? + public let totalTokenConsumed: String? + public let usageRankingPercent: Double? + public let activeDays: Int? + public let currentConsecutiveDays: Int? + public let lastUpdateTime: String? + public let dailyTokenUsage: [Int] + public let days: [MiniMaxUsageSummaryDay] + + public var latestDay: MiniMaxUsageSummaryDay? { + self.days.last + } + + public var latestActiveDay: MiniMaxUsageSummaryDay? { + self.days.last { $0.totalToken > 0 } + } + + public var snapshotDateKey: String { + Self.dateKey(fromUpdateTime: self.lastUpdateTime) ?? Self.todayDateKey() + } + + public var snapshotDay: MiniMaxUsageSummaryDay? { + if let day = self.days.first(where: { $0.date == self.snapshotDateKey }) { + return day + } + return self.latestDay + } + + public var latestSnapshotTokens: Int { + if let day = self.snapshotDay, day.totalToken > 0 { + return day.totalToken + } + if let last = self.dailyTokenUsage.last, last > 0 { + return last + } + return self.latestDay?.totalToken ?? 0 + } + + public var last7DaysTokens: Int { + self.tokenTotal(lastDays: 7) + } + + public var last30DaysTokens: Int { + self.tokenTotal(lastDays: 30) + } + + public func trendDays(last count: Int) -> [MiniMaxUsageSummaryDay] { + if !self.days.isEmpty { + return Array(self.days.suffix(count)) + } + guard !self.dailyTokenUsage.isEmpty else { return [] } + let values = Array(self.dailyTokenUsage.suffix(count)) + let calendar = Calendar.current + let snapshotDate = Self.date(fromDateKey: self.snapshotDateKey) ?? Date() + let snapshotDay = calendar.startOfDay(for: snapshotDate) + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return values.enumerated().map { index, tokens in + let daysBack = values.count - 1 - index + let date = calendar.date(byAdding: .day, value: -daysBack, to: snapshotDay) ?? snapshotDay + return MiniMaxUsageSummaryDay( + date: formatter.string(from: date), + totalInputToken: 0, + totalCacheReadToken: 0, + totalCacheCreateToken: 0, + totalOutputToken: 0, + totalToken: tokens, + cacheHitPercent: nil, + models: []) + } + } + + public var hasDisplayableData: Bool { + !self.dailyTokenUsage.isEmpty || self.days.contains { $0.totalToken > 0 } + } + + public var latestModelNames: [String] { + (self.latestActiveDay?.models ?? []).map(\.model) + } + + public init( + totalDays: Int?, + totalTokenConsumed: String?, + usageRankingPercent: Double?, + activeDays: Int?, + currentConsecutiveDays: Int?, + lastUpdateTime: String?, + dailyTokenUsage: [Int], + days: [MiniMaxUsageSummaryDay]) + { + self.totalDays = totalDays + self.totalTokenConsumed = totalTokenConsumed + self.usageRankingPercent = usageRankingPercent + self.activeDays = activeDays + self.currentConsecutiveDays = currentConsecutiveDays + self.lastUpdateTime = lastUpdateTime + self.dailyTokenUsage = dailyTokenUsage + self.days = days + } + + private func tokenTotal(lastDays count: Int) -> Int { + if !self.dailyTokenUsage.isEmpty { + let sum = self.dailyTokenUsage.suffix(count).reduce(0, +) + if sum > 0 { + return sum + } + } + return self.days.suffix(count).reduce(0) { $0 + $1.totalToken } + } + + static func dateKey(fromUpdateTime raw: String?, referenceDate: Date = Date()) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + let datePart = raw.split(separator: " ").first.map(String.init) ?? raw + let parts = datePart.split(separator: "-") + guard parts.count == 2, + let month = Int(parts[0]), + let day = Int(parts[1]), + (1...12).contains(month), + (1...31).contains(day) + else { + return nil + } + let calendar = Calendar.current + var components = DateComponents() + components.calendar = calendar + components.timeZone = TimeZone.current + components.year = calendar.component(.year, from: referenceDate) + components.month = month + components.day = day + guard var date = components.date else { return nil } + let futureThreshold = calendar.date(byAdding: .day, value: 1, to: referenceDate) ?? referenceDate + if date > futureThreshold { + components.year = (components.year ?? calendar.component(.year, from: referenceDate)) - 1 + guard let adjusted = components.date else { return nil } + date = adjusted + } + return Self.todayDateKey(for: date) + } + + private static func date(fromDateKey key: String) -> Date? { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: key) + } + + static func todayDateKey(for date: Date = Date()) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar.current + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } +} + +public struct MiniMaxUsageSummaryDay: Sendable, Equatable { + public let date: String + public let totalInputToken: Int + public let totalCacheReadToken: Int + public let totalCacheCreateToken: Int + public let totalOutputToken: Int + public let totalToken: Int + public let cacheHitPercent: Double? + public let models: [MiniMaxUsageSummaryModel] + + public init( + date: String, + totalInputToken: Int, + totalCacheReadToken: Int, + totalCacheCreateToken: Int, + totalOutputToken: Int, + totalToken: Int, + cacheHitPercent: Double?, + models: [MiniMaxUsageSummaryModel]) + { + self.date = date + self.totalInputToken = totalInputToken + self.totalCacheReadToken = totalCacheReadToken + self.totalCacheCreateToken = totalCacheCreateToken + self.totalOutputToken = totalOutputToken + self.totalToken = totalToken + self.cacheHitPercent = cacheHitPercent + self.models = models + } +} + +public struct MiniMaxUsageSummaryModel: Sendable, Equatable { + public let model: String + public let inputToken: Int + public let cacheReadToken: Int + public let cacheCreateToken: Int + public let outputToken: Int + public let totalToken: Int + public let cacheHitPercent: Double? + + public init( + model: String, + inputToken: Int, + cacheReadToken: Int, + cacheCreateToken: Int, + outputToken: Int, + totalToken: Int, + cacheHitPercent: Double?) + { + self.model = model + self.inputToken = inputToken + self.cacheReadToken = cacheReadToken + self.cacheCreateToken = cacheCreateToken + self.outputToken = outputToken + self.totalToken = totalToken + self.cacheHitPercent = cacheHitPercent + } +} + +enum MiniMaxUsageSummaryFetcher { + private static let usageSummaryPath = "backend/account/token_plan/usage_summary" + + static func resolveUsageSummaryURL( + region: MiniMaxAPIRegion, + environment: [String: String]) throws -> URL? + { + if let rejectedKey = MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: environment) { + throw ProviderEndpointOverrideError.minimax(rejectedKey) + } + if let host = MiniMaxSettingsReader.hostOverride(environment: environment) { + let lowered = host.lowercased() + if !lowered.contains("minimax.io"), !lowered.contains("minimaxi.com"), + let hostURL = MiniMaxUsageFetcher.url(from: host, path: Self.usageSummaryPath) + { + return hostURL + } + } + return MiniMaxTokenPlanCreditFetcher + .effectiveRegion(for: region, environment: environment) + .tokenPlanUsageSummaryURL + } + + static func fetch( + cookieHeader: String, + groupID: String?, + region: MiniMaxAPIRegion, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSummary + { + guard let url = try resolveUsageSummaryURL(region: region, environment: environment) else { + throw MiniMaxUsageError.apiError("MiniMax usage summary endpoint unavailable for configured host.") + } + let effectiveRegion = MiniMaxTokenPlanCreditFetcher.effectiveRegion(for: region, environment: environment) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + if let groupID = groupID?.trimmingCharacters(in: .whitespacesAndNewlines), !groupID.isEmpty { + request.setValue(groupID, forHTTPHeaderField: "x-group-id") + } + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "x-requested-with") + request.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + forHTTPHeaderField: "user-agent") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") + let origin = MiniMaxSubscriptionMetadataFetcher.platformOriginURL(region: effectiveRegion) + request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") + request.setValue(origin.absoluteString + "/", forHTTPHeaderField: "referer") + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") + } + return try MiniMaxUsageSummaryParser.parse(data: response.data) + } +} + +private struct MiniMaxUsageSummaryPayload: Decodable { + let totalDays: Int? + let totalTokenConsumed: String? + let usageRankingPercent: Double? + let activeDays: Int? + let currentConsecutiveDays: Int? + let dailyTokenUsage: [Int] + let dateModelUsage: [MiniMaxUsageSummaryDayPayload] + let lastUpdateTime: String? + let baseResp: MiniMaxBaseResponse? + + private enum CodingKeys: String, CodingKey { + case totalDays = "total_days" + case totalTokenConsumed = "total_token_consumed" + case usageRankingPercent = "usage_ranking_percent" + case activeDays = "active_days" + case currentConsecutiveDays = "current_consecutive_days" + case dailyTokenUsage = "daily_token_usage" + case dateModelUsage = "date_model_usage" + case lastUpdateTime = "last_update_time" + case baseResp = "base_resp" + } +} + +private struct MiniMaxUsageSummaryDayPayload: Decodable { + let date: String + let totalInputToken: Int + let totalCacheReadToken: Int + let totalCacheCreateToken: Int + let totalOutputToken: Int + let totalToken: Int + let cacheHitPercent: String? + let models: [MiniMaxUsageSummaryModelPayload] + + private enum CodingKeys: String, CodingKey { + case date + case totalInputToken = "total_input_token" + case totalCacheReadToken = "total_cache_read_token" + case totalCacheCreateToken = "total_cache_create_token" + case totalOutputToken = "total_output_token" + case totalToken = "total_token" + case cacheHitPercent = "cache_hit_percent" + case models + } +} + +private struct MiniMaxUsageSummaryModelPayload: Decodable { + let model: String + let inputToken: Int + let cacheReadToken: Int + let cacheCreateToken: Int + let outputToken: Int + let totalToken: Int + let cacheHitPercent: String? + + private enum CodingKeys: String, CodingKey { + case model + case inputToken = "input_token" + case cacheReadToken = "cache_read_token" + case cacheCreateToken = "cache_create_token" + case outputToken = "output_token" + case totalToken = "total_token" + case cacheHitPercent = "cache_hit_percent" + } +} + +enum MiniMaxUsageSummaryParser { + static func parse(data: Data) throws -> MiniMaxUsageSummary { + let payload = try JSONDecoder().decode(MiniMaxUsageSummaryPayload.self, from: data) + if let status = payload.baseResp?.statusCode, status != 0 { + let message = payload.baseResp?.statusMessage ?? "status_code \(status)" + if status == 1004 || message.lowercased().contains("cookie") + || message.lowercased().contains("login") + { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError(message) + } + return MiniMaxUsageSummary( + totalDays: payload.totalDays, + totalTokenConsumed: payload.totalTokenConsumed, + usageRankingPercent: payload.usageRankingPercent, + activeDays: payload.activeDays, + currentConsecutiveDays: payload.currentConsecutiveDays, + lastUpdateTime: payload.lastUpdateTime, + dailyTokenUsage: payload.dailyTokenUsage, + days: payload.dateModelUsage.map(self.day)) + } + + private static func day(_ payload: MiniMaxUsageSummaryDayPayload) -> MiniMaxUsageSummaryDay { + MiniMaxUsageSummaryDay( + date: payload.date, + totalInputToken: payload.totalInputToken, + totalCacheReadToken: payload.totalCacheReadToken, + totalCacheCreateToken: payload.totalCacheCreateToken, + totalOutputToken: payload.totalOutputToken, + totalToken: payload.totalToken, + cacheHitPercent: self.percent(from: payload.cacheHitPercent), + models: payload.models.map(self.model)) + } + + private static func model(_ payload: MiniMaxUsageSummaryModelPayload) -> MiniMaxUsageSummaryModel { + MiniMaxUsageSummaryModel( + model: payload.model, + inputToken: payload.inputToken, + cacheReadToken: payload.cacheReadToken, + cacheCreateToken: payload.cacheCreateToken, + outputToken: payload.outputToken, + totalToken: payload.totalToken, + cacheHitPercent: self.percent(from: payload.cacheHitPercent)) + } + + private static func percent(from raw: String?) -> Double? { + guard let raw else { return nil } + let trimmed = raw + .replacingOccurrences(of: "%", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return Double(trimmed) + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift new file mode 100644 index 0000000000..9628268d0b --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift @@ -0,0 +1,172 @@ +import Foundation + +enum MiniMaxWebEnrichmentResolver { + struct Candidate { + let override: MiniMaxCookieOverride + let sourceLabel: String + let shouldCache: Bool + let isCached: Bool + } + + /// Full candidate chain for cookie-first web refreshes (desktop, cache, browser, explicit). + static func candidates(context: ProviderFetchContext) -> [Candidate] { + var candidates = self.explicitCandidates(context: context) + + #if os(macOS) + candidates.append(contentsOf: self.desktopAgentCandidates(context: context)) + candidates.append(contentsOf: self.cachedAndBrowserCandidates(context: context)) + #endif + return self.deduplicated(candidates) + } + + // API-token enrichment: explicit cookies, desktop Agent session, validated cache, and user-initiated + // browser import only. + #if os(macOS) + static func apiEnrichmentCandidates( + context: ProviderFetchContext, + desktopSession: MiniMaxCookieImporter.SessionInfo? = nil) -> [Candidate] + { + var candidates = self.explicitCandidates(context: context) + candidates.append(contentsOf: self.desktopAgentCandidates( + context: context, + session: desktopSession)) + candidates.append(contentsOf: self.cachedAndBrowserCandidates(context: context)) + return self.deduplicated(candidates) + } + #else + static func apiEnrichmentCandidates(context: ProviderFetchContext) -> [Candidate] { + self.deduplicated(self.explicitCandidates(context: context)) + } + #endif + + /// Cookies from explicit user configuration only. + static func explicitCandidates(context: ProviderFetchContext) -> [Candidate] { + var candidates: [Candidate] = [] + if let settings = context.settings?.minimax, + settings.cookieSource == .manual, + let header = settings.manualCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), + !header.isEmpty, + let override = MiniMaxCookieHeader.override(from: header) + { + candidates.append(Candidate( + override: override, sourceLabel: "settings", shouldCache: false, isCached: false)) + } + if let raw = ProviderTokenResolver.minimaxCookie(environment: context.env), + let override = MiniMaxCookieHeader.override(from: raw) + { + candidates.append(Candidate( + override: override, sourceLabel: "environment", shouldCache: false, isCached: false)) + } + return candidates + } + + static func cacheValidated(_ candidate: Candidate) { + guard candidate.shouldCache else { return } + CookieHeaderCache.store( + provider: .minimax, + cookieHeader: candidate.override.cookieHeader, + sourceLabel: candidate.sourceLabel) + } + + private static func deduplicated(_ candidates: [Candidate]) -> [Candidate] { + var seen: Set = [] + return candidates.filter { seen.insert($0.override.cookieHeader).inserted } + } + + #if os(macOS) + static func allowsBrowserCookieImport(context: ProviderFetchContext) -> Bool { + context.runtime == .app && ProviderInteractionContext.current == .userInitiated + } + + static func desktopAgentCandidates( + context: ProviderFetchContext, + session: MiniMaxCookieImporter.SessionInfo? = nil) -> [Candidate] + { + guard let session = session ?? MiniMaxDesktopCookieImporter.importSession(), + let override = MiniMaxCookieHeader.override(from: session.cookieHeader) + else { + return [] + } + return [Candidate( + override: self.enrichWithBrowserTokens( + override, + sourceLabel: session.sourceLabel, + browserDetection: context.browserDetection), + sourceLabel: session.sourceLabel, + shouldCache: true, + isCached: false)] + } + + private static func cachedAndBrowserCandidates(context: ProviderFetchContext) -> [Candidate] { + var candidates: [Candidate] = [] + if let cached = CookieHeaderCache.load(provider: .minimax), + let override = MiniMaxCookieHeader.override(from: cached.cookieHeader) + { + candidates.append(Candidate( + override: self.enrichWithBrowserTokens( + override, + sourceLabel: cached.sourceLabel, + browserDetection: context.browserDetection), + sourceLabel: cached.sourceLabel, + shouldCache: false, + isCached: true)) + } + if self.allowsBrowserCookieImport(context: context) { + let sessions = (try? MiniMaxCookieImporter.importSessions( + browserDetection: context.browserDetection)) ?? [] + for session in sessions { + guard let override = MiniMaxCookieHeader.override(from: session.cookieHeader) else { continue } + candidates.append(Candidate( + override: self.enrichWithBrowserTokens( + override, + sourceLabel: session.sourceLabel, + browserDetection: context.browserDetection), + sourceLabel: session.sourceLabel, + shouldCache: true, + isCached: false)) + } + } + return candidates + } + + private static func enrichWithBrowserTokens( + _ override: MiniMaxCookieOverride, + sourceLabel: String, + browserDetection: BrowserDetection) -> MiniMaxCookieOverride + { + if override.authorizationToken != nil, override.groupID != nil { return override } + let accessTokens = MiniMaxLocalStorageImporter.importAccessTokens(browserDetection: browserDetection) + let groupIDs = MiniMaxLocalStorageImporter.importGroupIDs(browserDetection: browserDetection) + let normalizedLabel = self.normalizeStorageLabel(sourceLabel) + let matchingToken = accessTokens.first { + self.normalizeStorageLabel($0.sourceLabel) == normalizedLabel + } + let matchingGroupID = groupIDs.first { + self.normalizeStorageLabel($0.key) == normalizedLabel + }?.value + return MiniMaxCookieOverride( + cookieHeader: override.cookieHeader, + authorizationToken: override.authorizationToken + ?? matchingToken?.accessToken + ?? self.cookieValue(named: "HERTZ-SESSION", in: override.cookieHeader), + groupID: override.groupID + ?? matchingToken?.groupID + ?? matchingGroupID + ?? self.cookieValue(named: "minimax_group_id_v2", in: override.cookieHeader)) + } + + private static func normalizeStorageLabel(_ label: String) -> String { + for suffix in [" (Session Storage)", " (IndexedDB)"] where label.hasSuffix(suffix) { + return String(label.dropLast(suffix.count)) + } + return label + } + + private static func cookieValue(named name: String, in header: String) -> String? { + header.split(separator: ";").lazy + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { $0.lowercased().hasPrefix("\(name.lowercased())=") } + .map { String($0.dropFirst(name.count + 1)) } + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift index 91506be870..683562b02d 100644 --- a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -429,6 +429,9 @@ public struct MiniMaxDiagnosticDetails: Codable, Sendable { public let windowMinutes: Int? public let usedPercent: Double? public let resetsAt: Date? + public let pointsBalance: Double? + public let pointsBalanceExpiresAt: Date? + public let usageSummaryPresent: Bool public let services: [MiniMaxDiagnosticServiceUsage]? public let billingSummaryPresent: Bool @@ -440,6 +443,9 @@ public struct MiniMaxDiagnosticDetails: Codable, Sendable { self.windowMinutes = snapshot.windowMinutes self.usedPercent = snapshot.usedPercent self.resetsAt = snapshot.resetsAt + self.pointsBalance = snapshot.pointsBalance + self.pointsBalanceExpiresAt = snapshot.pointsBalanceExpiresAt + self.usageSummaryPresent = snapshot.usageSummary != nil self.services = snapshot.services?.map { MiniMaxDiagnosticServiceUsage(from: $0) } self.billingSummaryPresent = snapshot.billingSummary != nil } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 4b469d67e2..d2a0d2ff4b 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -82,10 +82,9 @@ public enum UsageFormatter { public static func percentText(_ percent: Double, suffix: String) -> String { let clamped = min(100, max(0, percent)) - if clamped > 0, clamped < 1 { - return self.localized("<1%% %@", suffix) - } - return self.localized("%.0f%% %@", clamped, suffix) + let text = self.localized("%.0f%% %@", clamped, suffix) + guard clamped > 0, clamped < 1 else { return text } + return text.replacingOccurrences(of: "0%", with: "<1%") } public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { @@ -138,6 +137,21 @@ public enum UsageFormatter { return date.formatted(.dateTime.month(.abbreviated).day().hour().minute().locale(self.currentLocale())) } + public static func preciseDateTimeDescription(from date: Date, now: Date = .init()) -> String { + let calendar = Calendar.current + if calendar.isDate(date, inSameDayAs: now) { + return date.formatted(.dateTime.hour().minute().second().locale(self.currentLocale())) + } + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), + calendar.isDate(date, inSameDayAs: tomorrow) + { + let timeStr = date.formatted(.dateTime.hour().minute().second().locale(self.currentLocale())) + return self.localized("reset_tomorrow_format", timeStr) + } + return date.formatted( + .dateTime.year().month(.abbreviated).day().hour().minute().second().locale(self.currentLocale())) + } + public static func resetLine( for window: RateWindow, style: ResetTimeDisplayStyle, @@ -245,6 +259,15 @@ public enum UsageFormatter { value.formatted(.currency(code: currencyCode).locale(Locale(identifier: "en_US"))) } + public static func decimalString(_ value: Double, fractionDigits: Int = 2) -> String { + String(format: "%.\(fractionDigits)f", value) + } + + public static func optionalPercentString(_ percent: Double?, fractionDigits: Int = 2) -> String { + guard let percent else { return "—" } + return String(format: "%.\(fractionDigits)f%%", percent) + } + public static func compactCurrencyString(_ value: Double, currencyCode: String) -> String { if value != 0, abs(value) < 1 { return self.currencyString(value, currencyCode: currencyCode) @@ -256,6 +279,10 @@ public enum UsageFormatter { } public static func tokenCountString(_ value: Int) -> String { + self.tokenCountString(value, fractionDigits: 1) + } + + public static func tokenCountString(_ value: Int, fractionDigits: Int) -> String { let absValue = abs(value) let sign = value < 0 ? "-" : "" @@ -268,7 +295,9 @@ public enum UsageFormatter { for unit in units where absValue >= unit.threshold { let scaled = Double(absValue) / unit.divisor let formatted: String - if scaled >= 10 { + if fractionDigits == 2 { + formatted = String(format: "%.2f", scaled) + } else if scaled >= 10 { formatted = String(format: "%.0f", scaled) } else { var s = String(format: "%.1f", scaled) @@ -399,7 +428,7 @@ public enum UsageFormatter { nil } - let tokenDetail = totalTokens.map(self.tokenCountString) + let tokenDetail = totalTokens.map { self.tokenCountString($0) } let parts = [costDetail, tokenDetail].compactMap(\.self) guard !parts.isEmpty else { return nil } return parts.joined(separator: " · ") diff --git a/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-credit-normal.json b/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-credit-normal.json new file mode 100644 index 0000000000..5c1fb7e19d --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-credit-normal.json @@ -0,0 +1,29 @@ +{ + "total_credits": 20000, + "used_credits": 0, + "remaining_credits": 20000, + "credit_packages_details": [ + { + "package_id": "6107592317211223800", + "group_id": "2040544334402560487", + "total_count": "20000", + "usage_count": "0", + "remains_count": "20000" + } + ], + "balance_breakdown": { + "total_balance": 20000, + "buckets": [ + { + "source": 2, + "balance": 20000, + "expire_time_ms": 1784995199999, + "order_id_prefix": "ORD-CR" + } + ] + }, + "base_resp": { + "status_code": 0, + "status_msg": "success" + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index ce5f01de36..aa98be6143 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -101,47 +101,6 @@ struct OverviewMenuCardVisibilityTests { } struct ProviderInlineDashboardModelTests { - @Test - func `kimi model orders rate limit before weekly quota`() throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let metadata = try #require(ProviderDefaults.metadata[.kimi]) - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 18.3, - windowMinutes: nil, - resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), - resetDescription: "375/2048 requests"), - secondary: RateWindow( - usedPercent: 9.5, - windowMinutes: 300, - resetsAt: now.addingTimeInterval(4 * 60 * 60), - resetDescription: "Rate: 19/200 per 5 hours"), - updatedAt: now) - - let model = UsageMenuCardView.Model.make(.init( - provider: .kimi, - 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(\.id) == ["secondary", "primary"]) - #expect(model.metrics.map(\.title) == ["Rate Limit", "Weekly"]) - } - @Test func `openrouter period usage gets inline dashboard`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) @@ -688,6 +647,7 @@ struct MiniMaxMenuCardModelTests { @Test func `minimax token plan model shows weekly quota and points balance`() throws { let now = Date() + let pointsBalanceExpiresAt = Date(timeIntervalSince1970: 1_784_995_199) let minimax = MiniMaxUsageSnapshot( planName: "Token Plan · TokenPlanPlus-年度会员", availablePrompts: nil, @@ -718,6 +678,7 @@ struct MiniMaxMenuCardModelTests { resetDescription: "Resets in 6 days"), ], pointsBalance: 14000, + pointsBalanceExpiresAt: pointsBalanceExpiresAt, subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) let snapshot = minimax.toUsageSnapshot() let metadata = try #require(ProviderDefaults.metadata[.minimax]) @@ -755,6 +716,10 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[1].cardStyle == false) #expect(model.providerCost?.title == "Credits") #expect(model.providerCost?.spendLine == "Balance: 14000") + let expectedExpiresLine = String( + format: L("Expires: %@"), + UsageFormatter.preciseDateTimeDescription(from: pointsBalanceExpiresAt, now: now)) + #expect(model.providerCost?.personalSpendLine == expectedExpiresLine) #expect(model.usageNotes == [String(format: L("Renews: %@"), minimaxRenewDate(1_810_569_600))]) } } diff --git a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift index 8a1d1c9999..62430573ad 100644 --- a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift +++ b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift @@ -52,6 +52,18 @@ struct MiniMaxCurrentTokenPlanResponseTests { body: "
Coding Plan Plus available usage 1000 prompts 5 hours
", contentType: "text/html") } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"remaining_credits":0,"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } #expect(url.host == "platform.minimaxi.com") #expect(url.path == "/v1/api/openplatform/coding_plan/remains") return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") @@ -72,6 +84,8 @@ struct MiniMaxCurrentTokenPlanResponseTests { #expect(requests.map { $0.url?.host } == [ "platform.minimaxi.com", "platform.minimaxi.com", + "www.minimaxi.com", + "www.minimaxi.com", ]) } diff --git a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift new file mode 100644 index 0000000000..3c2cd34db5 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift @@ -0,0 +1,182 @@ +import CommonCrypto +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +#if os(macOS) +struct MiniMaxDesktopCookieImporterTests { + @Test + func `imports minimax agent cookies from desktop sqlite`() throws { + let databaseURL = try self.makeCookiesDatabase( + records: [ + (".www.minimaxi.com", "_token", "desktop-token-value", nil), + ("agent.minimaxi.com", "_token", "agent-token-value", nil), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession(databaseURL: databaseURL) + #expect(session?.sourceLabel == "MiniMax Agent") + #expect(session?.cookieHeader.contains("_token=desktop-token-value") == true) + #expect(session?.cookieHeader.contains("agent-token-value") == false) + } + + @Test + func `imports platform console cookies from desktop sqlite`() throws { + let databaseURL = try self.makeCookiesDatabase( + records: [ + ("platform.minimaxi.com", "_token", "platform-token-value", nil), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession(databaseURL: databaseURL) + #expect(session?.cookieHeader.contains("_token=platform-token-value") == true) + } + + @Test + func `imports parent domain minimax agent cookies`() throws { + let databaseURL = try self.makeCookiesDatabase( + records: [ + (".minimaxi.com", "_token", "parent-domain-token", nil), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession(databaseURL: databaseURL) + #expect(session?.cookieHeader.contains("_token=parent-domain-token") == true) + } + + @Test + func `imports encrypted desktop cookies when plaintext value is empty`() throws { + let password = "desktop-test-password" + let encrypted = try self.makeEncryptedCookieValue(plaintext: "encrypted-token-value", password: password) + let databaseURL = try self.makeCookiesDatabase( + records: [ + (".www.minimaxi.com", "_token", "", encrypted), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession( + databaseURL: databaseURL, + decryptionKeys: [self.deriveKey(from: password)]) + #expect(session?.cookieHeader.contains("_token=encrypted-token-value") == true) + } + + private func makeEncryptedCookieValue(plaintext: String, password: String) throws -> Data { + let key = self.deriveKey(from: password) + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + let payload = plaintext.data(using: .utf8) ?? Data() + var outLength = 0 + var out = Data(count: payload.count + kCCBlockSizeAES128) + let outCapacity = out.count + let status = out.withUnsafeMutableBytes { outBytes in + payload.withUnsafeBytes { payloadBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + payloadBytes.baseAddress, + payload.count, + outBytes.baseAddress, + outCapacity, + &outLength) + } + } + } + } + guard status == kCCSuccess else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("encrypt failed") + } + out.count = outLength + var encrypted = Data("v10".utf8) + encrypted.append(out) + return encrypted + } + + private func deriveKey(from password: String) -> Data { + let salt = Data("saltysalt".utf8) + var key = Data(count: kCCKeySizeAES128) + let keyLength = key.count + _ = key.withUnsafeMutableBytes { keyBytes in + password.utf8CString.withUnsafeBytes { passBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passBytes.bindMemory(to: Int8.self).baseAddress, + passBytes.count - 1, + saltBytes.bindMemory(to: UInt8.self).baseAddress, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + keyBytes.bindMemory(to: UInt8.self).baseAddress, + keyLength) + } + } + } + return key + } + + private func makeCookiesDatabase( + records: [(String, String, String, Data?)]) throws -> URL + { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("minimax-desktop-cookies-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let databaseURL = directory.appendingPathComponent("Cookies") + + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("open failed") + } + defer { sqlite3_close(db) } + + let createSQL = """ + CREATE TABLE cookies ( + host_key TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + encrypted_value BLOB + ); + """ + guard sqlite3_exec(db, createSQL, nil, nil, nil) == SQLITE_OK else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("create failed") + } + + let insertSQL = "INSERT INTO cookies(host_key, name, value, encrypted_value) VALUES (?, ?, ?, ?);" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, insertSQL, -1, &stmt, nil) == SQLITE_OK else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("prepare failed") + } + defer { sqlite3_finalize(stmt) } + + for (host, name, value, encrypted) in records { + sqlite3_reset(stmt) + sqlite3_clear_bindings(stmt) + sqlite3_bind_text(stmt, 1, host, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_text(stmt, 2, name, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_text(stmt, 3, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + if let encrypted { + _ = encrypted.withUnsafeBytes { bytes in + sqlite3_bind_blob( + stmt, + 4, + bytes.baseAddress, + Int32(encrypted.count), + unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + } + } else { + sqlite3_bind_null(stmt, 4) + } + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("insert failed") + } + } + + return databaseURL + } +} +#endif diff --git a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift index 461c7f66ae..0a428d751c 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift @@ -4,6 +4,375 @@ import Testing @testable import CodexBar struct MiniMaxMenuCardBillingTests { + @Test + func `minimax usage summary renders cost inline dashboard when pricing is available`() throws { + let now = Date() + let summary = MiniMaxUsageSummary( + totalDays: 77, + totalTokenConsumed: "2.37B", + usageRankingPercent: 3.8, + activeDays: 60, + currentConsecutiveDays: 31, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [201_889, 2_800_317, 4_486_224, 88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-06-29", + totalInputToken: 200_395, + totalCacheReadToken: 172_249, + totalCacheCreateToken: 0, + totalOutputToken: 1494, + totalToken: 201_889, + cacheHitPercent: 85.95, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 200_395, + cacheReadToken: 172_249, + cacheCreateToken: 0, + outputToken: 1494, + totalToken: 374_138, + cacheHitPercent: 85.95), + ]), + MiniMaxUsageSummaryDay( + date: "2026-06-30", + totalInputToken: 2_778_091, + totalCacheReadToken: 1_809_827, + totalCacheCreateToken: 0, + totalOutputToken: 22226, + totalToken: 2_800_317, + cacheHitPercent: 65.15, + models: []), + MiniMaxUsageSummaryDay( + date: "2026-07-01", + totalInputToken: 4_454_050, + totalCacheReadToken: 3_139_433, + totalCacheCreateToken: 0, + totalOutputToken: 32174, + totalToken: 4_486_224, + cacheHitPercent: 70.48, + models: []), + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.6), + ]), + ]) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: nil, + billingSummary: nil, + usageSummary: summary) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + 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: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis[0].title == "Today") + #expect(model.inlineUsageDashboard?.kpis[0].value == "$0.03") + #expect(model.inlineUsageDashboard?.kpis[1].title == "30d cost") + #expect(model.inlineUsageDashboard?.kpis[2].title == "07-02 20:00 usage") + #expect(model.inlineUsageDashboard?.kpis[2].value == "88.00K") + #expect(model.inlineUsageDashboard?.kpis[3].title == "7d tokens") + #expect(model.inlineUsageDashboard?.kpis[3].value == "7.58M") + #expect(model.inlineUsageDashboard?.kpis[4].title == "Cache hit") + #expect(model.inlineUsageDashboard?.kpis[4].value == "74.63%") + #expect(model.inlineUsageDashboard?.kpis[5].title == "30d tokens") + #expect(model.inlineUsageDashboard?.kpis[5].value == "7.58M") + #expect( + model.inlineUsageDashboard?.detailLines.contains { + $0.contains("Top model") + } == true) + } + + @Test + func `minimax usage summary hides cost inline dashboard when cost summary is disabled`() throws { + let now = Date() + let summary = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "88K", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.6), + ]), + ]) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: nil, + billingSummary: nil, + usageSummary: summary) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + 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.inlineUsageDashboard?.kpis.contains { $0.title == "Today" } == false) + #expect(model.inlineUsageDashboard?.kpis.contains { $0.title == "30d cost" } == false) + #expect(model.inlineUsageDashboard?.kpis.first?.title == "07-02 20:00 usage") + } + + @Test + func `minimax usage summary still renders token inline dashboard without priced models`() throws { + let now = Date() + let summary = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "1", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [1], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 1, + totalCacheReadToken: 0, + totalCacheCreateToken: 0, + totalOutputToken: 0, + totalToken: 1, + cacheHitPercent: 0, + models: [ + MiniMaxUsageSummaryModel( + model: "coding-plan-vlm", + inputToken: 1, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 0, + totalToken: 1, + cacheHitPercent: 0), + ]), + ]) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: nil, + billingSummary: nil, + usageSummary: summary) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + 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(summary.toCostUsageTokenSnapshot() == nil) + #expect(model.inlineUsageDashboard?.kpis[0].title == "07-02 20:00 usage") + #expect(model.inlineUsageDashboard?.kpis[0].value == "1") + } + + @Test + func `minimax usage summary KPIs format tokens to two decimal places`() throws { + let now = Date() + let summary = MiniMaxUsageSummary( + totalDays: 77, + totalTokenConsumed: "1.1B", + usageRankingPercent: 3.8, + activeDays: 60, + currentConsecutiveDays: 31, + lastUpdateTime: "07-02 21:00", + dailyTokenUsage: [88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "coding-plan-vlm", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63), + ]), + ]) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: nil, + billingSummary: nil, + usageSummary: summary) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + 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.inlineUsageDashboard?.kpis[0].value == "88.00K") + #expect(model.inlineUsageDashboard?.kpis[1].value == "88.00K") + #expect(model.inlineUsageDashboard?.kpis[2].value == "74.63%") + #expect(model.inlineUsageDashboard?.kpis[3].value == "88.00K") + } + @Test func `minimax billing history renders inline dashboard`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 03dd11c874..92ccc14852 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -33,12 +33,14 @@ struct MiniMaxEndpointOverrideSettingsTests { MiniMaxSettingsReader.hostKey: "https://attacker.example", MiniMaxSettingsReader.codingPlanURLKey: "https://attacker.example/coding-plan", MiniMaxSettingsReader.remainsURLKey: "https://attacker.example/remains", + MiniMaxSettingsReader.tokenPlanCreditURLKey: "https://attacker.example/token-plan-credit", MiniMaxSettingsReader.billingHistoryURLKey: "https://attacker.example/account/amount", ] #expect(MiniMaxSettingsReader.hostOverride(environment: env) == nil) #expect(MiniMaxSettingsReader.codingPlanURL(environment: env) == nil) #expect(MiniMaxSettingsReader.remainsURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.tokenPlanCreditURL(environment: env) == nil) #expect(MiniMaxSettingsReader.billingHistoryURL(environment: env) == nil) } @@ -50,12 +52,14 @@ struct MiniMaxEndpointOverrideSettingsTests { MiniMaxSettingsReader.hostKey: encodedSlash, MiniMaxSettingsReader.codingPlanURLKey: "\(encodedSlash)/coding-plan", MiniMaxSettingsReader.remainsURLKey: "\(encodedSlash)/remains", + MiniMaxSettingsReader.tokenPlanCreditURLKey: "\(encodedSlash)/token-plan-credit", MiniMaxSettingsReader.billingHistoryURLKey: "\(encodedSlash)/account/amount", ] #expect(MiniMaxSettingsReader.hostOverride(environment: env) == nil) #expect(MiniMaxSettingsReader.codingPlanURL(environment: env) == nil) #expect(MiniMaxSettingsReader.remainsURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.tokenPlanCreditURL(environment: env) == nil) #expect(MiniMaxSettingsReader.billingHistoryURL(environment: env) == nil) #expect(MiniMaxSettingsReader.hostOverride(environment: [ MiniMaxSettingsReader.hostKey: doubleEncodedSlash, @@ -297,6 +301,7 @@ struct MiniMaxCookieHeaderTests { } } +// swiftlint:disable type_body_length struct MiniMaxUsageParserTests { @Test func `signed out check ignores login copy inside scripts`() { @@ -663,7 +668,7 @@ struct MiniMaxUsageParserTests { let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) let expectedUsed = Double(11) / Double(15000) * 100 - let expectedReset = Date(timeIntervalSince1970: TimeInterval(end) / 1000) + let expectedReset = Date(timeIntervalSince1970: 1_700_000_100 + (8_941_292.0 / 1000.0)) #expect(snapshot.planName == "Max") #expect(snapshot.availablePrompts == 15000) @@ -950,6 +955,18 @@ struct MiniMaxUsageParserTests { body: Self.codingPlanJSON, contentType: "application/json") } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"remaining_credits":0,"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } #expect(url.path == "/account/amount") #expect(url.query?.contains("aggregate=false") == true) #expect(request.value(forHTTPHeaderField: "Cookie") == "HERTZ-SESSION=abc") @@ -993,6 +1010,12 @@ struct MiniMaxUsageParserTests { body: Self.codingPlanJSON, contentType: "application/json") } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"remaining_credits":0,"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } let page = URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems? .first { $0.name == "page" }? @@ -1028,15 +1051,31 @@ struct MiniMaxUsageParserTests { } @Test - func `web usage fetch skips billing history when optional usage is disabled`() async throws { + func `web usage fetch still enriches credit and usage summary when billing history disabled`() async throws { let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) let transport = ProviderHTTPTransportStub { request in let url = try #require(request.url) - #expect(url.path.contains("coding-plan")) - return Self.httpResponse( - url: url, - body: Self.codingPlanJSON, - contentType: "application/json") + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"not login"}}"#, + statusCode: 401, + contentType: "application/json") + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") } let snapshot = try await MiniMaxUsageFetcher.fetchUsage( @@ -1050,7 +1089,188 @@ struct MiniMaxUsageParserTests { let requests = await transport.requests() #expect(snapshot.currentPrompts == 2) #expect(snapshot.billingSummary == nil) - #expect(requests.count == 1) + #expect(snapshot.pointsBalance == nil) + let billingRequests = requests.filter { $0.url?.path == "/account/amount" } + #expect(billingRequests.isEmpty) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan_credit" }) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan/usage_summary" }) + } + + @Test + func `web enrichment reports expired credentials without discarding API quota`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + let quota = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + let context = MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=expired", + authorizationToken: nil, + region: .global, + environment: [:], + transport: transport) + + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: quota, + context: context, + groupID: nil) + + #expect(attempt.rejectedCredentials) + #expect(!attempt.receivedWebData) + #expect(!attempt.accountMismatch) + #expect(attempt.snapshot.currentPrompts == 25) + #expect(attempt.snapshot.usageSummary == nil) + #expect(attempt.snapshot.pointsBalance == nil) + #expect(await transport.requests().count == 2) + } + + @Test + func `web enrichment rejects credit response for a different group`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("usage_summary") { + return Self.httpResponse(url: url, body: "{}", statusCode: 503, contentType: "application/json") + } + let body = """ + { + "remaining_credits": 20000, + "credit_packages_details": [{"group_id":"different-group"}], + "base_resp":{"status_code":0} + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + let quota = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + let context = MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=valid", + authorizationToken: nil, + region: .global, + environment: [:], + transport: transport) + + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: quota, + context: context, + groupID: "expected-group") + + #expect(attempt.accountMismatch) + #expect(!attempt.receivedWebData) + #expect(attempt.snapshot.pointsBalance == nil) + #expect(attempt.snapshot.currentPrompts == 25) + } + + @Test + func `web enrichment requires verified group match before attaching summary`() async throws { + let summaryBody = """ + { + "daily_token_usage": [100], + "date_model_usage": [], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("usage_summary") { + return Self.httpResponse(url: url, body: summaryBody, contentType: "application/json") + } + let body = """ + { + "remaining_credits": 20000, + "credit_packages_details": [{"group_id":"verified-group"}], + "base_resp":{"status_code":0} + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + let quota = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + let context = MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=valid", + authorizationToken: nil, + region: .global, + environment: [:], + transport: transport) + + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: quota, + context: context, + groupID: "verified-group") + + #expect(attempt.receivedWebData) + #expect(attempt.snapshot.usageSummary?.latestSnapshotTokens == 100) + #expect(attempt.snapshot.pointsBalance == 20000) + } + + @Test + func `web enrichment ignores summary when credit group does not match`() async throws { + let summaryBody = """ + { + "daily_token_usage": [100], + "date_model_usage": [], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("usage_summary") { + return Self.httpResponse(url: url, body: summaryBody, contentType: "application/json") + } + let body = """ + { + "remaining_credits": 20000, + "credit_packages_details": [{"group_id":"other-group"}], + "base_resp":{"status_code":0} + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + let quota = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: quota, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=valid", + authorizationToken: nil, + region: .global, + environment: [:], + transport: transport), + groupID: "expected-group") + + #expect(attempt.accountMismatch) + #expect(!attempt.receivedWebData) + #expect(attempt.snapshot.usageSummary == nil) + #expect(attempt.snapshot.pointsBalance == nil) } @Test @@ -1162,6 +1382,7 @@ struct MiniMaxUsageParserTests { } } +// swiftlint:enable type_body_length struct MiniMaxAPIRegionTests { @Test func `defaults to global hosts`() { @@ -1198,6 +1419,57 @@ struct MiniMaxAPIRegionTests { #expect(url.path == "/v1/token_plan/remains") } + @Test + func `dashboard URL opens console usage page`() { + #expect(MiniMaxAPIRegion.global.dashboardURL.absoluteString == "https://platform.minimax.io/console/usage") + #expect(MiniMaxAPIRegion.chinaMainland.dashboardURL.absoluteString == + "https://platform.minimaxi.com/console/usage") + #expect(MiniMaxProviderDescriptor.descriptor.metadata.dashboardURL == + MiniMaxAPIRegion.global.dashboardURL.absoluteString) + } + + @Test + func `resolves token plan credit URLs on www hosts`() { + let global = MiniMaxAPIRegion.global.tokenPlanCreditURL + let china = MiniMaxAPIRegion.chinaMainland.tokenPlanCreditURL + #expect(global.host == "www.minimax.io") + #expect(global.path == "/backend/account/token_plan_credit") + #expect(china.host == "www.minimaxi.com") + #expect(china.path == "/backend/account/token_plan_credit") + } + + @Test + func `token plan credit URL override wins`() throws { + let override = try #require(URL(string: "https://www.minimaxi.com/backend/account/token_plan_credit")) + let env = [MiniMaxSettingsReader.tokenPlanCreditURLKey: override.absoluteString] + let resolved = try MiniMaxTokenPlanCreditFetcher.resolveCreditURL(region: .global, environment: env) + #expect(resolved == override) + } + + @Test + func `host override selects matching www token plan credit host`() throws { + let chinaEnv = [MiniMaxSettingsReader.hostKey: "platform.minimaxi.com"] + let chinaResolved = try MiniMaxTokenPlanCreditFetcher.resolveCreditURL( + region: .global, + environment: chinaEnv) + #expect(chinaResolved.host == "www.minimaxi.com") + #expect(chinaResolved.path == "/backend/account/token_plan_credit") + + let globalEnv = [MiniMaxSettingsReader.hostKey: "platform.minimax.io"] + let globalResolved = try MiniMaxTokenPlanCreditFetcher.resolveCreditURL( + region: .chinaMainland, + environment: globalEnv) + #expect(globalResolved.host == "www.minimax.io") + #expect(globalResolved.path == "/backend/account/token_plan_credit") + } + + @Test + func `host override routes token plan credit through custom proxy host`() throws { + let env = [MiniMaxSettingsReader.hostKey: "proxy.example.test:8443"] + let resolved = try MiniMaxTokenPlanCreditFetcher.resolveCreditURL(region: .global, environment: env) + #expect(resolved.absoluteString == "https://proxy.example.test:8443/backend/account/token_plan_credit") + } + @Test func `host override wins for remains and coding plan`() { let env = [MiniMaxSettingsReader.hostKey: "api.minimaxi.com"] @@ -1207,6 +1479,30 @@ struct MiniMaxAPIRegionTests { #expect(remains.host == "api.minimaxi.com") } + @Test + func `host override routes usage summary through custom proxy host`() throws { + let env = [MiniMaxSettingsReader.hostKey: "proxy.example.test:8443"] + let resolved = try MiniMaxUsageSummaryFetcher.resolveUsageSummaryURL(region: .global, environment: env) + #expect(resolved?.absoluteString == "https://proxy.example.test:8443/backend/account/token_plan/usage_summary") + } + + @Test + func `host override maps minimax hosts to www usage summary endpoint`() throws { + let chinaEnv = [MiniMaxSettingsReader.hostKey: "platform.minimaxi.com"] + let chinaResolved = try MiniMaxUsageSummaryFetcher.resolveUsageSummaryURL( + region: .global, + environment: chinaEnv) + #expect(chinaResolved?.host == "www.minimaxi.com") + #expect(chinaResolved?.path == "/backend/account/token_plan/usage_summary") + + let globalEnv = [MiniMaxSettingsReader.hostKey: "platform.minimax.io"] + let globalResolved = try MiniMaxUsageSummaryFetcher.resolveUsageSummaryURL( + region: .chinaMainland, + environment: globalEnv) + #expect(globalResolved?.host == "www.minimax.io") + #expect(globalResolved?.path == "/backend/account/token_plan/usage_summary") + } + @Test func `billing history url uses account amount endpoint`() { let url = MiniMaxUsageFetcher.resolveBillingHistoryURL(region: .chinaMainland, environment: [:], page: 2) diff --git a/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift new file mode 100644 index 0000000000..cbbf2e14cf --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift @@ -0,0 +1,153 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private enum MiniMaxResetDescriptionTestIntervals { + static let twoHoursThirtyFourMinutes: TimeInterval = 9240 + static let threeDaysFiveHoursTwelveMinutes: TimeInterval = 277_920 + static let twoHoursFifteenMinutesMillis = 8_100_000 + static let twoHoursMillis = 7_200_000 + static let fiveHoursMillis = 18_000_000 +} + +struct MiniMaxResetDescriptionTests { + @Test + func `countdown phrase includes minutes for multi hour windows`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let resetsAt = now.addingTimeInterval(MiniMaxResetDescriptionTestIntervals.twoHoursThirtyFourMinutes) + + #expect(MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: now) == "2 hours 34 minutes") + #expect( + MiniMaxServiceUsage + .generateResetDescription(resetsAt: resetsAt, now: now) == "Resets in 2 hours 34 minutes") + } + + @Test + func `countdown phrase includes days hours and minutes for weekly windows`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let resetsAt = now.addingTimeInterval(MiniMaxResetDescriptionTestIntervals.threeDaysFiveHoursTwelveMinutes) + + #expect( + MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: now) == "3 days 5 hours 12 minutes") + } + + @Test + func `countdown phrase rounds up partial minutes`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let resetsAt = now.addingTimeInterval(61) + + #expect(MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: now) == "2 minutes") + } + + @Test + func `coding plan service reset description matches end time precision`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + MiniMaxResetDescriptionTestIntervals.twoHoursFifteenMinutesMillis + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end) + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let service = try #require(snapshot.services?.first) + + #expect(service.resetDescription == "Resets in 2 hours 15 minutes") + } + + @Test + func `reset description prefers remains time over window end`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + MiniMaxResetDescriptionTestIntervals.twoHoursMillis + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "remains_time": 8100000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let service = try #require(snapshot.services?.first) + + #expect(service.resetDescription == "Resets in 2 hours 15 minutes") + } + + @Test + func `five hour reset ignores implausible remains time`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + MiniMaxResetDescriptionTestIntervals.fiveHoursMillis + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 100, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 100, + "start_time": \(start), + "end_time": \(end), + "remains_time": 950400000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + let service = try #require(services.first { $0.windowType == "5 hours" }) + + #expect(service.resetsAt == Date(timeIntervalSince1970: TimeInterval(end) / 1000)) + #expect(service.resetDescription == "Resets in 5 hours") + } + + @Test + func `eight hour reset keeps plausible remains time cap`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let eightHoursMillis = 28_800_000 + let end = start + eightHoursMillis + let remainsMillis = 25_200_000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 100, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 100, + "start_time": \(start), + "end_time": \(end), + "remains_time": \(remainsMillis) + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let service = try #require(snapshot.services?.first { $0.windowType == "8 hours" }) + #expect(service.resetsAt == now.addingTimeInterval(TimeInterval(remainsMillis) / 1000)) + } +} diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index b7a0045722..32551184b8 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -255,6 +255,18 @@ struct MiniMaxTokenPlanChangeTests { if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { return Self.httpResponse(url: url, body: "not json", contentType: "application/json") } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"remaining_credits":0,"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } #expect(url.host == "www.minimaxi.com") #expect(url.path == "/v1/api/openplatform/coding_plan/remains") return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") @@ -292,6 +304,18 @@ struct MiniMaxTokenPlanChangeTests { if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { throw URLError(.timedOut) } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"remaining_credits":0,"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } #expect(url.host == "www.minimaxi.com") #expect(url.path == "/v1/api/openplatform/coding_plan/remains") return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") @@ -311,6 +335,8 @@ struct MiniMaxTokenPlanChangeTests { "platform.minimaxi.com", "platform.minimaxi.com", "www.minimaxi.com", + "www.minimaxi.com", + "www.minimaxi.com", ]) } @@ -594,6 +620,18 @@ struct MiniMaxTokenPlanChangeTests { if url.path.contains("coding_plan/remains") { return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"remaining_credits":0,"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } #expect(url.host == "www.minimaxi.com") #expect(url.path == "/v1/api/openplatform/charge/combo/cycle_audio_resource_package") #expect(url.query?.contains("biz_line=2") == true) diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift new file mode 100644 index 0000000000..6be3c49022 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -0,0 +1,493 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MiniMaxTokenPlanCreditTests { + @Test + func `parses token plan credit balance from console payload`() throws { + let data = try Data(contentsOf: Self.fixtureURL(named: "token-plan-credit-normal.json")) + let snapshot = try MiniMaxTokenPlanCreditFetcher.parseSnapshot(data: data) + #expect(snapshot.balance == 20000) + #expect(snapshot.expiresAt == Date(timeIntervalSince1970: 1_784_995_199.999)) + #expect(snapshot.groupIDs == ["2040544334402560487"]) + } + + @Test + func `parses token plan credit balance from balance breakdown fallback`() throws { + let body = """ + { + "balance_breakdown": { "total_balance": 15000 }, + "base_resp": { "status_code": 0 } + } + """ + let balance = try MiniMaxTokenPlanCreditFetcher.parseBalance(data: Data(body.utf8)) + #expect(balance == 15000) + } + + @Test + func `parses token plan credit balance from total minus used fallback`() throws { + let body = """ + { + "total_credits": 20000, + "used_credits": 3500, + "base_resp": { "status_code": 0 } + } + """ + let balance = try MiniMaxTokenPlanCreditFetcher.parseBalance(data: Data(body.utf8)) + #expect(balance == 16500) + } + + @Test + func `token plan credit enrichment is best effort when session is invalid`() async throws { + let remainsSnapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 0, + remainingPrompts: 1000, + windowMinutes: 300, + usedPercent: 0, + resetsAt: nil, + updatedAt: Date(), + services: nil) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"not login"}}"#, + statusCode: 401, + contentType: "application/json") + } + + let enriched = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + to: remainsSnapshot, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=abc", + authorizationToken: nil, + region: .chinaMainland, + environment: [:], + transport: transport), + groupID: nil) + + #expect(enriched.pointsBalance == nil) + #expect(enriched.planName == "Max") + } + + @Test + func `web usage fetch still enriches credit and usage summary when billing history disabled`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"not login"}}"#, + statusCode: 401, + contentType: "application/json") + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + #expect(snapshot.pointsBalance == nil) + let requests = await transport.requests() + #expect(!requests.contains { $0.url?.path.contains("account/amount") == true }) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan_credit" }) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan/usage_summary" }) + } + + @Test + func `token plan credit enrichment propagates cancellation`() async { + let remainsSnapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 0, + remainingPrompts: 1000, + windowMinutes: 300, + usedPercent: 0, + resetsAt: nil, + updatedAt: Date(), + services: nil) + let transport = ProviderHTTPTransportStub { _ in + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + _ = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + to: remainsSnapshot, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=abc", + authorizationToken: nil, + region: .chinaMainland, + environment: [:], + transport: transport), + groupID: nil) + } + } + + @Test + func `token plan credit enrichment forwards group id from curl override`() async throws { + let creditJSON = try String(contentsOf: Self.fixtureURL(named: "token-plan-credit-normal.json")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "x-group-id") == "2040544334402560487") + return Self.httpResponse(url: url, body: creditJSON, contentType: "application/json") + } + let curl = """ + curl 'https://platform.minimaxi.com/' \\ + -H 'Cookie: HERTZ-SESSION=abc' \\ + -H 'x-group-id: 2040544334402560487' + """ + guard let override = MiniMaxCookieHeader.override(from: curl), + let cookie = MiniMaxCookieHeader.normalized(from: override.cookieHeader) + else { + Issue.record("Expected curl override to preserve group id") + return + } + + let enriched = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + to: MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 0, + remainingPrompts: 1000, + windowMinutes: 300, + usedPercent: 0, + resetsAt: nil, + updatedAt: Date(), + services: nil), + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: cookie, + authorizationToken: override.authorizationToken, + region: .chinaMainland, + environment: [:], + transport: transport), + groupID: override.groupID) + + #expect(enriched.pointsBalance == 20000) + } + + @Test + func `api usage fetch merges token plan credit when explicit cookie is available`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let creditJSON = try String(contentsOf: Self.fixtureURL(named: "token-plan-credit-normal.json")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + #expect(url.path == "/backend/account/token_plan_credit") + return Self.httpResponse(url: url, body: creditJSON, contentType: "application/json") + } + + let remainsSnapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + now: now, + session: transport) + let enriched = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + to: remainsSnapshot, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "_token=abc; minimax_group_id_v2=2040544334402560487", + authorizationToken: nil, + region: .chinaMainland, + environment: [:], + transport: transport), + groupID: "2040544334402560487") + + #expect(remainsSnapshot.pointsBalance == nil) + #expect(enriched.pointsBalance == 20000) + } + + @Test + func `dedicated credit endpoint replaces remains fallback balance`() { + let snapshot = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 0, + remainingPrompts: 100, + windowMinutes: 300, + usedPercent: 0, + resetsAt: nil, + updatedAt: Date(), + pointsBalance: 5000) + + let enriched = snapshot.withPointsBalanceFromDedicatedEndpoint(20000, expiresAt: nil) + #expect(enriched.pointsBalance == 20000) + } + + @Test + func `credit enrichment fetches missing expiry without replacing existing balance`() async throws { + let creditJSON = try String(contentsOf: Self.fixtureURL(named: "token-plan-credit-normal.json")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.httpResponse(url: url, body: creditJSON, contentType: "application/json") + } + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 0, + remainingPrompts: 1000, + windowMinutes: 300, + usedPercent: 0, + resetsAt: nil, + updatedAt: Date(), + pointsBalance: 12345) + + let enriched = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + to: snapshot, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=abc", + authorizationToken: nil, + region: .chinaMainland, + environment: [:], + transport: transport), + groupID: nil) + + #expect(enriched.pointsBalance == 12345) + #expect(enriched.pointsBalanceExpiresAt == Date(timeIntervalSince1970: 1_784_995_199.999)) + } + + @Test + func `api token fetch resolves china region after global rejection`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.host == "api.minimax.io" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + #expect(url.host == "api.minimaxi.com") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let result = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: transport) + + #expect(result.resolvedRegion == .chinaMainland) + } + + @Test + func `api credit enrichment uses resolved china web host after global retry`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let creditJSON = try String(contentsOf: Self.fixtureURL(named: "token-plan-credit-normal.json")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.host == "api.minimax.io" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + if url.host == "api.minimaxi.com", url.path.contains("remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/backend/account/token_plan_credit") + return Self.httpResponse(url: url, body: creditJSON, contentType: "application/json") + } + + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: transport) + let enriched = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + to: apiResult.snapshot, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=abc", + authorizationToken: nil, + region: apiResult.resolvedRegion, + environment: [:], + transport: transport), + groupID: nil) + + #expect(apiResult.resolvedRegion == .chinaMainland) + #expect(enriched.pointsBalance == 20000) + } + + @Test + func `api usage summary enrichment uses resolved china web host after global retry`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let summaryJSON = """ + { + "last_update_time": "07-02 18:00", + "daily_token_usage": [19880, 16060000, 1020000000], + "date_model_usage": [], + "base_resp": { "status_code": 0 } + } + """ + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.host == "api.minimax.io" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + if url.host == "api.minimaxi.com", url.path.contains("remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse(url: url, body: summaryJSON, contentType: "application/json") + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: transport) + let fetchContext = MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=abc", + authorizationToken: nil, + region: apiResult.resolvedRegion, + environment: [:], + transport: transport) + let enriched = try await MiniMaxUsageFetcher.attachingUsageSummaryIfAvailable( + to: apiResult.snapshot, + context: fetchContext, + groupID: nil) + + #expect(apiResult.resolvedRegion == .chinaMainland) + #expect(enriched.usageSummary?.hasDisplayableData == true) + #expect(enriched.usageSummary?.latestSnapshotTokens == 1_020_000_000) + #expect(enriched.usageSummary?.last7DaysTokens == 1_036_079_880) + } + + @Test + func `api usage summary auth failure does not discard valid api quota`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.host == "api.minimax.io" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + if url.host == "api.minimaxi.com", url.path.contains("remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: transport) + let enriched = try await MiniMaxUsageFetcher.attachingUsageSummaryIfAvailable( + to: apiResult.snapshot, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=abc; bearer=expired", + authorizationToken: "sk-cp-test", + region: apiResult.resolvedRegion, + environment: [:], + transport: transport), + groupID: nil) + + #expect(apiResult.resolvedRegion == .chinaMainland) + #expect(apiResult.snapshot.services?.isEmpty == false) + #expect(enriched.usageSummary == nil) + #expect(enriched.services?.isEmpty == false) + } + + private static func fixtureURL(named name: String) throws -> URL { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/Providers/MiniMax", isDirectory: true) + return root.appendingPathComponent(name) + } + + private static let percentBasedRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + @Test(.enabled(if: ProcessInfo.processInfo.environment["MINIMAX_LIVE_TEST"] == "1")) + func `live env cookie resolves and fetches recharge balance`() async throws { + let cookieHeader = try #require(MiniMaxSettingsReader.cookieHeader()) + let cookieOverride = MiniMaxCookieHeader.override(from: cookieHeader) + let config = try #require(try CodexBarConfigStore().load()) + let minimax = try #require(config.providers.first { $0.id == .minimax }) + let apiToken = try #require(minimax.apiKey) + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: apiToken, + region: MiniMaxAPIRegion(rawValue: minimax.region ?? "") ?? .global) + let credit = try await MiniMaxTokenPlanCreditFetcher.fetch( + cookieHeader: cookieHeader, + groupID: cookieOverride?.groupID, + region: apiResult.resolvedRegion, + environment: [:], + transport: ProviderHTTPClient.shared) + print("LIVE_MINIMAX_COOKIE_RESOLVED=true") + print("LIVE_MINIMAX_RESOLVED_REGION=\(apiResult.resolvedRegion.rawValue)") + print("LIVE_MINIMAX_BALANCE=\(credit.balance.map { String($0) } ?? "nil")") + let balanceValue = try #require(credit.balance) + #expect(balanceValue >= 0) + if let expectedRaw = ProcessInfo.processInfo.environment["MINIMAX_LIVE_TEST_EXPECTED_BALANCE"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !expectedRaw.isEmpty, + let expected = Double(expectedRaw) + { + #expect(balanceValue == expected) + } + } + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift new file mode 100644 index 0000000000..6ed24aeb95 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift @@ -0,0 +1,174 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiniMaxUsagePricingTests { + @Test + func `m27 standard pricing bills cache read separately from input`() { + let cost = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M2.7", + inputToken: 1_000_000, + cacheReadToken: 500_000, + cacheCreateToken: 0, + outputToken: 0) + + #expect(cost != nil) + #expect(abs((cost ?? 0) - 0.33) < 0.0001) + } + + @Test + func `m3 long context uses higher rates above threshold`() { + let shortContext = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M3-512k", + inputToken: 400_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 1_000_000) + let longContext = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M3", + inputToken: 600_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 1_000_000) + + #expect(shortContext != nil) + #expect(longContext != nil) + #expect((longContext ?? 0) > (shortContext ?? 0)) + } + + @Test + func `coding plan models are treated as zero cost`() { + let cost = MiniMaxUsagePricing.minimaxCostUSD( + model: "coding-plan-vlm", + inputToken: 1_000_000, + cacheReadToken: 500_000, + cacheCreateToken: 100_000, + outputToken: 250_000) + + #expect(cost == 0) + } + + @Test + func `unknown minimax models remain unpriced`() { + let cost = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M9-preview", + inputToken: 1_000_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 250_000) + + #expect(cost == nil) + } + + @Test + func `projected daily cost aggregates model breakdown`() { + let summary = Self.sampleSummary() + guard let day = summary.days.last else { + Issue.record("Expected sample summary day") + return + } + let cost = summary.projectedCostUSD(for: day) + #expect(cost != nil) + #expect((cost ?? 0) > 0) + #expect(summary.projectedCostUSD(lastDays: 30) != nil) + } + + @Test + func `daily aggregate model rows do not use per request long context pricing`() throws { + let shortRate = try #require(MiniMaxUsagePricing.minimaxAggregateCostUSD( + model: "MiniMax-M3", + inputToken: 600_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 1_000_000)) + let longRate = try #require(MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M3", + inputToken: 600_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 1_000_000)) + + #expect(shortRate < longRate) + } + + @Test + func `days without model attribution remain unpriced`() { + let summary = Self.sampleSummary() + let unattributed = MiniMaxUsageSummaryDay( + date: "2026-07-03", + totalInputToken: 1_000_000, + totalCacheReadToken: 500_000, + totalCacheCreateToken: 0, + totalOutputToken: 100_000, + totalToken: 1_100_000, + cacheHitPercent: 50, + models: []) + + #expect(summary.projectedCostUSD(for: unattributed) == nil) + #expect(summary.projectedModelBreakdowns(for: unattributed).isEmpty) + } + + @Test + func `snapshot day with model breakdown projects cost history`() { + let summary = Self.sampleSummary() + let snapshot = summary.toCostUsageTokenSnapshot( + historyDays: 30, + now: Date(timeIntervalSince1970: 1_735_689_600)) + + #expect(snapshot != nil) + #expect(snapshot?.sessionCostUSD != nil) + #expect(snapshot?.last30DaysCostUSD != nil) + #expect(snapshot?.daily.count == 2) + #expect(snapshot?.daily.last?.modelBreakdowns?.count == 1) + #expect(snapshot?.daily.last?.modelBreakdowns?.first?.modelName == "MiniMax-M3-512k") + } + + private static func sampleSummary() -> MiniMaxUsageSummary { + MiniMaxUsageSummary( + totalDays: 77, + totalTokenConsumed: "2.37B", + usageRankingPercent: 3.8, + activeDays: 60, + currentConsecutiveDays: 31, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [201_889, 2_800_317, 4_486_224, 88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-06-29", + totalInputToken: 200_395, + totalCacheReadToken: 172_249, + totalCacheCreateToken: 0, + totalOutputToken: 1494, + totalToken: 201_889, + cacheHitPercent: 85.95, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 200_395, + cacheReadToken: 172_249, + cacheCreateToken: 0, + outputToken: 1494, + totalToken: 374_138, + cacheHitPercent: 85.95), + ]), + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.6), + ]), + ]) + } +} diff --git a/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift b/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift new file mode 100644 index 0000000000..92c1d10a61 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift @@ -0,0 +1,170 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct MiniMaxUsageSummaryChartMenuViewTests { + @Test + @MainActor + func `day detail primary includes projected daily cost`() { + let usage = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "88K", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 21:00", + dailyTokenUsage: [88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.6), + ]), + ]) + let primary = MiniMaxUsageSummaryChartMenuView._dayDetailPrimaryForTesting( + usage: usage, + dateKey: "2026-07-02") + #expect(primary?.contains("74.63%") == true) + #expect(primary?.contains("$") == true) + } + + @Test + @MainActor + func `summary KPI values use two decimal token formatting`() { + let usage = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "88K", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 21:00", + dailyTokenUsage: [88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: []), + ]) + let values = MiniMaxUsageSummaryChartMenuView.summaryKPIValues(usage: usage) + #expect(values == ["88.00K", "88.00K", "88.00K", "74.63%"]) + } + + @Test + @MainActor + func `model breakdown keeps every item behind a bounded scrolling viewport`() { + #expect(MiniMaxUsageSummaryChartMenuView.detailViewportRowCount(itemCount: 6) == 4) + #expect(MiniMaxUsageSummaryChartMenuView.detailRowsNeedScrolling(itemCount: 6)) + #expect(!MiniMaxUsageSummaryChartMenuView.detailRowsNeedScrolling(itemCount: 3)) + } + + @Test + @MainActor + func `duplicate model names do not trap detail rendering`() { + let duplicate = MiniMaxUsageSummaryModel( + model: "MiniMax-M3", + inputToken: 100, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 10, + totalToken: 110, + cacheHitPercent: 0) + let usage = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "220", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 21:00", + dailyTokenUsage: [220], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 200, + totalCacheReadToken: 0, + totalCacheCreateToken: 0, + totalOutputToken: 20, + totalToken: 220, + cacheHitPercent: 0, + models: [duplicate, duplicate]), + ]) + + #expect(MiniMaxUsageSummaryChartMenuView._detailRowCountForTesting( + usage: usage, + dateKey: "2026-07-02") == 2) + } + + @Test + @MainActor + func `usage summary detail height follows visible rows and caps at viewport limit`() { + let singleRowHeight = MiniMaxUsageSummaryChartMenuView._detailViewportHeightForTesting( + modeSubtitlePresence: [true]) + let twoRowHeight = MiniMaxUsageSummaryChartMenuView._detailViewportHeightForTesting( + modeSubtitlePresence: [true, true]) + let fourRowHeight = MiniMaxUsageSummaryChartMenuView._detailViewportHeightForTesting( + modeSubtitlePresence: [true, true, true, true]) + let fiveRowHeight = MiniMaxUsageSummaryChartMenuView._detailViewportHeightForTesting( + modeSubtitlePresence: [true, true, true, true, true]) + + #expect(singleRowHeight < twoRowHeight) + #expect(twoRowHeight < fourRowHeight) + #expect(fiveRowHeight == fourRowHeight) + + let emptyBlockHeight = MiniMaxUsageSummaryChartMenuView._detailBlockHeightForTesting( + modeSubtitlePresence: []) + let populatedBlockHeight = MiniMaxUsageSummaryChartMenuView._detailBlockHeightForTesting( + modeSubtitlePresence: [true]) + #expect(emptyBlockHeight < populatedBlockHeight) + } + + @Test + @MainActor + func `usage summary card height grows with selected day model count`() { + let oneModel = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [true], + hasChart: true) + let threeModels = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [true, true, true], + hasChart: true) + let fourModels = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [true, true, true, true], + hasChart: true) + let fiveModels = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [true, true, true, true, true], + hasChart: true) + + #expect(oneModel < threeModels) + #expect(threeModels < fourModels) + #expect(fiveModels == fourModels) + } + + @Test + @MainActor + func `resolved card height includes chart section when data is present`() { + let withChart = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [], + hasChart: true) + let withoutChart = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [], + hasChart: false) + #expect(withChart > withoutChart) + #expect(withChart - withoutChart > 130) + } +} diff --git a/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift new file mode 100644 index 0000000000..b345d43faa --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiniMaxUsageSummaryTests { + @Test + func `parses usage summary with cache hit percentages`() throws { + let body = """ + { + "total_days": 77, + "total_token_consumed": "2.37B", + "active_days": 60, + "current_consecutive_days": 31, + "last_update_time": "07-02 17:00", + "daily_token_usage": [201889, 2800317, 4486224], + "date_model_usage": [ + { + "date": "2026-06-29", + "models": [ + { + "model": "MiniMax-M3-512k", + "input_token": 200395, + "cache_read_token": 172249, + "cache_create_token": 0, + "output_token": 1494, + "total_token": 374138, + "cache_hit_percent": "85.95%" + } + ], + "total_input_token": 200395, + "total_cache_read_token": 172249, + "total_cache_create_token": 0, + "total_output_token": 1494, + "total_token": 201889, + "cache_hit_percent": "85.95%" + }, + { + "date": "2026-06-30", + "models": [], + "total_input_token": 2778091, + "total_cache_read_token": 1809827, + "total_cache_create_token": 0, + "total_output_token": 22226, + "total_token": 2800317, + "cache_hit_percent": "65.15%" + }, + { + "date": "2026-07-01", + "models": [], + "total_input_token": 4454050, + "total_cache_read_token": 3139433, + "total_cache_create_token": 0, + "total_output_token": 32174, + "total_token": 4486224, + "cache_hit_percent": "70.48%" + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + let summary = try MiniMaxUsageSummaryParser.parse(data: Data(body.utf8)) + + #expect(summary.totalTokenConsumed == "2.37B") + #expect(summary.activeDays == 60) + #expect(summary.currentConsecutiveDays == 31) + #expect(summary.lastUpdateTime == "07-02 17:00") + #expect(summary.days.count == 3) + #expect(summary.days[0].cacheHitPercent == 85.95) + #expect(summary.days[0].models[0].cacheHitPercent == 85.95) + #expect(summary.last30DaysTokens == 7_488_430) + #expect(summary.last7DaysTokens == 7_488_430) + #expect(summary.latestSnapshotTokens == 4_486_224) + #expect(summary.hasDisplayableData) + #expect(summary.latestActiveDay?.date == "2026-07-01") + } + + @Test + func `snapshot tokens prefer update day over previous full day`() { + let summary = MiniMaxUsageSummary( + totalDays: 2, + totalTokenConsumed: "1.0M", + usageRankingPercent: nil, + activeDays: 2, + currentConsecutiveDays: 2, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [4_486_224, 88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-01", + totalInputToken: 4_000_000, + totalCacheReadToken: 3_000_000, + totalCacheCreateToken: 0, + totalOutputToken: 32000, + totalToken: 4_486_224, + cacheHitPercent: 70.48, + models: []), + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: []), + ]) + + #expect(summary.latestSnapshotTokens == 88000) + #expect(summary.snapshotDay?.date == "2026-07-02") + } + + @Test + func `synthesizes trend days from daily token usage when model usage is missing`() { + let summary = MiniMaxUsageSummary( + totalDays: 3, + totalTokenConsumed: "1.0M", + usageRankingPercent: nil, + activeDays: 3, + currentConsecutiveDays: 3, + lastUpdateTime: "07-02 18:00", + dailyTokenUsage: [100, 200, 300], + days: []) + + #expect(summary.hasDisplayableData) + let trend = summary.trendDays(last: 3) + #expect(trend.map(\.date) == ["2026-06-30", "2026-07-01", "2026-07-02"]) + #expect(trend.map(\.totalToken) == [100, 200, 300]) + #expect(summary.last7DaysTokens == 600) + #expect(summary.latestSnapshotTokens == 300) + } + + @Test + func `login only usage summary maps to invalid credentials`() { + let body = """ + { + "daily_token_usage": [], + "date_model_usage": [], + "base_resp": { "status_code": 1004, "status_msg": "not login" } + } + """ + #expect(throws: MiniMaxUsageError.invalidCredentials) { + try MiniMaxUsageSummaryParser.parse(data: Data(body.utf8)) + } + } + + @Test + func `daily only summary anchors year from reference date near new year`() throws { + var calendar = Calendar.current + calendar.timeZone = TimeZone.current + let reference = try #require(calendar.date(from: DateComponents(year: 2026, month: 1, day: 1, hour: 12))) + let key = MiniMaxUsageSummary.dateKey(fromUpdateTime: "12-31 18:00", referenceDate: reference) + #expect(key == "2025-12-31") + } +} diff --git a/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift new file mode 100644 index 0000000000..2312bcad3c --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +struct MiniMaxWebEnrichmentResolverTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `manual cookie candidate is available without browser import gate`() { + let settings = ProviderSettingsSnapshot.make( + minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: .manual, + manualCookieHeader: "_token=manual-session", + apiRegion: .chinaMainland)) + let context = self.makeContext(runtime: .cli, settings: settings) + + let candidates = MiniMaxWebEnrichmentResolver.candidates(context: context) + + #expect( + candidates.contains { + $0.sourceLabel == "settings" && $0.override.cookieHeader.contains("_token=manual-session") + }) + } + + @Test + func `explicit candidates ignore saved manual cookie when source is auto`() { + let settings = ProviderSettingsSnapshot.make( + minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: .auto, + manualCookieHeader: "_token=stale-manual", + apiRegion: .global)) + let context = self.makeContext(runtime: .cli, settings: settings) + + let explicit = MiniMaxWebEnrichmentResolver.explicitCandidates(context: context) + + #expect(!explicit.contains { $0.sourceLabel == "settings" }) + } + + @Test + func `api enrichment includes desktop agent session before cached browser cookies`() throws { + CookieHeaderCache.store( + provider: .minimax, + cookieHeader: "_token=cached-session", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .minimax) } + + let settings = ProviderSettingsSnapshot.make( + minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil, + apiRegion: .global)) + let context = self.makeContext(runtime: .app, settings: settings) + let session = try MiniMaxCookieImporter.SessionInfo( + cookies: [#require(HTTPCookie(properties: [ + .domain: "www.minimaxi.com", + .name: "_token", + .path: "/", + .value: "desktop-token-value", + .secure: "TRUE", + ]))], + sourceLabel: "MiniMax Agent") + + let api = MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates( + context: context, + desktopSession: session) + + #expect(api.contains { $0.sourceLabel == "MiniMax Agent" }) + if let agentIndex = api.firstIndex(where: { $0.sourceLabel == "MiniMax Agent" }), + let chromeIndex = api.firstIndex(where: { $0.sourceLabel == "Chrome" }) + { + #expect(agentIndex < chromeIndex) + } + } + + @Test + func `desktop agent candidates are exposed for focused tests`() throws { + let context = self.makeContext(runtime: .app) + let session = try MiniMaxCookieImporter.SessionInfo( + cookies: [#require(HTTPCookie(properties: [ + .domain: "platform.minimaxi.com", + .name: "_token", + .path: "/", + .value: "agent-session", + .secure: "TRUE", + ]))], + sourceLabel: "MiniMax Agent") + + let candidates = MiniMaxWebEnrichmentResolver.desktopAgentCandidates( + context: context, + session: session) + + #expect(candidates.count == 1) + #expect(candidates[0].sourceLabel == "MiniMax Agent") + #expect(candidates[0].shouldCache) + #expect(candidates[0].override.cookieHeader.contains("_token=agent-session")) + } + + @Test + func `browser import gate only allows user initiated app refresh`() { + let context = self.makeContext(runtime: .app, includeOptionalUsage: true) + + ProviderInteractionContext.$current.withValue(.background) { + #expect(!MiniMaxWebEnrichmentResolver.allowsBrowserCookieImport(context: context)) + } + + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(MiniMaxWebEnrichmentResolver.allowsBrowserCookieImport(context: context)) + } + } + + @Test + func `explicit candidates exclude cached browser cookies`() { + CookieHeaderCache.store( + provider: .minimax, + cookieHeader: "_token=cached-session", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .minimax) } + + let context = self.makeContext(runtime: .app) + let explicit = MiniMaxWebEnrichmentResolver.explicitCandidates(context: context) + let api = MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates(context: context) + + #expect(!explicit.contains { $0.sourceLabel == "Chrome" }) + #expect(api.contains { $0.isCached && $0.sourceLabel == "Chrome" }) + } + + private func makeContext( + runtime: ProviderRuntime, + includeOptionalUsage: Bool = true, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: runtime, + sourceMode: .auto, + includeCredits: false, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} +#endif diff --git a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift index 093ee163ec..84dafb68c9 100644 --- a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -430,6 +430,26 @@ struct ProviderDiagnosticExportTests { #expect(details.remainingPrompts == 750) #expect(details.windowMinutes == 300) #expect(details.usedPercent == 25) + #expect(details.pointsBalance == nil) + } + + @Test + func `MiniMax details include recharge credit balance`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now, + services: nil, + pointsBalance: 20000) + + let details = MiniMaxDiagnosticDetails(from: snapshot) + #expect(details.pointsBalance == 20000) } @Test diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift index 9a4afe5b7b..f1f47d9456 100644 --- a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -1509,20 +1509,6 @@ extension StatusMenuTests { accountOrganization: nil, loginMethod: "ChatGPT")) } - - private func enableProvidersForInstantOpenTesting( - _ enabledProviders: Set, - settings: SettingsStore) - { - let registry = ProviderRegistry.shared - for provider in UsageProvider.allCases { - guard let metadata = registry.metadata[provider] else { continue } - settings.setProviderEnabled( - provider: provider, - metadata: metadata, - enabled: enabledProviders.contains(provider)) - } - } } private struct InstantOpenProviderFetchStrategy: ProviderFetchStrategy { diff --git a/Tests/CodexBarTests/StatusMenuTestSupport.swift b/Tests/CodexBarTests/StatusMenuTestSupport.swift new file mode 100644 index 0000000000..cefd6aebb2 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuTestSupport.swift @@ -0,0 +1,18 @@ +import CodexBarCore +@testable import CodexBar + +extension StatusMenuTests { + func enableProvidersForInstantOpenTesting( + _ enabledProviders: Set, + settings: SettingsStore) + { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) + } + } +} diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index e3d82a9772..e24066d8c5 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -54,12 +54,6 @@ struct UsageFormatterTests { #expect(UsageFormatter.percentString(1) == "1%") #expect(UsageFormatter.percentString(101) == "100%") #expect(UsageFormatter.usageLine(remaining: 99.9, used: 0.1, showUsed: true) == "<1% used") - // Values in (0.5, 1) round up to "1%" under %.0f, so the old post-format - // "0%" -> "<1%" replacement missed them. percentText must show "<1%" - // across the whole sub-1% range, matching percentString above. - #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "<1% used") - #expect(UsageFormatter.usageLine(remaining: 99.25, used: 0.75, showUsed: true) == "<1% used") - #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "<1% left") let usedWindow = RateWindow(usedPercent: 0.1, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let leftWindow = RateWindow(usedPercent: 99.9, windowMinutes: nil, resetsAt: nil, resetDescription: nil) @@ -72,7 +66,6 @@ struct UsageFormatterTests { UsageFormatter.setLocalizationProvider { key in switch key { case "%.0f%% %@": "%2$@ %1$.0f%%" - case "<1%% %@": "%1$@ <1%%" case "usage_percent_suffix_left": "剩余" case "usage_percent_suffix_used": "已使用" default: key @@ -82,8 +75,6 @@ struct UsageFormatterTests { #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "剩余 22%") #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "已使用 78%") - #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "剩余 <1%") - #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "已使用 <1%") } @Test @@ -325,6 +316,21 @@ struct UsageFormatterTests { #expect(UsageFormatter.tokenCountString(-42) == "-42") } + @Test + func `token count string supports fixed two decimal abbreviations`() { + #expect(UsageFormatter.tokenCountString(88000, fractionDigits: 2) == "88.00K") + #expect(UsageFormatter.tokenCountString(2500, fractionDigits: 2) == "2.50K") + #expect(UsageFormatter.tokenCountString(7_576_430, fractionDigits: 2) == "7.58M") + #expect(UsageFormatter.tokenCountString(1_100_000_000, fractionDigits: 2) == "1.10B") + } + + @Test + func `optional percent string keeps two decimal places`() { + #expect(UsageFormatter.optionalPercentString(74.6) == "74.60%") + #expect(UsageFormatter.optionalPercentString(3.8) == "3.80%") + #expect(UsageFormatter.optionalPercentString(nil) == "—") + } + @Test func `clean plan maps O auth to ollama`() { #expect(UsageFormatter.cleanPlanName("oauth") == "Ollama") diff --git a/docs/minimax.md b/docs/minimax.md index 82ae1d1108..1176bc4723 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -21,14 +21,22 @@ falls back across the provider's supported web requests when needed. - Auto mode can fall back to the web/cookie path when API-token credentials are rejected or the global endpoint returns 404. -2) **Cached/imported browser session** (automatic web path) +2) **MiniMax Agent desktop session** (automatic web-session path when installed) + - Reads the logged-in MiniMax Agent / MiniMax Code desktop cookie store from + `~/Library/Application Support/MiniMax/Cookies`. + - Does not require Chrome Keychain access; used first on web-session refreshes when present. + - Also used for API-token optional enrichment when no explicit manual/env cookie is available. + +3) **Cached/imported browser session** (automatic web path) - Uses CodexBar's standard cookie cache and browser import flow. -3) **Browser cookie import** (automatic) +4) **Browser cookie import** (automatic) - Uses provider metadata for browser order and MiniMax domain filters. - Chromium browser storage can supplement imported cookies with access-token context when available. + - Chrome decryption may require a one-time Keychain approval. Use **⌘R** on the MiniMax menu card to + trigger a user-initiated refresh when automatic background import is suppressed. -4) **Manual session cookie header** (optional web-path override) +5) **Manual session cookie header** (optional web-path override) - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual). - Accepts a raw `Cookie:` header or a full "Copy as cURL" string. - Low-level no-settings runtime can read `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. @@ -39,17 +47,30 @@ falls back across the provider's supported web requests when needed. - `MINIMAX_HOST=platform.minimaxi.com` - `MINIMAX_CODING_PLAN_URL=...` (full URL override) - `MINIMAX_REMAINS_URL=...` (full URL override) + - `MINIMAX_TOKEN_PLAN_CREDIT_URL=...` (full URL override for recharge-credit balance) +- `MINIMAX_HOST` also selects the matching `www.*` credit and usage-summary hosts for MiniMax-owned domains (`minimaxi.com` → `www.minimaxi.com`, `minimax.io` → `www.minimax.io`). Custom proxy hosts (for example `proxy.example.test:8443`) route coding-plan, remains, billing-history, and usage-summary requests through the override while keeping credit/summary on the configured host path. - Security policy: endpoint overrides are only accepted when they use `https://`, omit userinfo, and do not contain encoded host delimiters. Custom HTTPS proxy/test domains continue to work for compatibility, but `http://` endpoints are rejected so cookies and authorization headers are not sent in cleartext. - Strict provider-host mode: set `MINIMAX_REQUIRE_PROVIDER_ENDPOINT_OVERRIDES=true` to additionally reject custom proxy/test domains and only accept MiniMax-owned hosts under `minimax.io` or `minimaxi.com`. ## Cookie capture (optional override) -- Open the Coding Plan page and DevTools → Network. +- Preferred automatic paths: + - stay logged into **MiniMax Agent / MiniMax Code**, or + - stay logged into `platform.minimaxi.com` / `www.minimaxi.com` in Chrome, then press **⌘R** once and approve the Keychain prompt if macOS asks for Chrome safe-storage access. +- Manual fallback: + - Open the Coding 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. +- Token Plan recharge credits (积分余额) are fetched from `GET https://www.minimaxi.com/backend/account/token_plan_credit` (or the global `www.minimax.io` host) using a web session cookie. API-token remains responses do not include this balance. +- **Web-session refreshes** merge recharge credits and usage-summary enrichment through `MiniMaxWebEnrichmentResolver.candidates`, trying MiniMax Agent cookies first, then cached/browser/manual candidates. Successful browser profiles are cached for later background refreshes once Chrome Keychain access is already authorized. +- **API-token refreshes** attach optional recharge credits and usage-summary enrichment from explicit cookies (manual settings cookie header or `MINIMAX_COOKIE` / `MINIMAX_COOKIE_HEADER`), **MiniMax Agent desktop cookies**, a previously validated browser-session cache, or a user-initiated browser re-import. The API path calls `MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates` and does not attach background browser imports, so a different account's live browser session cannot be merged onto an API-key quota snapshot without user action. +- Console usage summary (`GET .../backend/account/token_plan/usage_summary`) uses the same cookie source split as recharge credits above. +- Pay-as-you-go cost projections treat `input_token` and `cache_read_token` as separate counters when pricing usage-summary model rows. +- Menu **Usage Dashboard** opens `https://platform.minimax.io/console/usage` or `https://platform.minimaxi.com/console/usage` based on the configured API region. Settings **Open Token Plan** still opens the Coding Plan page. +- 5-hour and weekly reset countdowns prefer plausible `remains_time` values but fall back to `end_time` when the API countdown is far outside the declared window. - Web-session billing history, when available, is mapped into the shared inline usage dashboard: - 30-day token trend. - Top model and top method breakdowns. @@ -59,7 +80,12 @@ If the billing-history endpoint is unavailable but normal Coding Plan quota data quota card and omits the chart instead of treating the whole provider as failed. ## Key files +- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift` +- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` +- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift` +- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift` +- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` @@ -77,6 +103,7 @@ codexbar diagnose --provider minimax --format json --pretty - Structural diagnostic JSON with provider, source/source mode, auth summary, usage summary, fetch attempts, and error categories. - Per-service quota percentages, used values, limits, remaining values, reset metadata, and unlimited state. These are the same non-secret values shown in the menu and help diagnose boosted quota denominators. +- Recharge-credit balance (`pointsBalance`) when a browser session successfully fetched `token_plan_credit`. - All sensitive fields (API tokens, cookies, emails, auth headers) are redacted via `LogRedactor`. - Errors are mapped to safe categories (`network`, `auth`, `api`, `parse`) with user-friendly descriptions. - No raw API responses, raw error messages, tokens, cookies, emails, account IDs, org IDs, or billing history. diff --git a/docs/screenshots/minimax-usage/menu-card-inline-dashboard.png b/docs/screenshots/minimax-usage/menu-card-inline-dashboard.png new file mode 100644 index 0000000000..f221ade876 Binary files /dev/null and b/docs/screenshots/minimax-usage/menu-card-inline-dashboard.png differ diff --git a/docs/screenshots/minimax-usage/menu-card-usage-notes.png b/docs/screenshots/minimax-usage/menu-card-usage-notes.png new file mode 100644 index 0000000000..35ee67e05c Binary files /dev/null and b/docs/screenshots/minimax-usage/menu-card-usage-notes.png differ diff --git a/docs/screenshots/minimax-usage/subscription-utilization.png b/docs/screenshots/minimax-usage/subscription-utilization.png new file mode 100644 index 0000000000..04339a01f3 Binary files /dev/null and b/docs/screenshots/minimax-usage/subscription-utilization.png differ diff --git a/docs/screenshots/minimax-usage/token-usage-details.png b/docs/screenshots/minimax-usage/token-usage-details.png new file mode 100644 index 0000000000..d862f5c984 Binary files /dev/null and b/docs/screenshots/minimax-usage/token-usage-details.png differ