diff --git a/CHANGELOG.md b/CHANGELOG.md index fa54888274..6f51fafef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,8 @@ - z.ai: support saved token-account team usage with account-scoped organization and project metadata. Thanks @zqbake! - CLI: show session pace in text output, expose derived pace data in JSON, and honor the configured weekly work-day baseline. Thanks @kmatsunami! - Claude: add a combined "Session + Weekly" menu bar metric that shows the 5-hour session and weekly lanes together (paced on the weekly lane), matching Codex, and classify lanes by cadence so a weekly-only account is not mislabeled as a session. Thanks @Shengqiang-Zhang! +- MiniMax: fetch Token Plan recharge-credit balance from the console `token_plan_credit` endpoint when a browser session cookie is available. Thanks @Yuxin-Qiao! +- MiniMax: add console usage-summary dashboard (inline KPI grid, cost trend, token-usage submenu, and Subscription Utilization charts for 5h/weekly quota). Thanks @Yuxin-Qiao! ### Changed - Settings: complete redesign as a System Settings-style window — a sidebar lists app panes plus every provider (search, drag reorder, status dots, enable via context menu), panes use native grouped forms, the window keeps one size instead of resizing per tab, and the last selected pane is remembered across launches. @@ -102,6 +104,11 @@ - Claude history: keep OAuth utilization separate across account switches while preserving continuity through token refreshes. - Linux CLI: keep Claude OAuth usage subprocess-free, skip version probes, and let Auto bypass unsupported web sources. Thanks @derekszen! - Usage display: make Usage widgets follow the used-versus-remaining preference already shared by menus and Overview rows (#1738). Thanks @OlegLustenko and @FrancoLan! +- MiniMax: route Token Plan credit lookups to the matching `www.*` host when `MINIMAX_HOST` selects a China or global platform domain. +- MiniMax: route usage-summary enrichment through `MINIMAX_HOST` custom proxies and ignore implausible 5h `remains_time` values when computing reset countdowns. +- MiniMax: open the menu Usage Dashboard action at `platform.*.com/console/usage` instead of the Coding Plan page. +- MiniMax: bill `cache_read_token` separately from `input_token` in usage-summary cost projections. +- MiniMax: keep optional usage-summary enrichment from failing API-token quota refreshes when an auxiliary cookie is stale. - OpenCode Go: keep rolling usage available when the dashboard omits the optional weekly window. Thanks @mohkg1017! - Menu bar: make Show most-used provider rank only providers selected for Overview. Thanks @dstier-git! - Codex: show expiring reset-credit availability even when optional credits and extra usage are hidden, while preserving CLI `--no-credits`. Thanks @simon-ami! diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 6930c074be..7ddf46329f 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -21,6 +21,10 @@ check_package_product_paths() { "${ROOT_DIR}/Scripts/test_package_product_paths.sh" } +check_package_app_paths() { + "${ROOT_DIR}/Scripts/test_package_app_paths.sh" +} + check_package_strip() { "${ROOT_DIR}/Scripts/test_package_strip.sh" } @@ -82,6 +86,7 @@ check_llms_index() { run_portable_checks() { check_codex_parser_hash check_package_product_paths + check_package_app_paths check_package_strip check_package_signing check_release_dsym_paths diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 73f85f0f4b..427a1844d7 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -19,6 +19,58 @@ SIGNING_MODE= resolve_package_signing_mode ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" + +validate_package_app_path() { + local label="$1" + local path="$2" + if [[ -z "$path" ]]; then + echo "ERROR: $label is empty." >&2 + return 1 + fi + case "$path" in + /*) ;; + *) + echo "ERROR: $label must be an absolute path: $path" >&2 + return 1 + ;; + esac + local base_name + base_name=$(basename "$path") + if [[ "$base_name" != *.app ]]; then + echo "ERROR: $label must point to a .app bundle: $path" >&2 + return 1 + fi + if [[ "$base_name" != CodexBar* ]]; then + echo "ERROR: $label basename must start with CodexBar: $path" >&2 + return 1 + fi + local parent_dir resolved root_resolved + parent_dir=$(dirname "$path") + if ! root_resolved=$(cd "$ROOT" && pwd -P); then + echo "ERROR: failed to resolve repo root for $label validation." >&2 + return 1 + fi + if ! resolved=$(cd "$parent_dir" 2>/dev/null && pwd -P); then + mkdir -p "$parent_dir" + if ! resolved=$(cd "$parent_dir" && pwd -P); then + echo "ERROR: $label parent directory is invalid: $parent_dir" >&2 + return 1 + fi + fi + resolved="$resolved/$base_name" + if [[ "$resolved" == "$root_resolved" ]]; then + echo "ERROR: $label must not target the repo root: $path" >&2 + return 1 + fi + case "$resolved" in + "$root_resolved"/*) ;; + *) + echo "ERROR: $label must stay inside the repo root ($root_resolved): $path" >&2 + return 1 + ;; + esac +} + LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') case "$LOWER_CONF" in debug|release) ;; @@ -183,8 +235,29 @@ for ARCH in "${ARCH_LIST[@]}"; do stage_build_products "$ARCH" done -APP_FINAL="$ROOT/CodexBar.app" -APP_STAGE="$ROOT/.build/package/CodexBar.app" +APP_FINAL="${CODEXBAR_PACKAGE_OUTPUT:-$ROOT/CodexBar.app}" +APP_STAGE="${CODEXBAR_PACKAGE_STAGE:-$ROOT/.build/package/CodexBar.app}" +validate_package_app_path "CODEXBAR_PACKAGE_OUTPUT" "$APP_FINAL" +validate_package_app_path "CODEXBAR_PACKAGE_STAGE" "$APP_STAGE" +resolve_package_app_path() { + local path="$1" + local base_name parent_dir resolved + base_name=$(basename "$path") + parent_dir=$(dirname "$path") + if ! resolved=$(cd "$parent_dir" 2>/dev/null && pwd -P); then + mkdir -p "$parent_dir" + resolved=$(cd "$parent_dir" && pwd -P) + fi + printf '%s\n' "$resolved/$base_name" +} +APP_FINAL_RESOLVED=$(resolve_package_app_path "$APP_FINAL") +APP_STAGE_RESOLVED=$(resolve_package_app_path "$APP_STAGE") +if [[ "$APP_FINAL_RESOLVED" == "$APP_STAGE_RESOLVED" ]]; then + echo "ERROR: CODEXBAR_PACKAGE_OUTPUT and CODEXBAR_PACKAGE_STAGE must resolve to different bundles." >&2 + echo " output=$APP_FINAL_RESOLVED stage=$APP_STAGE_RESOLVED" >&2 + exit 1 +fi +mkdir -p "$(dirname "$APP_FINAL")" "$(dirname "$APP_STAGE")" rm -rf "$APP_STAGE" APP="$APP_STAGE" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" @@ -453,6 +526,9 @@ chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework" install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar" # Re-sign Sparkle and all nested components with the selected package identity. SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" +# Finder metadata and quarantine attributes copied from downloaded artifacts make +# codesign reject nested Sparkle apps even though their file contents are valid. +xattr -cr "$SPARKLE" if [[ "$SIGNING_MODE" == "adhoc" ]]; then CODESIGN_ID="-" CODESIGN_ARGS=(--force --sign "$CODESIGN_ID") @@ -526,6 +602,10 @@ if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then fi # Finally sign the app bundle itself +# Signing nested bundles can reattach provenance metadata on some local volumes. +# Extended attributes are not part of the nested code signatures, so strip them +# once more before sealing the outer app. +xattr -cr "$APP" codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$APP_ENTITLEMENTS" \ "$APP" diff --git a/Scripts/test_package_app_paths.sh b/Scripts/test_package_app_paths.sh new file mode 100755 index 0000000000..afae13c146 --- /dev/null +++ b/Scripts/test_package_app_paths.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) + +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-app-paths.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT + +ROOT_RESOLVED=$(cd "$ROOT" && pwd -P) + +validate_package_app_path() { + local label="$1" + local path="$2" + if [[ -z "$path" ]]; then + echo "ERROR: $label is empty." >&2 + return 1 + fi + case "$path" in + /*) ;; + *) + echo "ERROR: $label must be an absolute path: $path" >&2 + return 1 + ;; + esac + local base_name + base_name=$(basename "$path") + if [[ "$base_name" != *.app ]]; then + echo "ERROR: $label must point to a .app bundle: $path" >&2 + return 1 + fi + if [[ "$base_name" != CodexBar* ]]; then + echo "ERROR: $label basename must start with CodexBar: $path" >&2 + return 1 + fi + local parent_dir resolved + parent_dir=$(dirname "$path") + if ! resolved=$(cd "$parent_dir" 2>/dev/null && pwd -P); then + mkdir -p "$parent_dir" + resolved=$(cd "$parent_dir" && pwd -P) + fi + resolved="$resolved/$base_name" + if [[ "$resolved" == "$ROOT_RESOLVED" ]]; then + echo "ERROR: $label must not target the repo root: $path" >&2 + return 1 + fi + case "$resolved" in + "$ROOT_RESOLVED"/*) ;; + *) + echo "ERROR: $label must stay inside the repo root ($ROOT_RESOLVED): $path" >&2 + return 1 + ;; + esac +} + +passes=0 +assert_rejects() { + local label="$1" + local path="$2" + if validate_package_app_path "$label" "$path" 2>"$TEMP_DIR/reject.log"; then + echo "ERROR: expected rejection for $label=$path" >&2 + exit 1 + fi + passes=$((passes + 1)) +} + +assert_accepts() { + local label="$1" + local path="$2" + validate_package_app_path "$label" "$path" + passes=$((passes + 1)) +} + +assert_accepts "default output" "$ROOT/CodexBar.app" +assert_accepts "default stage" "$ROOT/.build/package/CodexBar.app" +assert_accepts "custom stage" "$ROOT/.build/package/CodexBar.debug.app" + +assert_rejects "empty path" "" +assert_rejects "relative path" "CodexBar.app" +assert_rejects "repo root" "$ROOT" +assert_rejects "home directory" "$HOME" +assert_rejects "outside repo" "/tmp/CodexBar.app" +assert_rejects "non app bundle" "$ROOT/CodexBar.zip" +assert_rejects "wrong basename" "$ROOT/OtherApp.app" + +same_stage="$ROOT/.build/package/CodexBar.app" +assert_accepts "shared stage path" "$same_stage" +if validate_package_app_path "CODEXBAR_PACKAGE_OUTPUT" "$same_stage" 2>"$TEMP_DIR/same-path.log" \ + && validate_package_app_path "CODEXBAR_PACKAGE_STAGE" "$same_stage" 2>>"$TEMP_DIR/same-path.log" +then + ROOT_RESOLVED=$(cd "$ROOT" && pwd -P) + resolve_path() { + local path="$1" + local base_name parent_dir resolved + base_name=$(basename "$path") + parent_dir=$(dirname "$path") + resolved=$(cd "$parent_dir" && pwd -P) + printf '%s\n' "$resolved/$base_name" + } + final_resolved=$(resolve_path "$same_stage") + stage_resolved=$(resolve_path "$same_stage") + if [[ "$final_resolved" == "$stage_resolved" ]]; then + passes=$((passes + 1)) + else + echo "ERROR: expected identical resolved package paths for rejection test" >&2 + exit 1 + fi +else + echo "ERROR: expected package path validation to accept identical stage path inputs" >&2 + exit 1 +fi + +echo "Package app path tests passed ($passes checks)." 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/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 51d19b4e3b..2b189f8b97 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -227,6 +227,19 @@ enum MenuBarMetricWindowResolver { return windows.max(by: { $0.usedPercent < $1.usedPercent }) } + /// The quota window whose reset is soonest among the primary, secondary, and tertiary lanes. + static func nearestResetWindow(snapshot: UsageSnapshot?, now: Date = Date()) -> RateWindow? { + guard let snapshot else { return nil } + let datedWindows = [snapshot.primary, snapshot.secondary, snapshot.tertiary] + .compactMap(\.self) + .compactMap { window -> (RateWindow, Date)? in + guard let resetsAt = window.resetsAt, resetsAt > now else { return nil } + return (window, resetsAt) + } + guard !datedWindows.isEmpty else { return nil } + return datedWindows.min(by: { $0.1 < $1.1 })?.0 + } + private static func mostConstrainedCursorWindow( total: RateWindow?, auto: RateWindow?, diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index dbddeec183..405b2c0931 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -181,6 +181,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 } @@ -303,11 +305,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/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index b704754af5..7b24d1b582 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -258,6 +258,9 @@ struct PlanUtilizationHistoryChartMenuView: View { { names.insert(.opus) } + case .minimax: + if snapshot.primary != nil { names.insert(.session) } + if snapshot.secondary != nil { names.insert(.weekly) } default: let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) + (snapshot.extraRateWindows?.filter(\.usageKnown).map(\.window) ?? []) 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 dc257068f7..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" = "金鑰剩餘額度"; diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 736520aacf..d34ba14c13 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -241,6 +241,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+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 77fc163f1e..93335cda3d 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -806,9 +806,10 @@ extension StatusItemController { self.store.weeklyPace(provider: provider, window: window, now: now) } case .resetTime: + let resetWindow = self.menuBarResetTimeWindow(for: provider, snapshot: snapshot) return MenuBarDisplayText.displayText( mode: mode, - percentWindow: percentWindow, + percentWindow: resetWindow, showUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, now: now) diff --git a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift index 50820ec0a1..f7c1aa9dec 100644 --- a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift +++ b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift @@ -16,7 +16,7 @@ extension StatusItemController { } let resetDates = self.menuBarCountdownProviders().compactMap { provider in - self.menuBarMetricWindow( + self.menuBarResetTimeWindow( for: provider, snapshot: self.store.snapshot(for: provider))?.resetsAt } 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+MemoryPressure.swift b/Sources/CodexBar/StatusItemController+MemoryPressure.swift index c4224b1091..530084746a 100644 --- a/Sources/CodexBar/StatusItemController+MemoryPressure.swift +++ b/Sources/CodexBar/StatusItemController+MemoryPressure.swift @@ -8,12 +8,13 @@ extension StatusItemController { } let summary = MemoryPressureCacheTrimSummary( menuCardHeights: self.menuCardHeightCache.count, - menuWidths: self.measuredStandardMenuWidthCache.count, + menuWidths: self.measuredStandardMenuWidthCache.count + self.mergedMenuWidthCache.count, mergedSwitcherSelections: mergedSwitcherSelectionCount, recycledMenuCardViews: self.menuCardViewRecyclePool.count) self.menuCardHeightCache.removeAll(keepingCapacity: false) self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: false) + self.mergedMenuWidthCache.removeAll(keepingCapacity: false) self.mergedSwitcherContentCaches.removeAll(keepingCapacity: false) self.menuCardViewRecyclePool.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 7ed98ff77b..f2efb1849c 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -36,7 +36,6 @@ extension StatusItemController { static let costHistoryChartID = "costHistoryChart" static let usageHistoryChartID = "usageHistoryChart" static let storageBreakdownID = "storageBreakdown" - static let statusComponentsID = "statusComponents" func shortcut(for action: MenuDescriptor.MenuAction) -> (key: String, modifiers: NSEvent.ModifierFlags)? { switch action { @@ -149,6 +148,7 @@ extension StatusItemController { menuWasFreshBeforeOpen: menuWasFreshBeforeOpen) } self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + self.scheduleMiniMaxMergedMenuPrewarmIfNeeded(menu) // Only schedule refresh after menu is registered as open - refreshNow is called async self.scheduleOpenMenuRefresh(for: menu) } @@ -223,7 +223,59 @@ extension StatusItemController { breadcrumb: "populateMenu:\(provider?.rawValue ?? "merged")") defer { self.endMenuOperationTrace(trace, menu: menu, provider: provider) } defer { self.refreshMenuCardHeights(in: menu) } + defer { + if self.openMenus[ObjectIdentifier(menu)] != nil { + self.scheduleMiniMaxMergedMenuPrewarmIfNeeded(menu) + } else { + self.prewarmMiniMaxMergedMenuContentIfNeeded(in: menu) + } + } + + let snapshot = self.makeMenuPopulateSnapshot(menu: menu, provider: provider) + let gate = self.mergedMenuSmartUpdateGate(menu: menu, snapshot: snapshot) + + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil { + self.menuLogger.debug( + "populateMenu(open): provider=\(String(describing: provider)) " + + "display=\(snapshot.enabledProviders.map(\.rawValue)) " + + "available=\(self.store.enabledProviders().map(\.rawValue)) " + + "selection=\(String(describing: snapshot.switcherSelection)) " + + "last=\(String(describing: self.lastMergedSwitcherSelection)) " + + "smart=\(gate.canSmartUpdate)") + } + #endif + + if gate.canSmartUpdate { + self.updateMenuContentPreservingSwitcher(menu, context: snapshot.menuUpdateContext) + return + } + + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil { + self.menuLogger.debug( + "populateMenu(open): preserveSwitcher=\(gate.canPreserveProviderSwitcher) " + + "widthMatch=\(gate.providerSwitcherWidthMatches)") + } + #endif + + if gate.canPreserveProviderSwitcher { + self.updateMenuContentPreservingSwitcher(menu, context: snapshot.menuUpdateContext) + return + } + + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil, menu.items.first?.view is ProviderSwitcherView { + self.menuLogger.debug("populateMenu(open): rebuilding whole menu and replacing provider switcher") + } + #endif + self.rebuildMenuContent(menu, context: snapshot.menuRebuildContext) + } + private func makeMenuPopulateSnapshot( + menu: NSMenu, + provider: UsageProvider?) -> MenuPopulateSnapshot + { let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -257,30 +309,46 @@ extension StatusItemController { for: enabledProviders, selectedProvider: selectedProvider, descriptor: descriptor) + return MenuPopulateSnapshot( + enabledProviders: enabledProviders, + includesOverview: includesOverview, + switcherSelection: switcherSelection, + selectedProvider: selectedProvider, + currentProvider: currentProvider, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: openAIContext, + descriptor: descriptor, + menuWidth: menuWidth) + } + private func mergedMenuSmartUpdateGate( + menu: NSMenu, + snapshot: MenuPopulateSnapshot) -> MergedMenuSmartUpdateGate + { let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } - let switcherProvidersMatch = enabledProviders == self.lastSwitcherProviders + let switcherProvidersMatch = snapshot.enabledProviders == self.lastSwitcherProviders let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed - let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection - let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview + let switcherSelectionMatches = snapshot.switcherSelection == self.lastMergedSwitcherSelection + let switcherOverviewAvailabilityMatches = snapshot.includesOverview == self.lastSwitcherIncludesOverview let menuLocalizationMatches = self.menuLocalizationSignature() == self.lastMenuLocalizationSignature - let tokenSwitcherCompatible = tokenAccountDisplay == self.lastTokenAccountMenuDisplay && - ((tokenAccountDisplay?.showSwitcher == true && hasTokenSwitcher) || - (tokenAccountDisplay?.showSwitcher != true && !hasTokenSwitcher)) - let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && - ((codexAccountDisplay?.showSwitcher == true && hasCodexSwitcher) || - (codexAccountDisplay?.showSwitcher != true && !hasCodexSwitcher)) + let tokenSwitcherCompatible = snapshot.tokenAccountDisplay == self.lastTokenAccountMenuDisplay && + ((snapshot.tokenAccountDisplay?.showSwitcher == true && hasTokenSwitcher) || + (snapshot.tokenAccountDisplay?.showSwitcher != true && !hasTokenSwitcher)) + let codexSwitcherCompatible = snapshot.codexAccountDisplay == self.lastCodexAccountMenuDisplay && + ((snapshot.codexAccountDisplay?.showSwitcher == true && hasCodexSwitcher) || + (snapshot.codexAccountDisplay?.showSwitcher != true && !hasCodexSwitcher)) let reusableRowWidthsMatch = self.reusableFixedWidthRows(in: menu).allSatisfy { item in guard let view = item.view else { return false } - return abs(view.frame.width - menuWidth) <= 0.5 + return abs(view.frame.width - snapshot.menuWidth) <= 0.5 } let providerSwitcherWidthMatches = (menu.items.first?.view as? ProviderSwitcherView).map { view in - abs(view.frame.width - menuWidth) <= 0.5 + abs(view.frame.width - snapshot.menuWidth) <= 0.5 } ?? false let canSmartUpdate = self.shouldMergeIcons && - enabledProviders.count > 1 && - !isOverviewSelected && + snapshot.enabledProviders.count > 1 && + !snapshot.isOverviewSelected && switcherProvidersMatch && switcherUsageBarsShowUsedMatch && switcherSelectionMatches && @@ -291,36 +359,8 @@ extension StatusItemController { reusableRowWidthsMatch && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView - - #if DEBUG - if self.openMenus[ObjectIdentifier(menu)] != nil { - self.menuLogger.debug( - "populateMenu(open): provider=\(String(describing: provider)) " + - "display=\(enabledProviders.map(\.rawValue)) " + - "available=\(self.store.enabledProviders().map(\.rawValue)) " + - "selection=\(String(describing: switcherSelection)) " + - "last=\(String(describing: self.lastMergedSwitcherSelection)) " + - "smart=\(canSmartUpdate)") - } - #endif - - if canSmartUpdate { - self.updateMenuContentPreservingSwitcher( - menu, - context: MenuUpdateContext( - provider: selectedProvider, - currentProvider: currentProvider, - switcherSelection: switcherSelection ?? .provider(currentProvider), - menuWidth: menuWidth, - codexAccountDisplay: codexAccountDisplay, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext, - descriptor: descriptor)) - return - } - let canPreserveProviderSwitcher = self.shouldMergeIcons && - enabledProviders.count > 1 && + snapshot.enabledProviders.count > 1 && switcherProvidersMatch && switcherUsageBarsShowUsedMatch && switcherOverviewAvailabilityMatches && @@ -328,48 +368,10 @@ extension StatusItemController { providerSwitcherWidthMatches && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView - - #if DEBUG - if self.openMenus[ObjectIdentifier(menu)] != nil { - self.menuLogger.debug( - "populateMenu(open): preserveSwitcher=\(canPreserveProviderSwitcher) " + - "widthMatch=\(providerSwitcherWidthMatches)") - } - #endif - - if canPreserveProviderSwitcher { - self.updateMenuContentPreservingSwitcher( - menu, - context: MenuUpdateContext( - provider: selectedProvider, - currentProvider: currentProvider, - switcherSelection: switcherSelection ?? .provider(currentProvider), - menuWidth: menuWidth, - codexAccountDisplay: codexAccountDisplay, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext, - descriptor: descriptor)) - return - } - - #if DEBUG - if self.openMenus[ObjectIdentifier(menu)] != nil, menu.items.first?.view is ProviderSwitcherView { - self.menuLogger.debug("populateMenu(open): rebuilding whole menu and replacing provider switcher") - } - #endif - self.rebuildMenuContent( - menu, - context: MenuRebuildContext( - enabledProviders: enabledProviders, - includesOverview: includesOverview, - switcherSelection: switcherSelection, - currentProvider: currentProvider, - selectedProvider: selectedProvider, - menuWidth: menuWidth, - codexAccountDisplay: codexAccountDisplay, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext, - descriptor: descriptor)) + return MergedMenuSmartUpdateGate( + canSmartUpdate: canSmartUpdate, + canPreserveProviderSwitcher: canPreserveProviderSwitcher, + providerSwitcherWidthMatches: providerSwitcherWidthMatches) } private func reusableFixedWidthRows(in menu: NSMenu) -> [NSMenuItem] { @@ -460,7 +462,7 @@ extension StatusItemController { } } - private func openAIWebContext( + func openAIWebContext( currentProvider: UsageProvider, showAllAccounts: Bool) -> OpenAIWebContext { @@ -469,7 +471,8 @@ extension StatusItemController { surface: .liveCard) let hasCreditsHistory = codexProjection?.hasCreditsHistory == true let hasUsageBreakdown = codexProjection?.hasUsageBreakdown == true - let hasCostHistory = self.settings.costSummaryShowsSubmenu(for: currentProvider) && + let hasCostHistory = currentProvider != .minimax && + self.settings.costSummaryShowsSubmenu(for: currentProvider) && (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) let canShowBuyCredits = self.settings.showOptionalCreditsAndExtraUsage && codexProjection?.canShowBuyCredits == true @@ -772,6 +775,7 @@ extension StatusItemController { context: context.openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) self.addUsageHistoryClusterIfNeeded(to: menu, context: context) + self.addMiniMaxUsageSummarySectionIfNeeded(to: menu, context: context) if self.addZaiHourlyUsageMenuItemIfNeeded( to: menu, provider: context.currentProvider, @@ -1457,7 +1461,9 @@ extension StatusItemController { if provider == .openai { return self.makeOpenAIAPIUsageSubmenu(provider: provider, width: width) } - if UsageStore.tokenCostRequiresProviderSnapshot(provider) { + if UsageStore.tokenCostRequiresProviderSnapshot(provider), + provider != .minimax + { return self.makeCostHistorySubmenu(provider: provider, width: width) } if provider == .zai { @@ -1466,45 +1472,6 @@ extension StatusItemController { return nil } - func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { - guard let timeLimit = snapshot?.zaiUsage?.timeLimit else { return nil } - guard !timeLimit.usageDetails.isEmpty else { return nil } - - let submenu = NSMenu() - submenu.delegate = self - let titleItem = NSMenuItem(title: L("MCP details"), action: nil, keyEquivalent: "") - titleItem.isEnabled = false - submenu.addItem(titleItem) - - if let window = timeLimit.windowLabel { - let item = NSMenuItem(title: String(format: L("mcp_window"), window), action: nil, keyEquivalent: "") - item.isEnabled = false - submenu.addItem(item) - } - if let resetTime = timeLimit.nextResetTime { - let reset = self.settings.resetTimeDisplayStyle == .absolute - ? UsageFormatter.resetDescription(from: resetTime) - : UsageFormatter.resetCountdownDescription(from: resetTime) - let item = NSMenuItem(title: String(format: L("mcp_resets"), reset), action: nil, keyEquivalent: "") - item.isEnabled = false - submenu.addItem(item) - } - submenu.addItem(.separator()) - - let sortedDetails = timeLimit.usageDetails.sorted { - $0.modelCode.localizedCaseInsensitiveCompare($1.modelCode) == .orderedAscending - } - for detail in sortedDetails { - let usage = UsageFormatter.tokenCountString(detail.usage) - let item = NSMenuItem( - title: String(format: L("mcp_model_usage"), detail.modelCode, usage), - action: nil, - keyEquivalent: "") - submenu.addItem(item) - } - return submenu - } - private func makeUsageBreakdownSubmenu(width: CGFloat? = nil) -> NSMenu? { let breakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( from: self.store.openAIDashboard?.usageBreakdown ?? []) @@ -1555,7 +1522,8 @@ extension StatusItemController { } private func hasProviderNativeCostHistorySubmenu(provider: UsageProvider) -> Bool { - UsageStore.tokenCostRequiresProviderSnapshot(provider) && + provider != .minimax && + UsageStore.tokenCostRequiresProviderSnapshot(provider) && self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false } diff --git a/Sources/CodexBar/StatusItemController+MenuContexts.swift b/Sources/CodexBar/StatusItemController+MenuContexts.swift index 1091374281..c1e26c855a 100644 --- a/Sources/CodexBar/StatusItemController+MenuContexts.swift +++ b/Sources/CodexBar/StatusItemController+MenuContexts.swift @@ -31,4 +31,53 @@ extension StatusItemController { let openAIContext: OpenAIWebContext let descriptor: MenuDescriptor } + + struct MenuPopulateSnapshot { + let enabledProviders: [UsageProvider] + let includesOverview: Bool + let switcherSelection: ProviderSwitcherSelection? + let selectedProvider: UsageProvider? + let currentProvider: UsageProvider + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor + let menuWidth: CGFloat + + var isOverviewSelected: Bool { + self.switcherSelection == .overview + } + + var menuUpdateContext: MenuUpdateContext { + MenuUpdateContext( + provider: self.selectedProvider, + currentProvider: self.currentProvider, + switcherSelection: self.switcherSelection ?? .provider(self.currentProvider), + menuWidth: self.menuWidth, + codexAccountDisplay: self.codexAccountDisplay, + tokenAccountDisplay: self.tokenAccountDisplay, + openAIContext: self.openAIContext, + descriptor: self.descriptor) + } + + var menuRebuildContext: MenuRebuildContext { + MenuRebuildContext( + enabledProviders: self.enabledProviders, + includesOverview: self.includesOverview, + switcherSelection: self.switcherSelection, + currentProvider: self.currentProvider, + selectedProvider: self.selectedProvider, + menuWidth: self.menuWidth, + codexAccountDisplay: self.codexAccountDisplay, + tokenAccountDisplay: self.tokenAccountDisplay, + openAIContext: self.openAIContext, + descriptor: self.descriptor) + } + } + + struct MergedMenuSmartUpdateGate { + let canSmartUpdate: Bool + let canPreserveProviderSwitcher: Bool + let providerSwitcherWidthMatches: Bool + } } diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 87954d58e0..9c9009ba4a 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -479,6 +479,18 @@ struct MenuCardSectionContainerView: View { @ViewBuilder let content: () -> Content var body: some View { + Group { + if self.refreshMonitor != nil { + TimelineView(.periodic(from: .now, by: 60)) { _ in + self.cardContent + } + } else { + self.cardContent + } + } + } + + private var cardContent: some View { self.content() .environment(\.menuItemHighlighted, self.highlightState.isHighlighted) .environment(\.menuCardRefreshMonitor, self.refreshMonitor) diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift index 13e6fd34ae..9d58cc9585 100644 --- a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -1,5 +1,6 @@ import AppKit import CodexBarCore +import QuartzCore import SwiftUI extension StatusItemController { @@ -20,6 +21,9 @@ extension StatusItemController { _ menu: NSMenu, context: MenuUpdateContext) { + let updateStartedAt = CACurrentMediaTime() + let requestedAt = self.providerSwitcherRebuildRequestedAt + self.providerSwitcherRebuildRequestedAt = nil self.performMenuMutationWithoutAnimation { let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) if let switcherView = menu.items.first?.view as? ProviderSwitcherView { @@ -45,13 +49,14 @@ extension StatusItemController { let outgoingCodexAccountDisplay = self.lastCodexAccountMenuDisplay let outgoingTokenAccountDisplay = self.lastTokenAccountMenuDisplay self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let replacementStartedAt = CACurrentMediaTime() let displacedItems = self.replaceMenuContentKeepingRowsVisible( menu, fromIndex: contentStartIndex, with: cachedItems) + let replacementEndedAt = CACurrentMediaTime() // Cached items may have changed refresh state while detached from a menu. self.updatePersistentRefreshItemsEnabled() - self.refreshMenuCardHeights(in: menu) self.cacheMergedSwitcherContent( displacedItems, in: menu, @@ -69,6 +74,14 @@ extension StatusItemController { contentStartIndex: contentStartIndex, menuWidth: context.menuWidth, contentVersion: self.menuSession.contentVersion) + self.logProviderSwitcherPerformanceIfSlow(ProviderSwitcherPerformanceSample( + provider: context.provider, + cacheHit: true, + requestedAt: requestedAt, + updateStartedAt: updateStartedAt, + buildDuration: 0, + replacementDuration: replacementEndedAt - replacementStartedAt, + layoutDuration: 0)) return } @@ -85,24 +98,70 @@ extension StatusItemController { preserveHighlightedItem: true) defer { self.clearMenuCardViewRecyclePool() } self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let buildStartedAt = CACurrentMediaTime() let scratch = NSMenu() scratch.autoenablesItems = false self.addSwitcherScopedMenuContent(into: scratch, captureMenu: menu, context: context) + let buildEndedAt = CACurrentMediaTime() + let replacementStartedAt = CACurrentMediaTime() self.reconcileMenuContent(menu, fromIndex: contentStartIndex, shapes: shapes, with: scratch) + let replacementEndedAt = CACurrentMediaTime() + let layoutStartedAt = CACurrentMediaTime() self.refreshMenuCardHeights(in: menu) + let layoutEndedAt = CACurrentMediaTime() self.cacheVisibleMergedSwitcherContent( in: menu, selection: context.switcherSelection, contentStartIndex: contentStartIndex, menuWidth: context.menuWidth, contentVersion: self.menuSession.contentVersion) + self.logProviderSwitcherPerformanceIfSlow(ProviderSwitcherPerformanceSample( + provider: context.provider, + cacheHit: false, + requestedAt: requestedAt, + updateStartedAt: updateStartedAt, + buildDuration: buildEndedAt - buildStartedAt, + replacementDuration: replacementEndedAt - replacementStartedAt, + layoutDuration: layoutEndedAt - layoutStartedAt)) } } + private struct ProviderSwitcherPerformanceSample { + let provider: UsageProvider? + let cacheHit: Bool + let requestedAt: CFTimeInterval? + let updateStartedAt: CFTimeInterval + let buildDuration: CFTimeInterval + let replacementDuration: CFTimeInterval + let layoutDuration: CFTimeInterval + } + + private func logProviderSwitcherPerformanceIfSlow(_ sample: ProviderSwitcherPerformanceSample) { + let endedAt = CACurrentMediaTime() + let totalDuration = sample.requestedAt.map { endedAt - $0 } ?? (endedAt - sample.updateStartedAt) + guard totalDuration >= 0.008 else { return } + self.menuLogger.debug( + "provider switch performance", + metadata: [ + "provider": sample.provider?.rawValue ?? "overview", + "cacheHit": sample.cacheHit ? "1" : "0", + "queueMs": Self.formatMenuPerformanceDuration( + sample.updateStartedAt - (sample.requestedAt ?? sample.updateStartedAt)), + "buildMs": Self.formatMenuPerformanceDuration(sample.buildDuration), + "replaceMs": Self.formatMenuPerformanceDuration(sample.replacementDuration), + "layoutMs": Self.formatMenuPerformanceDuration(sample.layoutDuration), + "totalMs": Self.formatMenuPerformanceDuration(totalDuration), + ]) + } + + private static func formatMenuPerformanceDuration(_ duration: CFTimeInterval) -> String { + String(format: "%.1f", duration * 1000) + } + /// Adds everything below the provider switcher (account switchers, card content, and /// actionable sections) to `target`, which may be a detached scratch menu; interaction /// closures always capture `captureMenu`, the live menu the rows will serve. - private func addSwitcherScopedMenuContent( + func addSwitcherScopedMenuContent( into target: NSMenu, captureMenu: NSMenu, context: MenuUpdateContext) diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index 8f18e5266a..141420edf2 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -9,6 +9,19 @@ extension StatusItemController { selectedProvider: UsageProvider?, descriptor: MenuDescriptor) -> CGFloat { + let mergedCacheKey: String? = if self.shouldMergeIcons, providers.count > 1 { + [ + "version=\(self.menuSession.contentVersion)", + "providers=\(providers.map(\.rawValue).joined(separator: ","))", + "locale=\(self.menuLocalizationSignature())", + ].joined(separator: "|") + } else { + nil + } + if let mergedCacheKey, let cached = self.mergedMenuWidthCache[mergedCacheKey] { + return cached + } + let sectionSets: [[MenuDescriptor.Section]] = if self.shouldMergeIcons, providers.count > 1 { providers.map { provider in if provider == selectedProvider { @@ -21,7 +34,13 @@ extension StatusItemController { } else { [descriptor.sections] } - return self.measuredMenuCardWidth(for: sectionSets) + let width = self.measuredMenuCardWidth(for: sectionSets) + if let mergedCacheKey { + // Only one content version is useful. Keeping stale versions would retain widths + // indefinitely while provider snapshots refresh in the background. + self.mergedMenuWidthCache = [mergedCacheKey: width] + } + return width } func measuredMenuCardWidth(for sectionSets: [[MenuDescriptor.Section]]) -> CGFloat { diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift index f060a53edf..04c29624d3 100644 --- a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -139,4 +139,85 @@ extension StatusItemController { self.updatePersistentRefreshItemsEnabled() return true } + + /// Builds MiniMax's richer card while the user is reading the currently selected provider. + /// The first switch can then use the same detached-item cache as subsequent switch-backs. + func scheduleMiniMaxMergedMenuPrewarmIfNeeded(_ menu: NSMenu) { + guard self.shouldMergeIcons, + self.store.enabledProvidersForDisplay().contains(.minimax), + self.lastMergedSwitcherSelection != .provider(.minimax) + else { return } + + // An open NSMenu runs the main thread in event-tracking mode. A MainActor Task + // continuation can therefore starve until the menu closes, defeating the prewarm. + // Queue directly into that run-loop mode so the detached content is ready before + // the user's first switch to MiniMax. + ProviderSwitcherTrackingRunLoopScheduler.schedule { [weak self, weak menu] in + guard let self, let menu else { return } + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + self.prewarmMiniMaxMergedMenuContent(in: menu) + } + } + + func prewarmMiniMaxMergedMenuContent(in menu: NSMenu) { + let enabledProviders = self.store.enabledProvidersForDisplay() + guard self.shouldMergeIcons, enabledProviders.contains(.minimax) else { return } + + let tokenAccountDisplay = self.tokenAccountMenuDisplay(for: .minimax) + let codexAccountDisplay: CodexAccountMenuDisplay? = nil + let descriptor = self.makeMenuDescriptor(provider: .minimax, includeContextualActions: true) + let menuWidth = self.menuCardWidth( + for: enabledProviders, + selectedProvider: .minimax, + descriptor: descriptor) + if self.reusableMergedSwitcherContent( + for: .provider(.minimax), + in: menu, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay) != nil + { + return + } + + let previousCodexDisplay = self.lastCodexAccountMenuDisplay + let previousTokenDisplay = self.lastTokenAccountMenuDisplay + defer { + self.lastCodexAccountMenuDisplay = previousCodexDisplay + self.lastTokenAccountMenuDisplay = previousTokenDisplay + } + + let scratch = NSMenu() + scratch.autoenablesItems = false + self.addSwitcherScopedMenuContent( + into: scratch, + captureMenu: menu, + context: MenuUpdateContext( + provider: .minimax, + currentProvider: .minimax, + switcherSelection: .provider(.minimax), + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: self.openAIWebContext( + currentProvider: .minimax, + showAllAccounts: tokenAccountDisplay?.showAll ?? false), + descriptor: descriptor)) + let items = scratch.items + scratch.removeAllItems() + self.cacheMergedSwitcherContent( + items, + in: menu, + selection: .provider(.minimax), + context: MergedSwitcherContentCacheContext( + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + contentVersion: self.menuSession.contentVersion)) + } + + func prewarmMiniMaxMergedMenuContentIfNeeded(in menu: NSMenu) { + guard self.lastMergedSwitcherSelection != .provider(.minimax) else { return } + self.prewarmMiniMaxMergedMenuContent(in: menu) + } } diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift new file mode 100644 index 0000000000..7b67e81ea9 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift @@ -0,0 +1,6 @@ +import AppKit + +extension StatusItemController { + static let miniMaxUsageSummaryChartID = "miniMaxUsageSummaryChart" + static let statusComponentsID = "statusComponents" +} 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/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index beadd46c51..c561d4d21a 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -18,6 +18,7 @@ extension StatusItemController { return submenu } if UsageStore.tokenCostRequiresProviderSnapshot(provider), + provider != .minimax, let submenu = self.makeCostHistorySubmenu(provider: provider, width: width) { return submenu diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 24019b4bb0..05d588c6cb 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -1,5 +1,6 @@ import AppKit import CodexBarCore +import QuartzCore struct PendingProviderSwitcherRebuild { let menu: NSMenu @@ -324,6 +325,7 @@ extension StatusItemController { } func requestProviderSwitcherMenuRebuild(_ menu: NSMenu, provider: UsageProvider?) { + self.providerSwitcherRebuildRequestedAt = CACurrentMediaTime() guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) return diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift index 712fb31371..27a8c2b59d 100644 --- a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -32,4 +32,43 @@ extension StatusItemController { menu.addItem(item) return true } + + func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { + guard let timeLimit = snapshot?.zaiUsage?.timeLimit else { return nil } + guard !timeLimit.usageDetails.isEmpty else { return nil } + + let submenu = NSMenu() + submenu.delegate = self + let titleItem = NSMenuItem(title: L("MCP details"), action: nil, keyEquivalent: "") + titleItem.isEnabled = false + submenu.addItem(titleItem) + + if let window = timeLimit.windowLabel { + let item = NSMenuItem(title: String(format: L("mcp_window"), window), action: nil, keyEquivalent: "") + item.isEnabled = false + submenu.addItem(item) + } + if let resetTime = timeLimit.nextResetTime { + let reset = self.settings.resetTimeDisplayStyle == .absolute + ? UsageFormatter.resetDescription(from: resetTime) + : UsageFormatter.resetCountdownDescription(from: resetTime) + let item = NSMenuItem(title: String(format: L("mcp_resets"), reset), action: nil, keyEquivalent: "") + item.isEnabled = false + submenu.addItem(item) + } + submenu.addItem(.separator()) + + let sortedDetails = timeLimit.usageDetails.sorted { + $0.modelCode.localizedCaseInsensitiveCompare($1.modelCode) == .orderedAscending + } + for detail in sortedDetails { + let usage = UsageFormatter.tokenCountString(detail.usage) + let item = NSMenuItem( + title: String(format: L("mcp_model_usage"), detail.modelCode, usage), + action: nil, + keyEquivalent: "") + submenu.addItem(item) + } + return submenu + } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 4d6c033d53..bb317a086f 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -34,6 +34,7 @@ extension StatusItemControlling { } @MainActor +// swiftlint:disable:next type_body_length final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. static var menuCardRenderingEnabled = !SettingsStore.isRunningTests @@ -130,6 +131,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let persistentRefreshItems = NSHashTable.weakObjects() var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] var measuredStandardMenuWidthCache: [String: CGFloat] = [:] + var mergedMenuWidthCache: [String: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" var lastMenuAdjunctReadinessBaselineVersion = 0 var rootOpenHandledMenuObservationSignature: String? @@ -160,6 +162,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var providerSwitcherShortcutMenuID: ObjectIdentifier? var providerSwitcherPointerInteractionMenuID: ObjectIdentifier? var pendingProviderSwitcherPointerRebuild: PendingProviderSwitcherRebuild? + var providerSwitcherRebuildRequestedAt: CFTimeInterval? var overviewScrollAccumulatedDelta: CGFloat = 0 var overviewScrollNavigationHandlerForTesting: ((OverviewScrollStep) -> Void)? var hasPreparedForAppShutdown = false @@ -333,6 +336,18 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) } + func menuBarResetTimeWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { + if provider == .minimax { + let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) + if preference == .automatic { + return MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot) + ?? self.menuBarMetricWindow(for: provider, snapshot: snapshot) + } + return self.menuBarMetricWindow(for: provider, snapshot: snapshot) + } + return self.menuBarMetricWindow(for: provider, snapshot: snapshot) + } + private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?) -> RateWindow? { guard let snapshot else { return nil } let projection = CodexConsumerProjection.make( diff --git a/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift b/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift new file mode 100644 index 0000000000..0716946f8f --- /dev/null +++ b/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift @@ -0,0 +1,57 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + func appendMiniMaxPlanUtilizationSamples( + snapshot: UsageSnapshot, + appendWindow: (_ window: RateWindow?, _ name: PlanUtilizationSeriesName?) -> Void) + { + let services = snapshot.minimaxUsage?.services? + .filter(\.isPrimaryTextQuotaLane) ?? [] + if services.isEmpty { + appendWindow(snapshot.primary, .session) + appendWindow(snapshot.secondary, .weekly) + return + } + + if let weeklyService = services.first(where: { + $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" + }) { + appendWindow(self.miniMaxRateWindow(for: weeklyService), .weekly) + } + + let sessionService = services + .filter { + let normalized = $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized != "weekly" && normalized != "today" && normalized != "今日" + } + .min { + self.miniMaxWindowMinutes(for: $0) < self.miniMaxWindowMinutes(for: $1) + } + if let sessionService { + appendWindow(self.miniMaxRateWindow(for: sessionService), .session) + } + } + + private func miniMaxRateWindow(for service: MiniMaxServiceUsage) -> RateWindow { + RateWindow( + usedPercent: max(0, min(100, service.percent)), + windowMinutes: self.miniMaxWindowMinutes(for: service), + resetsAt: service.resetsAt, + resetDescription: service.resetDescription) + } + + private func miniMaxWindowMinutes(for service: MiniMaxServiceUsage) -> Int { + let windowType = service.windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + if windowType == "today" || windowType == "今日" { + return 24 * 60 + } + if windowType == "weekly" { + return 7 * 24 * 60 + } + if let parsed = MiniMaxServiceUsage.parseWindowType(service.windowType).windowMinutes { + return parsed + } + return 300 + } +} diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index b7d400e9cd..90cab19c2d 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -1,6 +1,8 @@ import CodexBarCore import Foundation +// swiftlint:disable file_length + extension UsageStore { private nonisolated static let limitResetThreshold = 1.0 nonisolated static let sessionLimitResetDetectorDefaultsKey = "sessionLimitResetDetectorStates" @@ -42,6 +44,9 @@ extension UsageStore { switch provider { case .codex, .claude: true + case .minimax: + self.planUtilizationHistory[.minimax]?.isEmpty == false + || self.miniMaxHasRecordablePlanUtilizationSamples() default: if self.planUtilizationHistory[provider]?.isEmpty == false { true @@ -224,7 +229,7 @@ extension UsageStore { } guard !samples.isEmpty else { return } - guard self.shouldRecordPlanUtilizationHistory(for: provider) else { return } + guard self.shouldRecordPlanUtilizationHistory(for: provider, snapshot: snapshot) else { return } guard !self.shouldDeferClaudePlanUtilizationHistory(provider: provider) else { return } var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? @@ -261,10 +266,18 @@ extension UsageStore { await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } - private func shouldRecordPlanUtilizationHistory(for provider: UsageProvider) -> Bool { + private func shouldRecordPlanUtilizationHistory( + for provider: UsageProvider, + snapshot: UsageSnapshot) -> Bool + { switch provider { case .codex, .claude: true + case .minimax: + !self.planUtilizationSeriesSamples( + provider: .minimax, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty default: self.settings.historicalTrackingEnabled } @@ -533,6 +546,14 @@ extension UsageStore { } } + private func miniMaxHasRecordablePlanUtilizationSamples() -> Bool { + guard let snapshot = self.snapshots[.minimax] else { return false } + return !self.planUtilizationSeriesSamples( + provider: .minimax, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty + } + private func planUtilizationSeriesSamples( provider: UsageProvider, snapshot: UsageSnapshot, @@ -574,6 +595,10 @@ extension UsageStore { appendWindow(snapshot.primary, name: .session) appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .opus) + case .minimax: + self.appendMiniMaxPlanUtilizationSamples( + snapshot: snapshot, + appendWindow: appendWindow) case .antigravity: let namedWeeklyWindows = snapshot.extraRateWindows? .filter { diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 44d2e4e646..e1842339bd 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -70,6 +70,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 } @@ -77,7 +80,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/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index 0e10c92762..5507493ba6 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -47,6 +47,14 @@ public enum BrowserCookieAccessGate { public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { guard browser.usesKeychainForCookieDecryption else { return true } guard !KeychainAccessGate.isDisabled else { return false } + + if ProviderInteractionContext.current == .userInitiated { + self.log.debug( + "Cookie access allowed for user-initiated refresh", + metadata: ["browser": browser.displayName]) + return true + } + let shouldCheckKeychain = self.lock.withLock { state in self.loadIfNeeded(&state) if let blockedUntil = state.deniedUntilByBrowser[browser.rawValue] { 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 75699be4ae..d2a0d2ff4b 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -137,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, @@ -244,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) @@ -255,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 ? "-" : "" @@ -267,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) @@ -398,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/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 46be2cca91..d0deecad18 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -221,7 +221,7 @@ struct BrowserDetectionTests { var preflightCount = 0 KeychainAccessGate.withTaskOverrideForTesting(false) { - ProviderInteractionContext.$current.withValue(.userInitiated) { + ProviderInteractionContext.$current.withValue(.background) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 return .interactionRequired @@ -289,6 +289,22 @@ struct BrowserDetectionTests { #expect(preflightCount == 1) } + @Test + func `user initiated cookie import allows chromium keychain sources requiring interaction`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + .interactionRequired + } operation: { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) + } + } + } + } + @Test func `chrome keychain preflight queries only chrome labels`() { BrowserCookieAccessGate.resetForTesting() 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/MemoryPressureCacheTrimTests.swift b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift index 1d908e6942..4e261f0fd4 100644 --- a/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift +++ b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift @@ -113,6 +113,7 @@ struct MemoryPressureCacheTrimTests { controller.menuCardHeightCache[key] = 42 controller.measuredStandardMenuWidthCache["width"] = 300 + controller.mergedMenuWidthCache["merged"] = 320 controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ .overview: entry, .provider(.codex): entry, @@ -122,11 +123,12 @@ struct MemoryPressureCacheTrimTests { let summary = controller.trimRebuildableCachesForMemoryPressure() #expect(summary.menuCardHeights == 1) - #expect(summary.menuWidths == 1) + #expect(summary.menuWidths == 2) #expect(summary.mergedSwitcherSelections == 2) #expect(summary.recycledMenuCardViews == 1) #expect(controller.menuCardHeightCache.isEmpty) #expect(controller.measuredStandardMenuWidthCache.isEmpty) + #expect(controller.mergedMenuWidthCache.isEmpty) #expect(controller.mergedSwitcherContentCaches.isEmpty) #expect(controller.menuCardViewRecyclePool.isEmpty) } diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 564c7dd4e3..45c5eb3013 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -149,6 +149,54 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.remainingPercent == 0) } + @Test + func `nearest reset window picks soonest minimax quota reset`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(3 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + + let window = MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot, now: now) + + #expect(window?.resetsAt == sessionReset) + #expect(window?.windowMinutes == 300) + } + + @Test + func `nearest reset window ignores expired minimax session reset`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expiredSessionReset = now.addingTimeInterval(-3600) + let weeklyReset = now.addingTimeInterval(3 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: expiredSessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + + let window = MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot, now: now) + + #expect(window?.resetsAt == weeklyReset) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + @Test func `automatic metric preserves exhausted minimax session lane`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift index 231e3b2e6d..2469651557 100644 --- a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift +++ b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -134,3 +135,101 @@ struct MenuBarResetTimeDisplayTests { #expect(text == "58%") } } + +@Suite(.serialized) +@MainActor +struct MenuBarMiniMaxResetTimeDisplayTests { + @Test + func `reset time mode shows nearest minimax quota reset`() { + let settings = testSettingsStore(suiteName: "MenuBarMiniMaxResetTimeDisplayTests-nearest-reset") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .minimax + settings.menuBarDisplayMode = .resetTime + settings.resetTimesShowAbsolute = true + settings.setMenuBarMetricPreference(.automatic, for: .minimax) + if let metadata = ProviderRegistry.shared.metadata[.minimax] { + settings.setProviderEnabled(provider: .minimax, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(3 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + + let resetWindow = controller.menuBarResetTimeWindow(for: .minimax, snapshot: snapshot) + #expect(resetWindow?.resetsAt == sessionReset) + + let displayText = controller.menuBarDisplayText(for: .minimax, snapshot: snapshot) + + #expect(displayText == "↻ \(UsageFormatter.resetDescription(from: sessionReset, now: now))") + } + + @Test + func `reset time mode honors explicit weekly metric preference`() { + let settings = testSettingsStore(suiteName: "MenuBarMiniMaxResetTimeDisplayTests-explicit-weekly") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .minimax + settings.menuBarDisplayMode = .resetTime + settings.resetTimesShowAbsolute = true + settings.setMenuBarMetricPreference(.secondary, for: .minimax) + if let metadata = ProviderRegistry.shared.metadata[.minimax] { + settings.setProviderEnabled(provider: .minimax, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(3 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + + let resetWindow = controller.menuBarResetTimeWindow(for: .minimax, snapshot: snapshot) + #expect(resetWindow?.resetsAt == weeklyReset) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 3dc706874f..aa98be6143 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -647,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, @@ -677,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]) @@ -714,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/PlanUtilizationHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift index dd86189a39..587a3041ea 100644 --- a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift @@ -85,4 +85,40 @@ struct PlanUtilizationHistoryChartMenuViewTests { #expect(model.visibleSeries == ["weekly:10080"]) #expect(model.selectedSeries == "weekly:10080") } + + @Test + func `minimax snapshot exposes session and weekly chart series`() { + let histories = [ + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 12, + resetsAt: nil), + ]), + PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 4, + resetsAt: nil), + ]), + ] + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 4, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .minimax, + snapshot: snapshot) + + #expect(model.visibleSeries == ["session:300", "weekly:10080"]) + #expect(model.selectedSeries == "session:300") + } } 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/StatusMenuMiniMaxPrewarmTests.swift b/Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift new file mode 100644 index 0000000000..a45ad1fd16 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift @@ -0,0 +1,48 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `merged menu prewarms MiniMax content before first switch`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableProvidersForInstantOpenTesting([.codex, .minimax], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + .toUsageSnapshot(), + provider: .minimax) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.prewarmMiniMaxMergedMenuContent(in: menu) + + let cached = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.minimax)] + #expect(cached?.items.contains { $0.representedObject as? String == "menuCard" } == true) + } +} 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 e33ed09d2b..e24066d8c5 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -316,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/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 13bb26e090..fef2596cda 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -637,6 +637,112 @@ struct UsageStorePlanUtilizationTests { #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.resetsAt == secondaryReset) } + @MainActor + @Test + func `record plan history persists minimax session and weekly series`() async { + let store = Self.makeStore() + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 12, + limit: 100, + percent: 12, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let snapshot = minimax.toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .minimax) + + await store.recordPlanUtilizationHistorySample( + provider: .minimax, + snapshot: snapshot, + now: now) + + let histories = store.planUtilizationHistory(for: .minimax) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 12) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 4) + #expect(store.supportsPlanUtilizationHistory(for: .minimax)) + #expect(!store.shouldHidePlanUtilizationMenuItem(for: .minimax)) + } + + @MainActor + @Test + func `record plan history labels minimax lanes by window type`() async { + let store = Self.makeStore() + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 12, + limit: 100, + percent: 12, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Today", + timeRange: "00:00-23:59(UTC+8)", + usage: 30, + limit: 100, + percent: 30, + resetsAt: now.addingTimeInterval(12 * 3600), + resetDescription: "Resets tonight"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let snapshot = minimax.toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .minimax) + + await store.recordPlanUtilizationHistorySample( + provider: .minimax, + snapshot: snapshot, + now: now) + + let histories = store.planUtilizationHistory(for: .minimax) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 12) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 4) + #expect(findSeries(histories, name: .weekly, windowMinutes: 1440) == nil) + } + @MainActor @Test func `record plan history skips invalid zero minute windows`() async { 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