From 4e3e744ca128794ecfe6575f242e9a5d0a76b4be Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:14:31 +0800 Subject: [PATCH 1/5] MiniMax: fetch Token Plan recharge credits via web session. Add token_plan_credit enrichment for cookie-backed refreshes, MiniMax Agent desktop cookie import, API-token web enrichment resolver, and menu credit display. Co-authored-by: Cursor --- CHANGELOG.md | 1 + Sources/CodexBar/MenuCardView+Costs.swift | 9 +- Sources/CodexBar/MenuCardView+MiniMax.swift | 20 +- .../Providers/MiniMax/MiniMaxAPIRegion.swift | 25 +- .../MiniMax/MiniMaxCookieImporter.swift | 2 + .../MiniMaxDesktopCookieImporter.swift | 272 ++++++++++++ .../MiniMax/MiniMaxProviderDescriptor.swift | 100 ++++- .../MiniMax/MiniMaxServiceUsage.swift | 50 ++- .../MiniMax/MiniMaxSettingsReader.swift | 10 + .../MiniMax/MiniMaxSubscriptionMetadata.swift | 30 ++ .../MiniMaxTokenPlanCreditFetcher.swift | 183 ++++++++ .../MiniMaxUsageFetcher+APIToken.swift | 55 +++ .../MiniMaxUsageFetcher+WebEnrichment.swift | 59 +++ .../MiniMax/MiniMaxUsageFetcher.swift | 130 +++--- .../MiniMaxUsageParser+ResetTime.swift | 61 +++ .../MiniMaxUsageSnapshot+Metadata.swift | 57 ++- .../MiniMax/MiniMaxUsageSnapshot.swift | 46 +- .../MiniMaxWebEnrichmentResolver.swift | 172 ++++++++ .../Providers/ProviderDiagnosticExport.swift | 6 + Sources/CodexBarCore/UsageFormatter.swift | 15 + .../CodexBarTests/BrowserDetectionTests.swift | 78 +--- .../MiniMax/token-plan-credit-normal.json | 29 ++ Tests/CodexBarTests/MenuCardModelTests.swift | 47 +-- ...MiniMaxCurrentTokenPlanResponseTests.swift | 14 + .../MiniMaxDesktopCookieImporterTests.swift | 182 ++++++++ .../CodexBarTests/MiniMaxProviderTests.swift | 265 +++++++++++- .../MiniMaxResetDescriptionTests.swift | 153 +++++++ .../MiniMaxTokenPlanChangeTests.swift | 38 ++ .../MiniMaxTokenPlanCreditTests.swift | 399 ++++++++++++++++++ .../MiniMaxWebEnrichmentResolverTests.swift | 160 +++++++ .../ProviderDiagnosticExportTests.swift | 20 + docs/minimax.md | 35 +- 32 files changed, 2486 insertions(+), 237 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+APIToken.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+WebEnrichment.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift create mode 100644 Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-credit-normal.json create mode 100644 Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d957bd5d5..5c32fbd3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Agent Sessions: list and focus live local or SSH-discovered Codex and Claude Code sessions from the menu and CLI. +- MiniMax: fetch Token Plan recharge credit balance from the console when a web session cookie is available, including MiniMax Agent desktop cookies and explicit manual or env cookies on API-token refreshes. Thanks @Yuxin-Qiao! ### Fixed - Ollama: recognize current WorkOS AuthKit sessions during browser-cookie discovery and manual cookie validation. Thanks @joeVenner! diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index b7d5fdbf37..405b2c0931 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -47,7 +47,6 @@ extension UsageMenuCardView.Model { error: String?) -> String? { guard metadata.supportsCredits else { return nil } - if metadata.id == .codex, credits == nil, error == nil { return nil } if metadata.id == .amp, let ampUsage = snapshot?.ampUsage, let ampCredits = self.ampCreditsLine(ampUsage) @@ -182,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 } @@ -304,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/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+WebEnrichment.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+WebEnrichment.swift new file mode 100644 index 0000000000..0338fdf0bb --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+WebEnrichment.swift @@ -0,0 +1,59 @@ +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 + + 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) + 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) + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 49d208ede8..9cef976d00 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,37 @@ 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 } + + return try await self.attachingTokenPlanCreditIfAvailable( + to: enrichedSnapshot, + context: context, + groupID: groupID) } private static func fetchBillingSummary(context: WebFetchContext, now: Date) async throws -> MiniMaxBillingSummary { @@ -985,14 +970,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 +1027,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 +1472,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 +1552,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/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift index d5048761b8..f25ec267f1 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -18,8 +18,10 @@ extension MiniMaxUsageSnapshot { services: self.services, billingSummary: self.billingSummary, pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, - subscriptionRenewsAt: self.subscriptionRenewsAt) + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) } func withSubscriptionMetadata(_ metadata: MiniMaxSubscriptionMetadata) -> MiniMaxUsageSnapshot { @@ -35,8 +37,59 @@ extension MiniMaxUsageSnapshot { services: self.services, billingSummary: self.billingSummary, 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 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, + 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, + 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..f8eecf89f0 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? @@ -12,8 +26,10 @@ public struct MiniMaxUsageSnapshot: Sendable { public let services: [MiniMaxServiceUsage]? public let billingSummary: MiniMaxBillingSummary? 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 @@ -76,8 +92,10 @@ public struct MiniMaxUsageSnapshot: Sendable { services: [MiniMaxServiceUsage]? = nil, billingSummary: MiniMaxBillingSummary? = 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 @@ -90,8 +108,10 @@ public struct MiniMaxUsageSnapshot: Sendable { self.services = services self.billingSummary = billingSummary self.pointsBalance = pointsBalance + self.pointsBalanceExpiresAt = pointsBalanceExpiresAt self.subscriptionExpiresAt = subscriptionExpiresAt self.subscriptionRenewsAt = subscriptionRenewsAt + self.webSessionState = webSessionState } public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { @@ -107,8 +127,29 @@ public struct MiniMaxUsageSnapshot: Sendable { services: self.services, billingSummary: billingSummary, 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, + pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, - subscriptionRenewsAt: self.subscriptionRenewsAt) + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: state) } } @@ -240,6 +281,7 @@ extension MiniMaxUsageSnapshot { limit: 0, currencyCode: "Points", period: "MiniMax points balance", + resetsAt: self.pointsBalanceExpiresAt, updatedAt: self.updatedAt) } } 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..8c2fa3edc8 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 = false self.services = snapshot.services?.map { MiniMaxDiagnosticServiceUsage(from: $0) } self.billingSummaryPresent = snapshot.billingSummary != nil } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 4b469d67e2..6259d1dd03 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -138,6 +138,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, diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index b585d655a0..d0deecad18 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -213,7 +213,7 @@ struct BrowserDetectionTests { } @Test - func `keychain interaction suppresses chromium family during cooldown`() { + func `keychain interaction suppresses chromium cookie source during cooldown`() { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } @@ -234,7 +234,6 @@ struct BrowserDetectionTests { return .allowed } operation: { #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) #expect( BrowserCookieAccessGate.shouldAttempt( .chrome, @@ -291,62 +290,16 @@ struct BrowserDetectionTests { } @Test - func `recorded browser denial suppresses automatic family and permits explicit source retry`() { + func `user initiated cookie import allows chromium keychain sources requiring interaction`() { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } - let start = Date(timeIntervalSince1970: 1500) - var preflightCount = 0 - KeychainAccessGate.withTaskOverrideForTesting(false) { - BrowserCookieAccessGate.recordIfNeeded( - BrowserCookieError.accessDenied(browser: .arc, details: "denied"), - now: start) KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in - preflightCount += 1 - return .allowed + .interactionRequired } operation: { - ProviderInteractionContext.$current.withValue(.background) { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(1)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(1)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.safari, now: start.addingTimeInterval(1)) == true) - } - BrowserCookieAccessGate.withExplicitRetry { - ProviderInteractionContext.$current.withValue(.userInitiated) { - #expect(BrowserCookieAccessGate - .shouldAttempt(.chrome, now: start.addingTimeInterval(2)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(2)) == true) - #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc)) - BrowserCookieAccessGate.recordAllowed(for: .arc) - } - } - ProviderInteractionContext.$current.withValue(.background) { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(3)) == true) - } - } - } - - #expect(preflightCount == 2) - } - - @Test - func `denied explicit cookie read closes retry scope`() { - BrowserCookieAccessGate.resetForTesting() - defer { BrowserCookieAccessGate.resetForTesting() } - - let start = Date(timeIntervalSince1970: 1700) - BrowserCookieAccessGate.recordDenied(for: .arc, now: start) - - KeychainAccessGate.withTaskOverrideForTesting(false) { - BrowserCookieAccessGate.withExplicitRetry { ProviderInteractionContext.$current.withValue(.userInitiated) { - #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1))) - #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc)) - - BrowserCookieAccessGate.recordDenied(for: .arc, now: start.addingTimeInterval(2)) - - #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(3)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(3)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) } } } @@ -399,7 +352,7 @@ struct BrowserDetectionTests { } @Test - func `browser keychain interaction suppresses family and permits scoped explicit retry`() throws { + func `browser keychain interaction suppresses only that browser`() throws { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } @@ -419,25 +372,14 @@ struct BrowserDetectionTests { if label == firstDiaLabel { return .interactionRequired } return .notFound } operation: { - ProviderInteractionContext.$current.withValue(.background) { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true) - #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) - } - BrowserCookieAccessGate.withExplicitRetry { - ProviderInteractionContext.$current.withValue(.userInitiated) { - #expect(BrowserCookieAccessGate - .shouldAttempt(.chrome, now: start.addingTimeInterval(61)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(61)) == true) - #expect(BrowserCookieAccessGate - .shouldAttempt(.edge, now: start.addingTimeInterval(61)) == false) - } - } + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) } } - #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstDiaLabel]) + #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstChromeLabel]) #expect(queriedLabels.allSatisfy { allowedLabels.contains($0) }) } diff --git a/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-credit-normal.json b/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-credit-normal.json new file mode 100644 index 0000000000..5c1fb7e19d --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-credit-normal.json @@ -0,0 +1,29 @@ +{ + "total_credits": 20000, + "used_credits": 0, + "remaining_credits": 20000, + "credit_packages_details": [ + { + "package_id": "6107592317211223800", + "group_id": "2040544334402560487", + "total_count": "20000", + "usage_count": "0", + "remains_count": "20000" + } + ], + "balance_breakdown": { + "total_balance": 20000, + "buckets": [ + { + "source": 2, + "balance": 20000, + "expire_time_ms": 1784995199999, + "order_id_prefix": "ORD-CR" + } + ] + }, + "base_resp": { + "status_code": 0, + "status_msg": "success" + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index ce5f01de36..aa98be6143 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -101,47 +101,6 @@ struct OverviewMenuCardVisibilityTests { } struct ProviderInlineDashboardModelTests { - @Test - func `kimi model orders rate limit before weekly quota`() throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let metadata = try #require(ProviderDefaults.metadata[.kimi]) - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 18.3, - windowMinutes: nil, - resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), - resetDescription: "375/2048 requests"), - secondary: RateWindow( - usedPercent: 9.5, - windowMinutes: 300, - resetsAt: now.addingTimeInterval(4 * 60 * 60), - resetDescription: "Rate: 19/200 per 5 hours"), - updatedAt: now) - - let model = UsageMenuCardView.Model.make(.init( - provider: .kimi, - metadata: metadata, - snapshot: snapshot, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.metrics.map(\.id) == ["secondary", "primary"]) - #expect(model.metrics.map(\.title) == ["Rate Limit", "Weekly"]) - } - @Test func `openrouter period usage gets inline dashboard`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) @@ -688,6 +647,7 @@ struct MiniMaxMenuCardModelTests { @Test func `minimax token plan model shows weekly quota and points balance`() throws { let now = Date() + let pointsBalanceExpiresAt = Date(timeIntervalSince1970: 1_784_995_199) let minimax = MiniMaxUsageSnapshot( planName: "Token Plan · TokenPlanPlus-年度会员", availablePrompts: nil, @@ -718,6 +678,7 @@ struct MiniMaxMenuCardModelTests { resetDescription: "Resets in 6 days"), ], pointsBalance: 14000, + pointsBalanceExpiresAt: pointsBalanceExpiresAt, subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) let snapshot = minimax.toUsageSnapshot() let metadata = try #require(ProviderDefaults.metadata[.minimax]) @@ -755,6 +716,10 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[1].cardStyle == false) #expect(model.providerCost?.title == "Credits") #expect(model.providerCost?.spendLine == "Balance: 14000") + let expectedExpiresLine = String( + format: L("Expires: %@"), + UsageFormatter.preciseDateTimeDescription(from: pointsBalanceExpiresAt, now: now)) + #expect(model.providerCost?.personalSpendLine == expectedExpiresLine) #expect(model.usageNotes == [String(format: L("Renews: %@"), minimaxRenewDate(1_810_569_600))]) } } diff --git a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift index 8a1d1c9999..62430573ad 100644 --- a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift +++ b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift @@ -52,6 +52,18 @@ struct MiniMaxCurrentTokenPlanResponseTests { body: "
Coding Plan Plus available usage 1000 prompts 5 hours
", contentType: "text/html") } + if url.path == "/backend/account/token_plan_credit" { + return Self.httpResponse( + url: url, + body: #"{"remaining_credits":0,"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } #expect(url.host == "platform.minimaxi.com") #expect(url.path == "/v1/api/openplatform/coding_plan/remains") return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") @@ -72,6 +84,8 @@ struct MiniMaxCurrentTokenPlanResponseTests { #expect(requests.map { $0.url?.host } == [ "platform.minimaxi.com", "platform.minimaxi.com", + "www.minimaxi.com", + "www.minimaxi.com", ]) } diff --git a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift new file mode 100644 index 0000000000..3c2cd34db5 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift @@ -0,0 +1,182 @@ +import CommonCrypto +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +#if os(macOS) +struct MiniMaxDesktopCookieImporterTests { + @Test + func `imports minimax agent cookies from desktop sqlite`() throws { + let databaseURL = try self.makeCookiesDatabase( + records: [ + (".www.minimaxi.com", "_token", "desktop-token-value", nil), + ("agent.minimaxi.com", "_token", "agent-token-value", nil), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession(databaseURL: databaseURL) + #expect(session?.sourceLabel == "MiniMax Agent") + #expect(session?.cookieHeader.contains("_token=desktop-token-value") == true) + #expect(session?.cookieHeader.contains("agent-token-value") == false) + } + + @Test + func `imports platform console cookies from desktop sqlite`() throws { + let databaseURL = try self.makeCookiesDatabase( + records: [ + ("platform.minimaxi.com", "_token", "platform-token-value", nil), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession(databaseURL: databaseURL) + #expect(session?.cookieHeader.contains("_token=platform-token-value") == true) + } + + @Test + func `imports parent domain minimax agent cookies`() throws { + let databaseURL = try self.makeCookiesDatabase( + records: [ + (".minimaxi.com", "_token", "parent-domain-token", nil), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession(databaseURL: databaseURL) + #expect(session?.cookieHeader.contains("_token=parent-domain-token") == true) + } + + @Test + func `imports encrypted desktop cookies when plaintext value is empty`() throws { + let password = "desktop-test-password" + let encrypted = try self.makeEncryptedCookieValue(plaintext: "encrypted-token-value", password: password) + let databaseURL = try self.makeCookiesDatabase( + records: [ + (".www.minimaxi.com", "_token", "", encrypted), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession( + databaseURL: databaseURL, + decryptionKeys: [self.deriveKey(from: password)]) + #expect(session?.cookieHeader.contains("_token=encrypted-token-value") == true) + } + + private func makeEncryptedCookieValue(plaintext: String, password: String) throws -> Data { + let key = self.deriveKey(from: password) + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + let payload = plaintext.data(using: .utf8) ?? Data() + var outLength = 0 + var out = Data(count: payload.count + kCCBlockSizeAES128) + let outCapacity = out.count + let status = out.withUnsafeMutableBytes { outBytes in + payload.withUnsafeBytes { payloadBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + payloadBytes.baseAddress, + payload.count, + outBytes.baseAddress, + outCapacity, + &outLength) + } + } + } + } + guard status == kCCSuccess else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("encrypt failed") + } + out.count = outLength + var encrypted = Data("v10".utf8) + encrypted.append(out) + return encrypted + } + + private func deriveKey(from password: String) -> Data { + let salt = Data("saltysalt".utf8) + var key = Data(count: kCCKeySizeAES128) + let keyLength = key.count + _ = key.withUnsafeMutableBytes { keyBytes in + password.utf8CString.withUnsafeBytes { passBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passBytes.bindMemory(to: Int8.self).baseAddress, + passBytes.count - 1, + saltBytes.bindMemory(to: UInt8.self).baseAddress, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + keyBytes.bindMemory(to: UInt8.self).baseAddress, + keyLength) + } + } + } + return key + } + + private func makeCookiesDatabase( + records: [(String, String, String, Data?)]) throws -> URL + { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("minimax-desktop-cookies-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let databaseURL = directory.appendingPathComponent("Cookies") + + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("open failed") + } + defer { sqlite3_close(db) } + + let createSQL = """ + CREATE TABLE cookies ( + host_key TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL, + encrypted_value BLOB + ); + """ + guard sqlite3_exec(db, createSQL, nil, nil, nil) == SQLITE_OK else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("create failed") + } + + let insertSQL = "INSERT INTO cookies(host_key, name, value, encrypted_value) VALUES (?, ?, ?, ?);" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, insertSQL, -1, &stmt, nil) == SQLITE_OK else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("prepare failed") + } + defer { sqlite3_finalize(stmt) } + + for (host, name, value, encrypted) in records { + sqlite3_reset(stmt) + sqlite3_clear_bindings(stmt) + sqlite3_bind_text(stmt, 1, host, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_text(stmt, 2, name, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_text(stmt, 3, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + if let encrypted { + _ = encrypted.withUnsafeBytes { bytes in + sqlite3_bind_blob( + stmt, + 4, + bytes.baseAddress, + Int32(encrypted.count), + unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + } + } else { + sqlite3_bind_null(stmt, 4) + } + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("insert failed") + } + } + + return databaseURL + } +} +#endif diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 03dd11c874..720d85d81d 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,165 @@ 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.pointsBalance == nil) + #expect(await transport.requests().count == 1) + } + + @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 attaches credit when group matches`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + 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.pointsBalance == 20000) + } + + @Test + func `web enrichment ignores credit when group does not match`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + 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.pointsBalance == nil) } @Test @@ -1162,6 +1359,7 @@ struct MiniMaxUsageParserTests { } } +// swiftlint:enable type_body_length struct MiniMaxAPIRegionTests { @Test func `defaults to global hosts`() { @@ -1198,6 +1396,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"] 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..8b95778e6c --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -0,0 +1,399 @@ +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 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_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" }) + } + + @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) + } + + 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/MiniMaxWebEnrichmentResolverTests.swift b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift new file mode 100644 index 0000000000..2312bcad3c --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +struct MiniMaxWebEnrichmentResolverTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `manual cookie candidate is available without browser import gate`() { + let settings = ProviderSettingsSnapshot.make( + minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: .manual, + manualCookieHeader: "_token=manual-session", + apiRegion: .chinaMainland)) + let context = self.makeContext(runtime: .cli, settings: settings) + + let candidates = MiniMaxWebEnrichmentResolver.candidates(context: context) + + #expect( + candidates.contains { + $0.sourceLabel == "settings" && $0.override.cookieHeader.contains("_token=manual-session") + }) + } + + @Test + func `explicit candidates ignore saved manual cookie when source is auto`() { + let settings = ProviderSettingsSnapshot.make( + minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: .auto, + manualCookieHeader: "_token=stale-manual", + apiRegion: .global)) + let context = self.makeContext(runtime: .cli, settings: settings) + + let explicit = MiniMaxWebEnrichmentResolver.explicitCandidates(context: context) + + #expect(!explicit.contains { $0.sourceLabel == "settings" }) + } + + @Test + func `api enrichment includes desktop agent session before cached browser cookies`() throws { + CookieHeaderCache.store( + provider: .minimax, + cookieHeader: "_token=cached-session", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .minimax) } + + let settings = ProviderSettingsSnapshot.make( + minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil, + apiRegion: .global)) + let context = self.makeContext(runtime: .app, settings: settings) + let session = try MiniMaxCookieImporter.SessionInfo( + cookies: [#require(HTTPCookie(properties: [ + .domain: "www.minimaxi.com", + .name: "_token", + .path: "/", + .value: "desktop-token-value", + .secure: "TRUE", + ]))], + sourceLabel: "MiniMax Agent") + + let api = MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates( + context: context, + desktopSession: session) + + #expect(api.contains { $0.sourceLabel == "MiniMax Agent" }) + if let agentIndex = api.firstIndex(where: { $0.sourceLabel == "MiniMax Agent" }), + let chromeIndex = api.firstIndex(where: { $0.sourceLabel == "Chrome" }) + { + #expect(agentIndex < chromeIndex) + } + } + + @Test + func `desktop agent candidates are exposed for focused tests`() throws { + let context = self.makeContext(runtime: .app) + let session = try MiniMaxCookieImporter.SessionInfo( + cookies: [#require(HTTPCookie(properties: [ + .domain: "platform.minimaxi.com", + .name: "_token", + .path: "/", + .value: "agent-session", + .secure: "TRUE", + ]))], + sourceLabel: "MiniMax Agent") + + let candidates = MiniMaxWebEnrichmentResolver.desktopAgentCandidates( + context: context, + session: session) + + #expect(candidates.count == 1) + #expect(candidates[0].sourceLabel == "MiniMax Agent") + #expect(candidates[0].shouldCache) + #expect(candidates[0].override.cookieHeader.contains("_token=agent-session")) + } + + @Test + func `browser import gate only allows user initiated app refresh`() { + let context = self.makeContext(runtime: .app, includeOptionalUsage: true) + + ProviderInteractionContext.$current.withValue(.background) { + #expect(!MiniMaxWebEnrichmentResolver.allowsBrowserCookieImport(context: context)) + } + + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(MiniMaxWebEnrichmentResolver.allowsBrowserCookieImport(context: context)) + } + } + + @Test + func `explicit candidates exclude cached browser cookies`() { + CookieHeaderCache.store( + provider: .minimax, + cookieHeader: "_token=cached-session", + sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .minimax) } + + let context = self.makeContext(runtime: .app) + let explicit = MiniMaxWebEnrichmentResolver.explicitCandidates(context: context) + let api = MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates(context: context) + + #expect(!explicit.contains { $0.sourceLabel == "Chrome" }) + #expect(api.contains { $0.isCached && $0.sourceLabel == "Chrome" }) + } + + private func makeContext( + runtime: ProviderRuntime, + includeOptionalUsage: Bool = true, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: runtime, + sourceMode: .auto, + includeCredits: false, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} +#endif diff --git a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift index 093ee163ec..84dafb68c9 100644 --- a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -430,6 +430,26 @@ struct ProviderDiagnosticExportTests { #expect(details.remainingPrompts == 750) #expect(details.windowMinutes == 300) #expect(details.usedPercent == 25) + #expect(details.pointsBalance == nil) + } + + @Test + func `MiniMax details include recharge credit balance`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now, + services: nil, + pointsBalance: 20000) + + let details = MiniMaxDiagnosticDetails(from: snapshot) + #expect(details.pointsBalance == 20000) } @Test diff --git a/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. From a82bb6aae38670f04d51c4657a1ba5de206e362f Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:25:09 +0800 Subject: [PATCH 2/5] MiniMax: add usage summary dashboard UI. Surface console usage-summary KPIs, cost trends, and token usage details in the menu card and hosted submenus, stacked on the credit enrichment foundation. Co-authored-by: Cursor --- CHANGELOG.md | 1 + .../InlineUsageDashboardContent.swift | 251 ++++++- .../MiniMaxUsageSummaryChartMenuView.swift | 706 ++++++++++++++++++ .../Resources/ar.lproj/Localizable.strings | 31 + .../Resources/ca.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 31 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fa.lproj/Localizable.strings | 31 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/gl.lproj/Localizable.strings | 31 + .../Resources/id.lproj/Localizable.strings | 33 +- .../Resources/it.lproj/Localizable.strings | 31 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 31 + .../Resources/pt-BR.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/th.lproj/Localizable.strings | 31 + .../Resources/tr.lproj/Localizable.strings | 31 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 31 + .../zh-Hant.lproj/Localizable.strings | 31 +- .../StatusItemController+Actions.swift | 15 + .../StatusItemController+HostedSubmenus.swift | 36 + ...troller+MiniMaxUsageSummaryConstants.swift | 5 + ...emController+MiniMaxUsageSummaryMenu.swift | 94 +++ ...ontroller+MiniMaxUsageSummarySection.swift | 74 ++ Sources/CodexBar/UsageStore+TokenCost.swift | 16 +- .../MiniMaxUsageFetcher+UsageSummary.swift | 114 +++ .../MiniMaxUsageFetcher+WebEnrichment.swift | 59 -- .../MiniMax/MiniMaxUsageFetcher.swift | 7 +- .../MiniMax/MiniMaxUsagePricing.swift | 177 +++++ .../MiniMaxUsageSnapshot+Metadata.swift | 24 + .../MiniMax/MiniMaxUsageSnapshot.swift | 5 + .../MiniMaxUsageSummary+CostProjection.swift | 117 +++ .../MiniMax/MiniMaxUsageSummary.swift | 409 ++++++++++ .../Providers/ProviderDiagnosticExport.swift | 2 +- Sources/CodexBarCore/UsageFormatter.swift | 26 +- .../MiniMaxMenuCardBillingTests.swift | 369 +++++++++ .../CodexBarTests/MiniMaxProviderTests.swift | 53 +- .../MiniMaxTokenPlanCreditTests.swift | 96 ++- .../MiniMaxUsagePricingTests.swift | 174 +++++ ...iniMaxUsageSummaryChartMenuViewTests.swift | 170 +++++ .../MiniMaxUsageSummaryTests.swift | 155 ++++ .../StatusMenuMiniMaxPrewarmTests.swift | 48 ++ .../CodexBarTests/StatusMenuTestSupport.swift | 18 + Tests/CodexBarTests/UsageFormatterTests.swift | 24 +- .../menu-card-inline-dashboard.png | Bin 0 -> 67082 bytes .../minimax-usage/menu-card-usage-notes.png | Bin 0 -> 67702 bytes .../subscription-utilization.png | Bin 0 -> 42609 bytes .../minimax-usage/token-usage-details.png | Bin 0 -> 92733 bytes 54 files changed, 3515 insertions(+), 95 deletions(-) create mode 100644 Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift create mode 100644 Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift create mode 100644 Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift create mode 100644 Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift delete mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+WebEnrichment.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift create mode 100644 Tests/CodexBarTests/MiniMaxUsagePricingTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift create mode 100644 Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift create mode 100644 Tests/CodexBarTests/StatusMenuTestSupport.swift create mode 100644 docs/screenshots/minimax-usage/menu-card-inline-dashboard.png create mode 100644 docs/screenshots/minimax-usage/menu-card-usage-notes.png create mode 100644 docs/screenshots/minimax-usage/subscription-utilization.png create mode 100644 docs/screenshots/minimax-usage/token-usage-details.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c32fbd3f3..89a7f97bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Agent Sessions: list and focus live local or SSH-discovered Codex and Claude Code sessions from the menu and CLI. - MiniMax: fetch Token Plan recharge credit balance from the console when a web session cookie is available, including MiniMax Agent desktop cookies and explicit manual or env cookies on API-token refreshes. Thanks @Yuxin-Qiao! +- MiniMax: show console usage-summary KPIs, cost trends, and token usage details in the menu dashboard. Thanks @Yuxin-Qiao! ### Fixed - Ollama: recognize current WorkOS AuthKit sessions during browser-cookie discovery and manual cookie validation. Thanks @joeVenner! diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 2577ae6bae..9b7c30eb08 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -68,6 +68,14 @@ extension UsageMenuCardView.Model { return notes } + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData + { + return Self.minimaxUsageSummaryNotes(usageSummary) + } + if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, let billing = input.snapshot?.minimaxUsage?.billingSummary @@ -183,7 +191,21 @@ extension UsageMenuCardView.Model { } private static func resolveInlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + input.tokenCostInlineDashboardEnabled, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData, + let costSnapshot = primaryCostHistorySnapshot(input: input), + !costSnapshot.daily.isEmpty + { + return self.minimaxCostAndUsageSummaryInlineDashboard( + snapshot: costSnapshot, + usage: usageSummary, + comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled) + } if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider), + input.provider != .minimax || input.tokenCostInlineDashboardEnabled, let tokenSnapshot = primaryCostHistorySnapshot(input: input), !tokenSnapshot.daily.isEmpty { @@ -212,6 +234,13 @@ extension UsageMenuCardView.Model { { return Self.zaiInlineDashboard(modelUsage: modelUsage, now: input.now) } + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData + { + return Self.minimaxUsageSummaryInlineDashboard(usageSummary) + } if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, let billing = input.snapshot?.minimaxUsage?.billingSummary, @@ -246,7 +275,7 @@ extension UsageMenuCardView.Model { } static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { - provider == .openai || provider == .mistral + provider == .openai || provider == .mistral || provider == .minimax } static func primaryCostHistorySnapshot(input: Input) -> CostUsageTokenSnapshot? { @@ -261,6 +290,11 @@ extension UsageMenuCardView.Model { return projected } return input.snapshot == nil ? input.tokenSnapshot : nil + case .minimax: + if let projected = input.snapshot?.minimaxUsage?.usageSummary?.toCostUsageTokenSnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil default: return input.tokenSnapshot } @@ -396,7 +430,9 @@ extension UsageMenuCardView.Model { emphasis: false), .init( title: tokenHistoryTitle, - value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", + value: snapshot.last30DaysTokens.map { + Self.tokenCountString($0, provider: provider) + } ?? "—", emphasis: false), ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest), points: points, @@ -638,6 +674,214 @@ extension UsageMenuCardView.Model { detailLines: details) } + private static func minimaxUsageSummaryInlineDashboard(_ usage: MiniMaxUsageSummary) -> InlineUsageDashboardModel { + let trendDays = usage.trendDays(last: 7) + let points = trendDays.map { + let tokenText = Self.minimaxTokenString($0.totalToken) + let cacheText = UsageFormatter.optionalPercentString($0.cacheHitPercent) + let cacheLine = String(format: L("Cache hit: %@"), cacheText) + return InlineUsageDashboardModel.Point( + id: $0.date, + label: Self.shortDayLabel($0.date), + value: Double($0.totalToken), + accessibilityValue: "\($0.date): \(tokenText) \(L("tokens")) · \(cacheLine)") + } + let cacheText = UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent) + let todayTitle = usage.lastUpdateTime + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap { $0.isEmpty ? nil : String(format: L("%@ usage"), $0) } + ?? L("Today") + + return InlineUsageDashboardModel( + accessibilityLabel: L("MiniMax 7 day token usage trend"), + valueStyle: .tokens, + kpis: [ + .init( + title: todayTitle, + value: Self.minimaxTokenString(usage.latestSnapshotTokens), + emphasis: true), + .init( + title: L("7d tokens"), + value: Self.minimaxTokenString(usage.last7DaysTokens), + emphasis: false), + .init( + title: L("Cache hit"), + value: cacheText, + emphasis: false), + .init( + title: L("30d tokens"), + value: Self.minimaxTokenString(usage.last30DaysTokens), + emphasis: false), + ], + points: points, + detailLines: Self.minimaxUsageSummaryCardDetailLines(usage)) + } + + private static func minimaxLatestUsageKPITitle(_ usage: MiniMaxUsageSummary) -> String { + if let time = usage.lastUpdateTime?.trimmingCharacters(in: .whitespacesAndNewlines), !time.isEmpty { + return String(format: L("%@ usage"), time) + } + return L("Latest usage") + } + + private static func minimaxCostAndUsageSummaryInlineDashboard( + snapshot: CostUsageTokenSnapshot, + usage: MiniMaxUsageSummary, + comparisonPeriodsEnabled: Bool) -> InlineUsageDashboardModel + { + let base = self.costHistoryInlineDashboard( + provider: .minimax, + snapshot: snapshot, + comparisonPeriodsEnabled: comparisonPeriodsEnabled) + let cacheText = UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent) + return InlineUsageDashboardModel( + accessibilityLabel: base.accessibilityLabel, + valueStyle: base.valueStyle, + kpis: [ + base.kpis[0], + base.kpis[1], + .init( + title: self.minimaxLatestUsageKPITitle(usage), + value: self.minimaxTokenString(usage.latestSnapshotTokens), + emphasis: false), + .init( + title: L("7d tokens"), + value: self.minimaxTokenString(usage.last7DaysTokens), + emphasis: false), + .init( + title: L("Cache hit"), + value: cacheText, + emphasis: false), + .init( + title: L("30d tokens"), + value: self.minimaxTokenString(usage.last30DaysTokens), + emphasis: false), + ], + points: base.points, + detailLines: self.minimaxCostDashboardDetailLines(snapshot: snapshot, usage: usage)) + } + + private static func minimaxCostDashboardDetailLines( + snapshot: CostUsageTokenSnapshot, + usage: MiniMaxUsageSummary) -> [String] + { + var details: [String] = [] + if let topModel = Self.topCostModel(from: snapshot.daily) { + details.append("\(L("Top model")): \(Self.shortModelName(topModel))") + } + if let day = usage.snapshotDay, + let todayLine = Self.minimaxTodayBreakdownLine(day) + { + details.append(todayLine) + } + if let hint = Self.tokenUsageHint(provider: .minimax) { + details.append(hint) + } + details.append(L("Language models only; data may be delayed")) + return details + } + + private static func minimaxUsageSummaryCardDetailLines(_ usage: MiniMaxUsageSummary) -> [String] { + var details: [String] = [] + if let day = usage.snapshotDay, + let todayLine = Self.minimaxTodayBreakdownLine(day) + { + details.append(todayLine) + } + if let total = usage.totalTokenConsumed, !total.isEmpty { + details.append("\(L("Total consumed")): \(total)") + } + if let cache = usage.snapshotDay?.cacheHitPercent { + details.append( + String(format: L("Cache hit: %@"), UsageFormatter.optionalPercentString(cache))) + } + if let activeDays = usage.activeDays { + details.append(String(format: L("Active days: %@"), "\(activeDays)")) + } + if let streak = usage.currentConsecutiveDays { + details.append(String(format: L("Streak: %@ days"), "\(streak)")) + } + if let rank = usage.usageRankingPercent { + details.append( + String( + format: L("Usage rank: top %@%%"), + UsageFormatter.decimalString(rank))) + } + if let updated = usage.lastUpdateTime, !updated.isEmpty { + details.append("\(L("Updated")): \(updated)") + } + if let topModel = Self.topMiniMaxUsageSummaryModel(from: usage.days) { + details.append("\(L("Top model")): \(Self.shortModelName(topModel.model)) (\(topModel.tokens))") + } + details.append(L("Language models only; data may be delayed")) + return details + } + + private static func minimaxTodayBreakdownLine(_ day: MiniMaxUsageSummaryDay) -> String? { + if let topModel = day.models.max(by: { lhs, rhs in + if lhs.totalToken == rhs.totalToken { return lhs.model > rhs.model } + return lhs.totalToken < rhs.totalToken + }) { + let cacheText = UsageFormatter.optionalPercentString(topModel.cacheHitPercent) + return String( + format: L("%@ · %@ input · %@ output · %@ cache hit"), + Self.shortModelName(topModel.model), + Self.minimaxTokenString(topModel.inputToken), + Self.minimaxTokenString(topModel.outputToken), + cacheText) + } + guard day.totalToken > 0 else { return nil } + let cacheText = UsageFormatter.optionalPercentString(day.cacheHitPercent) + return String( + format: L("%@ input · %@ output · %@ cache hit"), + Self.minimaxTokenString(day.totalInputToken), + Self.minimaxTokenString(day.totalOutputToken), + cacheText) + } + + private static func minimaxTokenString(_ value: Int) -> String { + UsageFormatter.tokenCountString(value, fractionDigits: 2) + } + + private static func tokenCountString(_ value: Int, provider: UsageProvider) -> String { + if provider == .minimax { + return self.minimaxTokenString(value) + } + return UsageFormatter.tokenCountString(value) + } + + private static func minimaxUsageSummaryNotes(_ usage: MiniMaxUsageSummary) -> [String] { + [ + String( + format: L("Today: %@ tokens"), + self.minimaxTokenString(usage.latestSnapshotTokens)), + String( + format: L("Last 7 days: %@ tokens"), + self.minimaxTokenString(usage.last7DaysTokens)), + String( + format: L("Last 30 days: %@ tokens"), + self.minimaxTokenString(usage.last30DaysTokens)), + ] + self.minimaxUsageSummaryCardDetailLines(usage) + } + + private static func topMiniMaxUsageSummaryModel( + from days: [MiniMaxUsageSummaryDay]) -> (model: String, tokens: String)? + { + var totals: [String: Int] = [:] + for day in days { + for model in day.models { + totals[model.model, default: 0] += model.totalToken + } + } + guard let top = totals.max(by: { lhs, rhs in + if lhs.value == rhs.value { return lhs.key > rhs.key } + return lhs.value < rhs.value + }) else { + return nil + } + return (top.key, Self.minimaxTokenString(top.value)) + } + private static func deepseekInlineDashboard(_ usage: DeepSeekUsageSummary) -> InlineUsageDashboardModel { let symbol = usage.currency == "CNY" ? "¥" : "$" let points = usage.daily.suffix(30).map { @@ -810,7 +1054,8 @@ struct InlineUsageDashboardContent: View { Text(line) .font(.caption) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift b/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift new file mode 100644 index 0000000000..aded0f1ced --- /dev/null +++ b/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift @@ -0,0 +1,706 @@ +import Charts +import CodexBarCore +import SwiftUI + +@MainActor +struct MiniMaxUsageSummaryChartMenuView: View { + private struct Point: Identifiable { + let id: String + let dateKey: String + let date: Date + let totalTokens: Int + let cacheHitPercent: Double? + let day: MiniMaxUsageSummaryDay + } + + private struct DetailRow: Identifiable { + let id: String + let title: String + let subtitle: String? + let modeSubtitle: String? + let accentColor: Color + } + + private struct KPI: Identifiable { + let id: String + let title: String + let value: String + } + + private struct Model { + let points: [Point] + let pointsByDateKey: [String: Point] + let dateKeys: [(key: String, date: Date)] + let axisDates: [Date] + let barColor: Color + } + + private let usage: MiniMaxUsageSummary + private let width: CGFloat + private let showsSummaryKPIs: Bool + private let onHeightChange: ((CGFloat) -> Void)? + @State private var selectedDateKey: String? + @State private var windowDays: WindowDays = .seven + + private enum WindowDays: Int, CaseIterable { + case seven = 7 + case thirty = 30 + } + + init( + usage: MiniMaxUsageSummary, + showsSummaryKPIs: Bool = true, + onHeightChange: ((CGFloat) -> Void)? = nil, + width: CGFloat) + { + self.usage = usage + self.showsSummaryKPIs = showsSummaryKPIs + self.onHeightChange = onHeightChange + self.width = width + } + + var body: some View { + let model = Self.makeModel(usage: self.usage, windowDays: self.windowDays.rawValue) + let selectedDateKey = self.effectiveSelectedDateKey(model: model) + let detail = self.detailContent(selectedDateKey: selectedDateKey, model: model) + + VStack(alignment: .leading, spacing: Self.outerSpacing) { + if self.showsSummaryKPIs { + self.kpiGrid(Self.summaryKPIs(usage: self.usage)) + } else { + self.usageStatsSection(windowDays: self.windowDays) + } + + self.trendHeaderSection + + if model.points.isEmpty { + Text(L("No data available")) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Chart { + ForEach(model.points) { point in + BarMark( + x: .value(L("Day"), point.date, unit: .day), + y: .value(L("tokens"), point.totalTokens)) + .foregroundStyle(model.barColor) + } + } + .chartYAxis(.hidden) + .chartXAxis(.hidden) + .chartLegend(.hidden) + .frame(height: Self.chartHeight) + .accessibilityLabel( + self.windowDays == .seven + ? L("MiniMax 7 day token usage trend") + : L("MiniMax 30 day token usage trend")) + .chartOverlay { proxy in + GeometryReader { geo in + ZStack(alignment: .topLeading) { + if let rect = self.selectionBandRect( + selectedDateKey: selectedDateKey, + model: model, + proxy: proxy, + geo: geo) + { + Rectangle() + .fill(Self.selectionBandColor) + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + .allowsHitTesting(false) + } + MouseLocationReader { location in + self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + } + } + + if model.axisDates.count == 2 { + HStack { + Text(model.axisDates[0], format: .dateTime.month(.abbreviated).day()) + Spacer() + Text(model.axisDates[1], format: .dateTime.month(.abbreviated).day()) + } + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .frame(height: Self.axisLabelAreaHeight) + .padding(.top, -Self.outerSpacing) + } + + VStack(alignment: .leading, spacing: Self.detailSpacing) { + Text(detail.primary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.tail) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + + if !detail.rows.isEmpty { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: Self.detailSpacing) { + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame( + width: 2, + height: Self.accentHeight(for: row)) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.detailTitleLineHeight, alignment: .leading) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + if let modeSubtitle = row.modeSubtitle { + Text(modeSubtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + } + } + .frame(height: Self.detailRowHeight(for: row), alignment: .leading) + } + } + } + .scrollIndicators( + Self.detailRowsNeedScrolling(itemCount: detail.rows.count) ? .visible : .hidden) + .frame( + height: Self.detailRowsViewportHeight(rows: detail.rows), + alignment: .topLeading) + .id(selectedDateKey) + } + } + .frame( + height: Self.detailBlockHeight(rows: detail.rows), + alignment: .topLeading) + } + + ForEach(Self.footerLines(usage: self.usage), id: \.self) { line in + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(height: Self.footerLineHeight, alignment: .leading) + } + } + .padding(.horizontal, 16) + .padding(.vertical, Self.verticalPadding) + .frame(minWidth: self.width, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onChange(of: self.windowDays) { _, newValue in + self.selectedDateKey = nil + let updatedModel = Self.makeModel(usage: self.usage, windowDays: newValue.rawValue) + self.notifyHeightChange(model: updatedModel) + } + } + + private var trendHeaderSection: some View { + VStack(alignment: .leading, spacing: Self.trendHeaderSpacing) { + Text(L("Usage trend")) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + self.windowToggle + } + .frame(height: Self.trendHeaderBlockHeight, alignment: .topLeading) + } + + private var windowToggle: some View { + Picker( + selection: Binding( + get: { self.windowDays.rawValue }, + set: { self.windowDays = WindowDays(rawValue: $0) ?? .seven })) + { + Text(L("Last 7 days")).tag(WindowDays.seven.rawValue) + Text(L("Last 30 days")).tag(WindowDays.thirty.rawValue) + } label: { + EmptyView() + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityLabel(L("Usage trend")) + } + + static let maxVisibleDetailLines = 4 + private static let kpiTitleLineHeight: CGFloat = 13 + private static let kpiValueLineHeight: CGFloat = 20 + private static let kpiCellInnerSpacing: CGFloat = 1 + private static let kpiRowHeight: CGFloat = 36 + private static let kpiGridRowSpacing: CGFloat = 6 + private static let kpiGridHeight: CGFloat = 78 + private static let trendHeaderSpacing: CGFloat = 6 + private static let trendHeaderTitleHeight: CGFloat = 14 + private static let trendHeaderPickerHeight: CGFloat = 24 + private static var trendHeaderBlockHeight: CGFloat { + self.trendHeaderTitleHeight + self.trendHeaderSpacing + self.trendHeaderPickerHeight + } + + private static let footerLineHeight: CGFloat = 28 + private static let detailPrimaryLineHeight: CGFloat = 32 + private static let detailTitleLineHeight: CGFloat = 16 + private static let detailSubtitleLineHeight: CGFloat = 13 + private static let compactDetailRowHeight: CGFloat = 36 + private static let expandedDetailRowHeight: CGFloat = 44 + private static let detailSpacing: CGFloat = 6 + private static let chartHeight: CGFloat = 114 + private static let axisLabelAreaHeight: CGFloat = 16 + private static let outerSpacing: CGFloat = 10 + static let verticalPadding: CGFloat = 10 + private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) + + private static func totalCardHeight( + rows: [DetailRow], + hasChart: Bool) -> CGFloat + { + var height = self.verticalPadding * 2 + height += self.kpiGridHeight + height += self.outerSpacing + self.trendHeaderBlockHeight + if hasChart { + height += self.chartHeight + height += self.axisLabelAreaHeight + height += self.outerSpacing + height += self.detailBlockHeight(rows: rows) + } else { + height += 20 + } + height += self.outerSpacing + height += self.footerLineHeight + return height + } + + private static func detailBlockHeight(rows: [DetailRow]) -> CGFloat { + guard !rows.isEmpty else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + self.detailSpacing + self.detailRowsViewportHeight(rows: rows) + } + + private static func detailRowsViewportHeight(rows: [DetailRow]) -> CGFloat { + let visibleRows = Array(rows.prefix(self.maxVisibleDetailLines)) + guard !visibleRows.isEmpty else { return 0 } + + let rowHeights = visibleRows.reduce(CGFloat(0)) { total, row in + total + self.detailRowHeight(for: row) + } + let spacing = CGFloat(max(visibleRows.count - 1, 0)) * self.detailSpacing + return rowHeights + spacing + } + + private static func detailRowHeight(for row: DetailRow) -> CGFloat { + self.detailRowHeight(hasModeSubtitle: row.modeSubtitle != nil) + } + + private static func detailRowHeight(hasModeSubtitle: Bool) -> CGFloat { + hasModeSubtitle ? self.expandedDetailRowHeight : self.compactDetailRowHeight + } + + private static func accentHeight(for row: DetailRow) -> CGFloat { + row.subtitle == nil && row.modeSubtitle == nil ? 14 : self.detailRowHeight(for: row) + } + + static func detailViewportRowCount(itemCount: Int) -> Int { + min(max(itemCount, 0), self.maxVisibleDetailLines) + } + + static func detailRowsNeedScrolling(itemCount: Int) -> Bool { + itemCount > self.maxVisibleDetailLines + } + + private func effectiveSelectedDateKey(model: Model) -> String? { + self.selectedDateKey ?? model.dateKeys.last?.key + } + + private func notifyHeightChange(model: Model) { + guard let onHeightChange = self.onHeightChange else { return } + let detail = self.detailContent( + selectedDateKey: self.effectiveSelectedDateKey(model: model), + model: model) + onHeightChange(Self.totalCardHeight( + rows: detail.rows, + hasChart: !model.points.isEmpty)) + } + + private func usageStatsSection(windowDays: WindowDays) -> some View { + self.kpiGrid(Self.usageStatsKPIs(usage: self.usage, windowDays: windowDays)) + } + + private static func usageStatsKPIs(usage: MiniMaxUsageSummary, windowDays: WindowDays) -> [KPI] { + var kpis: [KPI] = [] + if let total = usage.totalTokenConsumed?.trimmingCharacters(in: .whitespacesAndNewlines), + !total.isEmpty + { + kpis.append(KPI(id: "total", title: L("Total consumed"), value: total)) + } + if let peak = self.peakDayTokens(usage: usage) { + kpis.append(KPI( + id: "peak", + title: L("Daily peak"), + value: self.minimaxTokenString(peak))) + } + if let activeDays = usage.activeDays { + kpis.append(KPI(id: "active", title: L("Active days"), value: "\(activeDays)")) + } + if let spend = usage.projectedCostUSD(lastDays: windowDays.rawValue) { + kpis.append(KPI( + id: "spend", + title: self.windowSpendTitle(windowDays: windowDays), + value: UsageFormatter.usdString(spend))) + } + return kpis + } + + private static func peakDayTokens(usage: MiniMaxUsageSummary) -> Int? { + let dayPeak = usage.days.map(\.totalToken).max() ?? 0 + let trendPeak = usage.dailyTokenUsage.max() ?? 0 + let peak = max(dayPeak, trendPeak) + return peak > 0 ? peak : nil + } + + private func kpiGrid(_ kpis: [KPI]) -> some View { + VStack(alignment: .leading, spacing: Self.kpiGridRowSpacing) { + ForEach(Array(stride(from: 0, to: kpis.count, by: 2)), id: \.self) { rowStart in + HStack(alignment: .top, spacing: 0) { + ForEach(kpis[rowStart.. some View { + VStack(alignment: .leading, spacing: Self.kpiCellInnerSpacing) { + Text(kpi.title) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.kpiTitleLineHeight, alignment: .leading) + Text(kpi.value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.72) + .frame(height: Self.kpiValueLineHeight, alignment: .leading) + } + .frame(height: Self.kpiRowHeight, alignment: .topLeading) + } + + private static func summaryKPIs(usage: MiniMaxUsageSummary) -> [KPI] { + let latestTitle: String = { + if let time = usage.lastUpdateTime?.trimmingCharacters(in: .whitespacesAndNewlines), !time.isEmpty { + return String(format: L("%@ usage"), time) + } + return L("Latest usage") + }() + return [ + KPI( + id: "latest", + title: latestTitle, + value: Self.minimaxTokenString(usage.latestSnapshotTokens)), + KPI( + id: "7d", + title: L("7d tokens"), + value: Self.minimaxTokenString(usage.last7DaysTokens)), + KPI( + id: "30d", + title: L("30d tokens"), + value: Self.minimaxTokenString(usage.last30DaysTokens)), + KPI( + id: "cache", + title: L("Cache hit"), + value: UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent)), + ] + } + + static func summaryKPIValues(usage: MiniMaxUsageSummary) -> [String] { + self.summaryKPIs(usage: usage).map(\.value) + } + + private static func footerLines(usage: MiniMaxUsageSummary) -> [String] { + [L("Language models only; data may be delayed")] + } + + private func detailContent(selectedDateKey: String?, model: Model) -> (primary: String, rows: [DetailRow]) { + guard let key = selectedDateKey, + let point = model.pointsByDateKey[key], + let date = Self.date(from: key) + else { + return (L("Hover a bar for details"), []) + } + + let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + let primary = Self.dayDetailPrimary(dayLabel: dayLabel, point: point, usage: self.usage) + return (primary, Self.detailRows(for: point, accentColor: model.barColor, usage: self.usage)) + } + + private static func windowSpendTitle(windowDays: WindowDays) -> String { + switch windowDays { + case .seven: L("7d spend") + case .thirty: L("30d spend") + } + } + + private static func dayDetailPrimary(dayLabel: String, point: Point, usage: MiniMaxUsageSummary) -> String { + let cacheText = UsageFormatter.optionalPercentString(point.cacheHitPercent) + var parts = [ + "\(dayLabel): \(Self.minimaxTokenString(point.totalTokens)) \(L("tokens"))", + String(format: L("Cache hit: %@"), cacheText), + ] + if let costUSD = usage.projectedCostUSD(for: point.day) { + parts.append(UsageFormatter.usdString(costUSD)) + } + return parts.joined(separator: " · ") + } + + private static func makeModel(usage: MiniMaxUsageSummary, windowDays: Int) -> Model { + let sorted = usage.trendDays(last: windowDays).compactMap { day -> Point? in + guard let date = Self.date(from: day.date), day.totalToken >= 0 else { return nil } + return Point( + id: day.date, + dateKey: day.date, + date: date, + totalTokens: day.totalToken, + cacheHitPercent: day.cacheHitPercent, + day: day) + } + let axisDates: [Date] = { + guard let first = sorted.first?.date, let last = sorted.last?.date else { return [] } + if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } + return [first, last] + }() + let pointsByDateKey = Dictionary(uniqueKeysWithValues: sorted.map { ($0.dateKey, $0) }) + let brand = ProviderDescriptorRegistry.descriptor(for: .minimax).branding.color + return Model( + points: sorted, + pointsByDateKey: pointsByDateKey, + dateKeys: sorted.map { ($0.dateKey, $0.date) }, + axisDates: axisDates, + barColor: Color(red: brand.red, green: brand.green, blue: brand.blue)) + } + + private static func date(from key: String) -> Date? { + let parts = key.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) else { return nil } + var comps = DateComponents() + comps.calendar = Calendar.current + comps.timeZone = TimeZone.current + comps.year = year + comps.month = month + comps.day = day + comps.hour = 12 + return comps.date + } + + private static func detailRows( + for point: Point, + accentColor: Color, + usage: MiniMaxUsageSummary) -> [DetailRow] + { + let breakdownByModel = Dictionary( + usage.projectedModelBreakdowns(for: point.day).map { ($0.modelName, $0) }, + uniquingKeysWith: { first, _ in first }) + if !point.day.models.isEmpty { + return point.day.models + .sorted { lhs, rhs in + if lhs.totalToken == rhs.totalToken { return lhs.model < rhs.model } + return lhs.totalToken > rhs.totalToken + } + .enumerated() + .map { index, model in + let breakdown = breakdownByModel[model.model] + let subtitle = breakdown.flatMap { + UsageFormatter.modelCostDetail( + $0.modelName, + costUSD: $0.costUSD, + totalTokens: $0.totalTokens) + } ?? Self.modelTokenSubtitle(model) + return DetailRow( + id: "\(model.model)#\(index)", + title: Self.shortModelName(model.model), + subtitle: subtitle, + modeSubtitle: + "\(L("cache-hit input")): \(Self.minimaxTokenString(model.cacheReadToken)) · " + + "\(L("output")): \(Self.minimaxTokenString(model.outputToken))", + accentColor: accentColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + } + + let cacheMiss = max(0, point.day.totalInputToken - point.day.totalCacheReadToken) + let breakdown = breakdownByModel["Day totals"] + let subtitle = breakdown.flatMap { + UsageFormatter.modelCostDetail( + $0.modelName, + costUSD: $0.costUSD, + totalTokens: $0.totalTokens) + } ?? + "\(L("cache-hit input")): \(Self.minimaxTokenString(point.day.totalCacheReadToken)) · " + + "\(L("cache-miss input")): \(Self.minimaxTokenString(cacheMiss))" + return [ + DetailRow( + id: "summary", + title: L("Day totals"), + subtitle: subtitle, + modeSubtitle: + "\(L("output")): \(Self.minimaxTokenString(point.day.totalOutputToken)) · " + + "\(L("Caches")): \(Self.minimaxTokenString(point.day.totalCacheCreateToken))", + accentColor: accentColor), + ] + } + + private static func modelTokenSubtitle(_ model: MiniMaxUsageSummaryModel) -> String { + let cacheText = UsageFormatter.optionalPercentString(model.cacheHitPercent) + return "\(Self.minimaxTokenString(model.totalToken)) \(L("tokens")) · " + + String(format: L("Cache hit: %@"), cacheText) + } + + private static func breakdownAccentOpacity(for index: Int) -> Double { + max(0.35, 1.0 - (Double(index) * 0.15)) + } + + private static func shortModelName(_ name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 26 else { return trimmed } + return String(trimmed.prefix(25)) + "…" + } + + private static func minimaxTokenString(_ value: Int) -> String { + UsageFormatter.tokenCountString(value, fractionDigits: 2) + } + + private func selectionBandRect( + selectedDateKey: String?, + model: Model, + proxy: ChartProxy, + geo: GeometryProxy) -> CGRect? + { + guard let key = selectedDateKey, + let index = model.dateKeys.firstIndex(where: { $0.key == key }), + let plotAnchor = proxy.plotFrame + else { return nil } + let plotFrame = geo[plotAnchor] + let date = model.dateKeys[index].date + guard let x = proxy.position(forX: date) else { return nil } + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20) + let slotWidth = abs(nextDayX - x) + let halfWidth = slotWidth * 0.25 + 2 + return CGRect( + x: plotFrame.origin.x + x - halfWidth, + y: plotFrame.origin.y, + width: halfWidth * 2, + height: plotFrame.height) + } + + private func updateSelection( + location: CGPoint?, + model: Model, + proxy: ChartProxy, + geo: GeometryProxy) + { + guard let location, + let plotAnchor = proxy.plotFrame + else { return } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { return } + let xInPlot = location.x - plotFrame.origin.x + guard let date: Date = proxy.value(atX: xInPlot) else { return } + guard let nearest = self.nearestDateKey(to: date, model: model) else { return } + + if self.selectedDateKey != nearest { + self.selectedDateKey = nearest + self.notifyHeightChange(model: model) + } + } + + private func nearestDateKey(to date: Date, model: Model) -> String? { + model.dateKeys.min(by: { + abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) + })?.key + } +} + +extension MiniMaxUsageSummaryChartMenuView { + static func _dayDetailPrimaryForTesting(usage: MiniMaxUsageSummary, dateKey: String) -> String? { + guard let day = usage.days.first(where: { $0.date == dateKey }), + let date = Self.date(from: dateKey) + else { + return nil + } + let point = Point( + id: day.date, + dateKey: day.date, + date: date, + totalTokens: day.totalToken, + cacheHitPercent: day.cacheHitPercent, + day: day) + let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + return Self.dayDetailPrimary(dayLabel: dayLabel, point: point, usage: usage) + } + + static func _detailRowCountForTesting(usage: MiniMaxUsageSummary, dateKey: String) -> Int { + let view = Self(usage: usage, width: 320) + let model = Self.makeModel(usage: usage, windowDays: 30) + return view.detailContent(selectedDateKey: dateKey, model: model).rows.count + } + + static func _detailViewportHeightForTesting(modeSubtitlePresence: [Bool]) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.detailRowsViewportHeight(rows: rows) + } + + static func _detailBlockHeightForTesting(modeSubtitlePresence: [Bool]) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.detailBlockHeight(rows: rows) + } + + static func _totalCardHeightForTesting(modeSubtitlePresence: [Bool], hasChart: Bool) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.totalCardHeight(rows: rows, hasChart: hasChart) + } +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 788e792f41..f9177cb73b 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(النظام)"; +"7d" = "7 يومًا"; "30d" = "30 يومًا"; "A managed Codex login is already running. Wait for it to finish before adding " = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة "; "API key" = "مفتاح API"; @@ -53,6 +54,26 @@ "CLI paths" = "CLI المسارات"; "CLI sessions" = "جلسات CLI"; "Caches" = "التخزين المؤقت"; +"Cache hit: %@" = "معدل إصابة التخزين المؤقت: %@"; +"Cache hit" = "إصابة التخزين المؤقت"; +"%@ usage" = "استخدام %@"; +"Latest usage" = "أحدث استخدام"; +"7d tokens" = "استخدام 7 أيام"; +"Last 7 days: %@ tokens" = "آخر 7 أيام: %@ رمز"; +"Last 7 days" = "آخر 7 أيام"; +"Usage trend" = "اتجاه الاستخدام"; +"MiniMax 7 day token usage trend" = "اتجاه استخدام MiniMax لمدة 7 أيام"; +"Language models only; data may be delayed" = "نماذج اللغة فقط؛ قد تتأخر البيانات"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ إدخال · %@ إخراج · إصابة تخزين %@"; +"%@ input · %@ output · %@ cache hit" = "%@ إدخال · %@ إخراج · إصابة تخزين %@"; +"Total consumed" = "إجمالي الاستهلاك"; +"Daily peak" = "ذروة اليوم"; +"Active days" = "أيام النشاط"; +"Active days: %@" = "أيام النشاط: %@"; +"Streak: %@ days" = "سلسلة: %@ يومًا"; +"Usage rank: top %@%%" = "ترتيب الاستخدام: أفضل %@%%"; +"Token usage details" = "تفاصيل استخدام الرموز"; +"Day totals" = "إجمالي اليوم"; "Cancel" = "إلغاء"; "Check for Updates…" = "تحقق من التحديثات..."; "Check for updates automatically" = "تحقق تلقائيا من التحديثات"; @@ -124,6 +145,7 @@ "Enabled" = "مفعل"; "Error" = "خطأ"; "Error simulation" = "محاكاة الخطأ"; +"Expires: %@" = "تنتهي الصلاحية %@"; "Expose troubleshooting tools in the Debug tab." = "اعرض أدوات استكشاف الأخطاء في تبويب التصحيح."; "Failed" = "فشل"; "False" = "خطأ"; @@ -764,6 +786,14 @@ "Top method" = "الطريقة العليا"; "30d cash" = "30d نقدا"; "30d billing history from MiniMax web session" = "30d تاريخ الفوترة من جلسة الويب MiniMax"; +"MiniMax web session expired" = "انتهت صلاحية جلسة الويب MiniMax"; +"MiniMax web session account does not match" = "جلسة الويب MiniMax لا تطابق حساب API"; +"No MiniMax browser session found" = "لم يتم العثور على جلسة متصفح MiniMax"; +"MiniMax web data endpoints are unavailable" = "نقاط نهاية بيانات الويب MiniMax غير متاحة"; +"MiniMax web session unavailable" = "جلسة الويب MiniMax غير متاحة"; +"Re-import MiniMax session from browser" = "إعادة استيراد جلسة MiniMax من المتصفح"; +"Open MiniMax login page" = "فتح صفحة تسجيل الدخول MiniMax"; +"MiniMax web session: %@" = "جلسة الويب MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS قد تتأخر فوترة Cost Explorer."; "Rate limit: %d / %@" = "الحد الأقصى للسعر: %d / %@"; "Key remaining" = "المفتاح المتبقي"; @@ -928,6 +958,7 @@ "Weekly" = "الأسبوعي"; "not detected" = "لم يتم اكتشافه"; "Estimated from local Codex logs for the selected account." = "تم التقدير من سجلات Codex المحلية للحساب المختار."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "مُقدَّر من ملخص استخدام MiniMax وأسعار الدفع حسب الاستخدام المنشورة · قد يختلف عن الفاتورة"; "minimax_usage_amount_format" = "الاستخدام: %@ / %@"; "minimax_used_percent_format" = "المستخدم %@"; "minimax_service_text_generation" = "توليد النصوص"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 54770860a1..63d17b1bb5 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " proveïdors"; "(System)" = "(Sistema)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espera que acabi abans d'afegir "; "API key" = "Clau d'API"; @@ -1030,6 +1031,7 @@ "Est. total (%@): %@" = "Total estimat (%@): %@"; "Est. total (30d): %@" = "Total estimat (30 d): %@"; "Estimated from local Codex logs for the selected account." = "Estimat a partir dels registres locals de Codex per al compte seleccionat."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimat a partir del resum d'ús de MiniMax i els preus publicats de pagament per ús · pot diferir de la factura"; "Google accounts" = "Comptes de Google"; "Google OAuth" = "Google OAuth"; "Hourly Tokens" = "Tokens per hora"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 1750dbb461..8db519b40c 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "Anbieter"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Eine verwaltete Codex-Anmeldung läuft bereits. Warten Sie, bis der Vorgang abgeschlossen ist, bevor Sie ihn hinzufügen"; "API key" = "API-Schlüssel"; @@ -921,6 +922,7 @@ "Weekly" = "Wöchentlich"; "not detected" = "nicht erkannt"; "Estimated from local Codex logs for the selected account." = "Geschätzt aus lokalen Codex-Protokollen für das ausgewählte Konto."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Geschätzt anhand der MiniMax-Nutzungsübersicht und veröffentlichter Pay-as-you-go-Preise · kann von der Rechnung abweichen"; "minimax_usage_amount_format" = "Verwendung: %@ / %@"; "minimax_used_percent_format" = "Verbraucht %@"; "minimax_service_text_generation" = "Textgenerierung"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 4b5b472f22..062037dfaa 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; "API key" = "API key"; @@ -53,6 +54,26 @@ "CLI paths" = "CLI paths"; "CLI sessions" = "CLI sessions"; "Caches" = "Caches"; +"Cache hit: %@" = "Cache hit: %@"; +"Cache hit" = "Cache hit"; +"%@ usage" = "%@ usage"; +"Latest usage" = "Latest usage"; +"7d tokens" = "7d tokens"; +"Last 7 days: %@ tokens" = "Last 7 days: %@ tokens"; +"Last 7 days" = "Last 7 days"; +"Usage trend" = "Usage trend"; +"MiniMax 7 day token usage trend" = "MiniMax 7 day token usage trend"; +"Language models only; data may be delayed" = "Language models only; data may be delayed"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ cache hit"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ cache hit"; +"Total consumed" = "Total consumed"; +"Daily peak" = "Daily peak"; +"Active days" = "Active days"; +"Active days: %@" = "Active days: %@"; +"Streak: %@ days" = "Streak: %@ days"; +"Usage rank: top %@%%" = "Usage rank: top %@%%"; +"Token usage details" = "Token usage details"; +"Day totals" = "Day totals"; "Cancel" = "Cancel"; "Check for Updates…" = "Check for Updates…"; "Check for updates automatically" = "Check for updates automatically"; @@ -124,6 +145,7 @@ "Enabled" = "Enabled"; "Error" = "Error"; "Error simulation" = "Error simulation"; +"Expires: %@" = "Expires: %@"; "Expose troubleshooting tools in the Debug tab." = "Expose troubleshooting tools in the Debug tab."; "Failed" = "Failed"; "False" = "False"; @@ -764,6 +786,14 @@ "Top method" = "Top method"; "30d cash" = "30d cash"; "30d billing history from MiniMax web session" = "30d billing history from MiniMax web session"; +"MiniMax web session expired" = "MiniMax web session expired"; +"MiniMax web session account does not match" = "MiniMax web session account does not match"; +"No MiniMax browser session found" = "No MiniMax browser session found"; +"MiniMax web data endpoints are unavailable" = "MiniMax web data endpoints are unavailable"; +"MiniMax web session unavailable" = "MiniMax web session unavailable"; +"Re-import MiniMax session from browser" = "Re-import MiniMax session from browser"; +"Open MiniMax login page" = "Open MiniMax login page"; +"MiniMax web session: %@" = "MiniMax web session: %@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer billing can lag."; "Rate limit: %d / %@" = "Rate limit: %d / %@"; "Key remaining" = "Key remaining"; @@ -929,6 +959,7 @@ "Weekly" = "Weekly"; "not detected" = "not detected"; "Estimated from local Codex logs for the selected account." = "Estimated from local Codex logs for the selected account."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimated from MiniMax usage summary and published pay-as-you-go pricing."; "minimax_usage_amount_format" = "Usage: %@ / %@"; "minimax_used_percent_format" = "Used %@"; "minimax_service_text_generation" = "Text Generation"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 6df1177e20..3cb9b9d1c0 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " proveedores"; "(System)" = "(Sistema)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir "; "API key" = "Clave de API"; @@ -677,6 +678,7 @@ /* Cost estimation */ "cost_header_estimated" = "Coste (estimado)"; "cost_estimate_hint" = "Estimado a partir de registros locales · puede diferir de tu factura"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimado a partir del resumen de uso de MiniMax y los precios publicados de pago por uso · puede diferir de la factura"; /* Popup panels */ "No usage configured." = "No hay uso configurado."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index e62f320240..486e7c3032 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(سیستم)"; +"7d" = "7 روز"; "30d" = "30 روز"; "A managed Codex login is already running. Wait for it to finish before adding " = "یک ورود Codex مدیریت شده هم اکنون در حال اجرا است. صبر کنید تا تمام شود و بعد را اضافه کنید"; "API key" = "کلید API"; @@ -53,6 +54,26 @@ "CLI paths" = "مسیرهای CLI"; "CLI sessions" = "جلسات CLI"; "Caches" = "کش ها"; +"Cache hit: %@" = "نرخ اصابت کش: %@"; +"Cache hit" = "اصابت کش"; +"%@ usage" = "مصرف %@"; +"Latest usage" = "آخرین مصرف"; +"7d tokens" = "مصرف ۷ روز"; +"Last 7 days: %@ tokens" = "۷ روز گذشته: %@ توکن"; +"Last 7 days" = "۷ روز گذشته"; +"Usage trend" = "روند مصرف"; +"MiniMax 7 day token usage trend" = "روند ۷ روزه توکن MiniMax"; +"Language models only; data may be delayed" = "فقط مدل‌های زبانی؛ داده ممکن است با تأخیر باشد"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ ورودی · %@ خروجی · اصابت کش %@"; +"%@ input · %@ output · %@ cache hit" = "%@ ورودی · %@ خروجی · اصابت کش %@"; +"Total consumed" = "کل مصرف"; +"Daily peak" = "اوج روزانه"; +"Active days" = "روزهای فعال"; +"Active days: %@" = "روزهای فعال: %@"; +"Streak: %@ days" = "پیوستگی: %@ روز"; +"Usage rank: top %@%%" = "رتبه مصرف: %@%% برتر"; +"Token usage details" = "جزئیات مصرف توکن"; +"Day totals" = "جمع روز"; "Cancel" = "لغو"; "Check for Updates…" = "به روزرسانی ها را بررسی کنید..."; "Check for updates automatically" = "به طور خودکار به روزرسانی ها را بررسی کنید"; @@ -124,6 +145,7 @@ "Enabled" = "فعال"; "Error" = "خطا"; "Error simulation" = "شبیه سازی خطا"; +"Expires: %@" = "انقضا: %@"; "Expose troubleshooting tools in the Debug tab." = "ابزارهای عیب یابی را در تب اشکال زدایی آشکار کنید."; "Failed" = "شکست خورد"; "False" = "نادرست"; @@ -764,6 +786,14 @@ "Top method" = "روش تاپ"; "30d cash" = "30d پول نقد"; "30d billing history from MiniMax web session" = "تاریخچه صورتحساب 30d از جلسه وب MiniMax"; +"MiniMax web session expired" = "جلسه وب MiniMax منقضی شده است"; +"MiniMax web session account does not match" = "جلسه وب MiniMax با حساب API مطابقت ندارد"; +"No MiniMax browser session found" = "جلسه مرورگر MiniMax یافت نشد"; +"MiniMax web data endpoints are unavailable" = "نقاط پایانی داده وب MiniMax در دسترس نیستند"; +"MiniMax web session unavailable" = "جلسه وب MiniMax در دسترس نیست"; +"Re-import MiniMax session from browser" = "وارد کردن مجدد جلسه MiniMax از مرورگر"; +"Open MiniMax login page" = "باز کردن صفحه ورود MiniMax"; +"MiniMax web session: %@" = "جلسه وب MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS صورتحساب اکسپلورر هزینه ممکن است کند شود."; "Rate limit: %d / %@" = "محدودیت نرخ: %d / %@"; "Key remaining" = "کلید باقی مانده"; @@ -928,6 +958,7 @@ "Weekly" = "هفتگی"; "not detected" = "شناسایی نشد"; "Estimated from local Codex logs for the selected account." = "برآورد شده از لاگ های محلی Codex حساب انتخاب شده."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "برآورد شده از خلاصه مصرف MiniMax و قیمت‌گذاری رسمی pay-as-you-go · ممکن است با صورتحساب متفاوت باشد"; "minimax_usage_amount_format" = "استفاده: %@ / %@"; "minimax_used_percent_format" = "%@ استفاده شده"; "minimax_service_text_generation" = "تولید متن"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 5dfecb3073..2911d5b577 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " fournisseurs"; "(System)" = "(System)"; +"7d" = "7j"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez qu'il soit terminé avant d'ajouter"; "API key" = "Clé API"; @@ -923,6 +924,7 @@ "Weekly" = "Hebdomadaire"; "not detected" = "non détecté"; "Estimated from local Codex logs for the selected account." = "Estimé à partir des journaux Codex locaux pour le compte sélectionné."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimé à partir du résumé d'utilisation MiniMax et des tarifs pay-as-you-go publiés · peut différer de la facture"; "minimax_usage_amount_format" = "Utilisation : %@ / %@"; "minimax_used_percent_format" = "Utilisé %@"; "minimax_service_text_generation" = "Génération de texte"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index c716d497ee..54e44372e1 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1149,3 +1149,34 @@ "usage_percent_suffix_used" = "usado"; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Non se atopou o token da API de z.ai. Define apiKey en ~/.codexbar/config.json ou Z_AI_API_KEY."; "≈ %d%% run-out risk" = "≈ %d%% de risco de esgotamento"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ cache hit"; +"%@ usage" = "%@ usage"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ cache hit"; +"7d" = "7d"; +"7d tokens" = "7d tokens"; +"Active days" = "Active days"; +"Active days: %@" = "Active days: %@"; +"Cache hit" = "Cache hit"; +"Cache hit: %@" = "Cache hit: %@"; +"Daily peak" = "Daily peak"; +"Day totals" = "Day totals"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimated from MiniMax usage summary and published pay-as-you-go pricing."; +"Expires: %@" = "Expires: %@"; +"Language models only; data may be delayed" = "Language models only; data may be delayed"; +"Last 7 days" = "Last 7 days"; +"Last 7 days: %@ tokens" = "Last 7 days: %@ tokens"; +"Latest usage" = "Latest usage"; +"MiniMax 7 day token usage trend" = "MiniMax 7 day token usage trend"; +"MiniMax web data endpoints are unavailable" = "MiniMax web data endpoints are unavailable"; +"MiniMax web session account does not match" = "MiniMax web session account does not match"; +"MiniMax web session expired" = "MiniMax web session expired"; +"MiniMax web session unavailable" = "MiniMax web session unavailable"; +"MiniMax web session: %@" = "MiniMax web session: %@"; +"No MiniMax browser session found" = "No MiniMax browser session found"; +"Open MiniMax login page" = "Open MiniMax login page"; +"Re-import MiniMax session from browser" = "Re-import MiniMax session from browser"; +"Streak: %@ days" = "Streak: %@ days"; +"Token usage details" = "Token usage details"; +"Total consumed" = "Total consumed"; +"Usage rank: top %@%%" = "Usage rank: top %@%%"; +"Usage trend" = "Usage trend"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 368a6480d3..00147f6cf0 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " penyedia"; "(System)" = "(Sistem)"; +"7d" = "7 hari"; "30d" = "30 hari"; "A managed Codex login is already running. Wait for it to finish before adding " = "Login Codex terkelola sudah berjalan. Tunggu hingga selesai sebelum menambahkan "; "API key" = "Kunci API"; @@ -53,6 +54,26 @@ "CLI paths" = "Path CLI"; "CLI sessions" = "Sesi CLI"; "Caches" = "Cache"; +"Cache hit: %@" = "Hit cache: %@"; +"Cache hit" = "Hit cache"; +"%@ usage" = "Penggunaan %@"; +"Latest usage" = "Penggunaan terbaru"; +"7d tokens" = "Token 7 hari"; +"Last 7 days: %@ tokens" = "7 hari terakhir: %@ token"; +"Last 7 days" = "7 hari terakhir"; +"Usage trend" = "Tren penggunaan"; +"MiniMax 7 day token usage trend" = "Tren penggunaan token MiniMax 7 hari"; +"Language models only; data may be delayed" = "Hanya model bahasa; data mungkin tertunda"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · hit cache %@"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · hit cache %@"; +"Total consumed" = "Total dikonsumsi"; +"Daily peak" = "Puncak harian"; +"Active days" = "Hari aktif"; +"Active days: %@" = "Hari aktif: %@"; +"Streak: %@ days" = "Beruntun: %@ hari"; +"Usage rank: top %@%%" = "Peringkat penggunaan: %@%% teratas"; +"Token usage details" = "Detail penggunaan token"; +"Day totals" = "Total harian"; "Cancel" = "Batal"; "Check for Updates…" = "Periksa Pembaruan…"; "Check for updates automatically" = "Periksa pembaruan secara otomatis"; @@ -124,6 +145,7 @@ "Enabled" = "Aktif"; "Error" = "Error"; "Error simulation" = "Simulasi error"; +"Expires: %@" = "Berlaku hingga: %@"; "Expose troubleshooting tools in the Debug tab." = "Tampilkan alat pemecahan masalah di tab Debug."; "Failed" = "Gagal"; "False" = "Salah"; @@ -766,6 +788,14 @@ "Top method" = "Metode teratas"; "30d cash" = "Kas 30 hari"; "30d billing history from MiniMax web session" = "Riwayat tagihan 30 hari dari sesi web MiniMax"; +"MiniMax web session expired" = "Sesi web MiniMax kedaluwarsa"; +"MiniMax web session account does not match" = "Sesi web MiniMax tidak cocok dengan akun API"; +"No MiniMax browser session found" = "Tidak ada sesi browser MiniMax ditemukan"; +"MiniMax web data endpoints are unavailable" = "Endpoint data web MiniMax tidak tersedia"; +"MiniMax web session unavailable" = "Sesi web MiniMax tidak tersedia"; +"Re-import MiniMax session from browser" = "Impor ulang sesi MiniMax dari browser"; +"Open MiniMax login page" = "Buka halaman login MiniMax"; +"MiniMax web session: %@" = "Sesi web MiniMax: %@"; "AWS Cost Explorer billing can lag." = "Tagihan AWS Cost Explorer dapat tertunda."; "Rate limit: %d / %@" = "Batas rate: %d / %@"; "Key remaining" = "Sisa kunci"; @@ -930,6 +960,7 @@ "Weekly" = "Mingguan"; "not detected" = "tidak terdeteksi"; "Estimated from local Codex logs for the selected account." = "Diperkirakan dari log Codex lokal untuk akun yang dipilih."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Diperkirakan dari ringkasan penggunaan MiniMax dan harga pay-as-you-go yang dipublikasikan · dapat berbeda dari tagihan"; "minimax_usage_amount_format" = "Penggunaan: %@ / %@"; "minimax_used_percent_format" = "Terpakai %@"; "minimax_service_text_generation" = "Pembuatan Teks"; @@ -985,7 +1016,7 @@ "CodexBar could not save the current system account before switching." = "CodexBar tidak dapat menyimpan akun sistem saat ini sebelum beralih."; "CodexBar could not update managed account storage." = "CodexBar tidak dapat memperbarui penyimpanan akun terkelola."; "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar menemukan akun terkelola lain yang sudah menggunakan akun sistem saat ini. Selesaikan akun duplikat sebelum beralih."; -"CodexBar will ask macOS Keychain for \U201c%@\U201d so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk \U201c%@\U201d agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk “%@” agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk token OAuth Claude Code agar dapat mengambil penggunaan Claude Anda. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Amp Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Augment Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 32ca01b05b..06569ccf72 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " provider"; "(System)" = "(Sistema)"; +"7d" = "7 g"; "30d" = "30 g"; "A managed Codex login is already running. Wait for it to finish before adding " = "È già in corso un accesso gestito a Codex. Attendi che termini prima di aggiungere "; "API key" = "Chiave API"; @@ -53,6 +54,26 @@ "CLI paths" = "Percorsi CLI"; "CLI sessions" = "Sessioni CLI"; "Caches" = "Cache"; +"Cache hit: %@" = "Hit della cache: %@"; +"Cache hit" = "Hit della cache"; +"%@ usage" = "Utilizzo %@"; +"Latest usage" = "Ultimo utilizzo"; +"7d tokens" = "Token 7g"; +"Last 7 days: %@ tokens" = "Ultimi 7 giorni: %@ token"; +"Last 7 days" = "Ultimi 7 giorni"; +"Usage trend" = "Andamento utilizzo"; +"MiniMax 7 day token usage trend" = "Trend utilizzo token MiniMax 7 giorni"; +"Language models only; data may be delayed" = "Solo modelli linguistici; i dati possono essere in ritardo"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ hit cache"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ hit cache"; +"Total consumed" = "Totale consumato"; +"Daily peak" = "Picco giornaliero"; +"Active days" = "Giorni attivi"; +"Active days: %@" = "Giorni attivi: %@"; +"Streak: %@ days" = "Serie: %@ giorni"; +"Usage rank: top %@%%" = "Classifica utilizzo: top %@%%"; +"Token usage details" = "Dettagli utilizzo token"; +"Day totals" = "Totali giornalieri"; "Cancel" = "Annulla"; "Check for Updates…" = "Controlla aggiornamenti…"; "Check for updates automatically" = "Controlla automaticamente gli aggiornamenti"; @@ -124,6 +145,7 @@ "Enabled" = "Attivo"; "Error" = "Errore"; "Error simulation" = "Simulazione errore"; +"Expires: %@" = "Scade: %@"; "Expose troubleshooting tools in the Debug tab." = "Espone gli strumenti di risoluzione problemi nella scheda Debug."; "Failed" = "Fallito"; "False" = "Falso"; @@ -766,6 +788,14 @@ "Top method" = "Metodo principale"; "30d cash" = "Spesa 30 g"; "30d billing history from MiniMax web session" = "Cronologia di fatturazione degli ultimi 30 giorni dalla sessione web MiniMax"; +"MiniMax web session expired" = "Sessione web MiniMax scaduta"; +"MiniMax web session account does not match" = "La sessione web MiniMax non corrisponde all'account API"; +"No MiniMax browser session found" = "Nessuna sessione browser MiniMax trovata"; +"MiniMax web data endpoints are unavailable" = "Gli endpoint dati web MiniMax non sono disponibili"; +"MiniMax web session unavailable" = "Sessione web MiniMax non disponibile"; +"Re-import MiniMax session from browser" = "Reimporta la sessione MiniMax dal browser"; +"Open MiniMax login page" = "Apri la pagina di accesso MiniMax"; +"MiniMax web session: %@" = "Sessione web MiniMax: %@"; "AWS Cost Explorer billing can lag." = "La fatturazione di AWS Cost Explorer può avere ritardi."; "Rate limit: %d / %@" = "Limite di richiesta: %d / %@"; "Key remaining" = "Residuo chiave"; @@ -930,6 +960,7 @@ "Weekly" = "Settimanale"; "not detected" = "non rilevato"; "Estimated from local Codex logs for the selected account." = "Stima basata sui log locali di Codex per l'account selezionato."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Stimato dal riepilogo utilizzo MiniMax e dai prezzi pay-as-you-go pubblicati · può differire dalla fattura"; "minimax_usage_amount_format" = "Utilizzo: %@ / %@"; "minimax_used_percent_format" = "Usato %@"; "minimax_service_text_generation" = "Generazione testo"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 44b6d8e5ea..635755fa4a 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 件のプロバイダ"; "(System)" = "(システム)"; +"7d" = "7日"; "30d" = "30日"; "A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; "API key" = "API キー"; @@ -920,6 +921,7 @@ "Weekly" = "週間"; "not detected" = "未検出"; "Estimated from local Codex logs for the selected account." = "選択したアカウントのローカル Codex ログから推定しています。"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "用量サマリーと公式従量課金料金から推定 · 請求額と異なる場合があります"; "minimax_usage_amount_format" = "使用量: %@ / %@"; "minimax_used_percent_format" = "使用済み %@"; "minimax_service_text_generation" = "テキスト生成"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 1e603e1cd6..8e64541f70 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 공급자"; "(System)" = "(시스템)"; +"7d" = "7일"; "30d" = "30일"; "A managed Codex login is already running. Wait for it to finish before adding " = "관리되는 Codex 로그인이 이미 실행 중입니다. 추가하기 전에 완료될 때까지 기다리세요. "; "API key" = "API 키"; @@ -890,6 +891,7 @@ "Weekly" = "주간"; "not detected" = "감지되지 않음"; "Estimated from local Codex logs for the selected account." = "선택한 계정의 로컬 Codex 로그를 기반으로 추정됨."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "사용 요약 및 공식 종량제 요금 기준 추정 · 청구서와 다를 수 있음"; "minimax_usage_amount_format" = "사용량: %@ / %@"; "minimax_used_percent_format" = "%@ 사용됨"; "minimax_service_text_generation" = "텍스트 생성"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index fb53ca9c9e..88fb3f5291 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(Systeem)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat je het toevoegt"; "API key" = "API-sleutel"; @@ -923,6 +924,7 @@ "Weekly" = "Wekelijks"; "not detected" = "niet gedetecteerd"; "Estimated from local Codex logs for the selected account." = "Geschat op basis van lokale Codex-logboeken voor het geselecteerde account."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Geschat op basis van het MiniMax-gebruiksoverzicht en gepubliceerde pay-as-you-go-tarieven · kan afwijken van de factuur"; "minimax_usage_amount_format" = "Gebruik: %@ / %@"; "minimax_used_percent_format" = "Gebruikt %@"; "minimax_service_text_generation" = "Tekst genereren"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 09c348868d..15eee86e60 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Trwa już zarządzane logowanie Codex. Poczekaj na jego zakończenie, zanim dodasz "; "API key" = "Klucz API"; @@ -53,6 +54,26 @@ "CLI paths" = "Ścieżki CLI"; "CLI sessions" = "Sesje CLI"; "Caches" = "Pamięci podręczne"; +"Cache hit: %@" = "Trafienia cache: %@"; +"Cache hit" = "Trafienia cache"; +"%@ usage" = "Użycie %@"; +"Latest usage" = "Ostatnie użycie"; +"7d tokens" = "Tokeny 7d"; +"Last 7 days: %@ tokens" = "Ostatnie 7 dni: %@ tokenów"; +"Last 7 days" = "Ostatnie 7 dni"; +"Usage trend" = "Trend użycia"; +"MiniMax 7 day token usage trend" = "Trend użycia tokenów MiniMax (7 dni)"; +"Language models only; data may be delayed" = "Tylko modele językowe; dane mogą być opóźnione"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ wejście · %@ wyjście · %@ trafień cache"; +"%@ input · %@ output · %@ cache hit" = "%@ wejście · %@ wyjście · %@ trafień cache"; +"Total consumed" = "Łącznie zużyto"; +"Daily peak" = "Dzienny szczyt"; +"Active days" = "Aktywne dni"; +"Active days: %@" = "Aktywne dni: %@"; +"Streak: %@ days" = "Seria: %@ dni"; +"Usage rank: top %@%%" = "Ranking użycia: top %@%%"; +"Token usage details" = "Szczegóły użycia tokenów"; +"Day totals" = "Sumy dzienne"; "Cancel" = "Anuluj"; "Check for Updates…" = "Sprawdź aktualizacje…"; "Check for updates automatically" = "Sprawdzaj aktualizacje automatycznie"; @@ -124,6 +145,7 @@ "Enabled" = "Włączone"; "Error" = "Błąd"; "Error simulation" = "Symulacja błędu"; +"Expires: %@" = "Wygasa: %@"; "Expose troubleshooting tools in the Debug tab." = "Udostępnia narzędzia diagnostyczne na karcie Debug."; "Failed" = "Niepowodzenie"; "False" = "Fałsz"; @@ -766,6 +788,14 @@ "Top method" = "Najlepsza metoda"; "30d cash" = "Gotówka 30 dni"; "30d billing history from MiniMax web session" = "30-dniowa historia rozliczeń z sesji webowej MiniMax"; +"MiniMax web session expired" = "Sesja webowa MiniMax wygasła"; +"MiniMax web session account does not match" = "Sesja webowa MiniMax nie pasuje do konta API"; +"No MiniMax browser session found" = "Nie znaleziono sesji przeglądarki MiniMax"; +"MiniMax web data endpoints are unavailable" = "Punkty końcowe danych webowych MiniMax są niedostępne"; +"MiniMax web session unavailable" = "Sesja webowa MiniMax jest niedostępna"; +"Re-import MiniMax session from browser" = "Ponownie zaimportuj sesję MiniMax z przeglądarki"; +"Open MiniMax login page" = "Otwórz stronę logowania MiniMax"; +"MiniMax web session: %@" = "Sesja webowa MiniMax: %@"; "AWS Cost Explorer billing can lag." = "Dane rozliczeń AWS Cost Explorer mogą być opóźnione."; "Rate limit: %d / %@" = "Limit żądań: %d / %@"; "Key remaining" = "Pozostało klucza"; @@ -930,6 +960,7 @@ "Weekly" = "Tydzień"; "not detected" = "nie wykryto"; "Estimated from local Codex logs for the selected account." = "Oszacowano na podstawie lokalnych logów Codex dla wybranego konta."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Oszacowano na podstawie podsumowania użycia MiniMax i opublikowanych cen pay-as-you-go · może różnić się od rachunku"; "minimax_usage_amount_format" = "Użycie: %@ / %@"; "minimax_used_percent_format" = "Wykorzystano %@"; "minimax_service_text_generation" = "Generowanie tekstu"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 73c4a73438..e4dd18657d 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar "; "API key" = "Chave de API"; @@ -920,6 +921,7 @@ "Weekly" = "Semanal"; "not detected" = "não detectado"; "Estimated from local Codex logs for the selected account." = "Estimado a partir de logs locais do Codex para a conta selecionada."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimado a partir do resumo de uso do MiniMax e dos preços publicados de pay-as-you-go · pode diferir da fatura"; "minimax_usage_amount_format" = "Uso: %@ / %@"; "minimax_used_percent_format" = "Usado %@"; "minimax_service_text_generation" = "Geração de texto"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 03428d64ac..6304616454 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " leverantörer"; "(System)" = "(System)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till "; "API key" = "API-nyckel"; @@ -848,6 +849,7 @@ "Weekly" = "Vecka"; "not detected" = "inte hittad"; "Estimated from local Codex logs for the selected account." = "Uppskattat från lokala Codex-loggar för valt konto."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Uppskattat från MiniMax-användningssammanfattning och publicerade pay-as-you-go-priser · kan skilja sig från fakturan"; "minimax_usage_amount_format" = "Användning: %@ / %@"; "minimax_used_percent_format" = "Förbrukat %@"; "minimax_service_text_generation" = "Textgenerering"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index f537c2d1b9..d3abe0ce91 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "ผู้ให้บริการ "; "(System)" = "(ระบบ)"; +"7d" = "7 วัน"; "30d" = "30 วัน"; "A managed Codex login is already running. Wait for it to finish before adding " = "การเข้าสู่ระบบ Codex ที่มีการจัดการกําลังทํางานอยู่แล้ว รอให้เสร็จก่อนที่จะเพิ่ม "; "API key" = "ปุ่ม API"; @@ -53,6 +54,26 @@ "CLI paths" = "เส้นทาง CLI"; "CLI sessions" = "CLI เซสชัน"; "Caches" = "แคช"; +"Cache hit: %@" = "อัตราแคชฮิต: %@"; +"Cache hit" = "แคชฮิต"; +"%@ usage" = "การใช้งาน %@"; +"Latest usage" = "การใช้งานล่าสุด"; +"7d tokens" = "การใช้งาน 7 วัน"; +"Last 7 days: %@ tokens" = "7 วันที่ผ่านมา: %@ โทเค็น"; +"Last 7 days" = "7 วันที่ผ่านมา"; +"Usage trend" = "แนวโน้มการใช้งาน"; +"MiniMax 7 day token usage trend" = "แนวโน้มโทเค็น MiniMax 7 วัน"; +"Language models only; data may be delayed" = "นับเฉพาะโมเดลภาษา ข้อมูลอาจล่าช้า"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ อินพุต · %@ เอาต์พุต · แคชฮิต %@"; +"%@ input · %@ output · %@ cache hit" = "%@ อินพุต · %@ เอาต์พุต · แคชฮิต %@"; +"Total consumed" = "ใช้ไปทั้งหมด"; +"Daily peak" = "สูงสุดรายวัน"; +"Active days" = "วันที่ใช้งาน"; +"Active days: %@" = "วันที่ใช้งาน: %@"; +"Streak: %@ days" = "ใช้ต่อเนื่อง: %@ วัน"; +"Usage rank: top %@%%" = "อันดับการใช้งาน: สูงสุด %@%%"; +"Token usage details" = "รายละเอียดการใช้โทเค็น"; +"Day totals" = "รวมรายวัน"; "Cancel" = "ยกเลิก"; "Check for Updates…" = "ตรวจสอบการอัปเดต..."; "Check for updates automatically" = "ตรวจสอบการอัปเดตโดยอัตโนมัติ"; @@ -124,6 +145,7 @@ "Enabled" = "เปิดใช้งาน"; "Error" = "ข้อผิดพลาด"; "Error simulation" = "การจําลองข้อผิดพลาด"; +"Expires: %@" = "หมดอายุ %@"; "Expose troubleshooting tools in the Debug tab." = "แสดงเครื่องมือการแก้ไขปัญหาในแท็บ แก้ไขข้อบกพร่อง"; "Failed" = "ล้มเหลว"; "False" = "เท็จ"; @@ -764,6 +786,14 @@ "Top method" = "วิธียอดนิยม"; "30d cash" = "30d เงินสด"; "30d billing history from MiniMax web session" = "30d ประวัติการเรียกเก็บเงินจากเซสชันเว็บ MiniMax"; +"MiniMax web session expired" = "เซสชันเว็บ MiniMax หมดอายุแล้ว"; +"MiniMax web session account does not match" = "เซสชันเว็บ MiniMax ไม่ตรงกับบัญชี API"; +"No MiniMax browser session found" = "ไม่พบเซสชันเบราว์เซอร์ MiniMax"; +"MiniMax web data endpoints are unavailable" = "เอ็นด์พอยต์ข้อมูลเว็บ MiniMax ไม่พร้อมใช้งาน"; +"MiniMax web session unavailable" = "เซสชันเว็บ MiniMax ไม่พร้อมใช้งาน"; +"Re-import MiniMax session from browser" = "นำเข้าเซสชัน MiniMax จากเบราว์เซอร์อีกครั้ง"; +"Open MiniMax login page" = "เปิดหน้าเข้าสู่ระบบ MiniMax"; +"MiniMax web session: %@" = "เซสชันเว็บ MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS การเรียกเก็บเงิน Cost Explorer อาจล่าช้า"; "Rate limit: %d / %@" = "จํากัดอัตรา: %d / %@"; "Key remaining" = "กุญแจที่เหลืออยู่"; @@ -928,6 +958,7 @@ "Weekly" = "รายสัปดาห์"; "not detected" = "ตรวจไม่พบ"; "Estimated from local Codex logs for the selected account." = "ประมาณการจากบันทึก Codex ท้องถิ่นสําหรับบัญชีที่เลือก"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "ประมาณการจากสรุปการใช้งาน MiniMax และราคาแบบจ่ายตามการใช้งานที่เผยแพร่ · อาจแตกต่างจากใบแจ้งหนี้"; "minimax_usage_amount_format" = "การใช้งาน: %@ / %@"; "minimax_used_percent_format" = "ใช้ไป %@"; "minimax_service_text_generation" = "การสร้างข้อความ"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 308b2740a1..27e986d511 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " sağlayıcı"; "(System)" = "(Sistem)"; +"7d" = "7 gün"; "30d" = "30 gün"; "A managed Codex login is already running. Wait for it to finish before adding " = "Yönetilen bir Codex girişi zaten çalışıyor. Eklemeden önce bitmesini bekleyin "; "API key" = "API anahtarı"; @@ -53,6 +54,26 @@ "CLI paths" = "CLI yolları"; "CLI sessions" = "CLI oturumları"; "Caches" = "Önbellekler"; +"Cache hit: %@" = "Önbellek isabeti: %@"; +"Cache hit" = "Önbellek isabeti"; +"%@ usage" = "%@ kullanımı"; +"Latest usage" = "Son kullanım"; +"7d tokens" = "7g token"; +"Last 7 days: %@ tokens" = "Son 7 gün: %@ token"; +"Last 7 days" = "Son 7 gün"; +"Usage trend" = "Kullanım trendi"; +"MiniMax 7 day token usage trend" = "MiniMax 7 günlük token kullanım trendi"; +"Language models only; data may be delayed" = "Yalnızca dil modelleri; veriler gecikebilir"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ girdi · %@ çıktı · %@ önbellek isabeti"; +"%@ input · %@ output · %@ cache hit" = "%@ girdi · %@ çıktı · %@ önbellek isabeti"; +"Total consumed" = "Toplam tüketim"; +"Daily peak" = "Günlük zirve"; +"Active days" = "Aktif günler"; +"Active days: %@" = "Aktif günler: %@"; +"Streak: %@ days" = "Seri: %@ gün"; +"Usage rank: top %@%%" = "Kullanım sırası: ilk %@%%"; +"Token usage details" = "Token kullanım detayları"; +"Day totals" = "Günlük toplamlar"; "Cancel" = "İptal"; "Check for Updates…" = "Güncellemeleri Denetle…"; "Check for updates automatically" = "Güncellemeleri otomatik denetle"; @@ -124,6 +145,7 @@ "Enabled" = "Etkin"; "Error" = "Hata"; "Error simulation" = "Hata simülasyonu"; +"Expires: %@" = "Sona erer: %@"; "Expose troubleshooting tools in the Debug tab." = "Sorun giderme araçlarını Hata Ayıklama sekmesinde göster."; "Failed" = "Başarısız"; "False" = "Yanlış"; @@ -762,6 +784,14 @@ "Top method" = "En çok kullanılan yöntem"; "30d cash" = "30 günlük nakit"; "30d billing history from MiniMax web session" = "MiniMax web oturumundan 30 günlük faturalandırma geçmişi"; +"MiniMax web session expired" = "MiniMax web oturumu süresi doldu"; +"MiniMax web session account does not match" = "MiniMax web oturumu API hesabıyla eşleşmiyor"; +"No MiniMax browser session found" = "MiniMax tarayıcı oturumu bulunamadı"; +"MiniMax web data endpoints are unavailable" = "MiniMax web veri uç noktaları kullanılamıyor"; +"MiniMax web session unavailable" = "MiniMax web oturumu kullanılamıyor"; +"Re-import MiniMax session from browser" = "MiniMax oturumunu tarayıcıdan yeniden içe aktar"; +"Open MiniMax login page" = "MiniMax giriş sayfasını aç"; +"MiniMax web session: %@" = "MiniMax web oturumu: %@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer faturalandırması gecikmeli olabilir."; "Rate limit: %d / %@" = "Hız limiti: %d / %@"; "Key remaining" = "Anahtar kalan"; @@ -925,6 +955,7 @@ "Weekly" = "Haftalık"; "not detected" = "algılanmadı"; "Estimated from local Codex logs for the selected account." = "Seçili hesap için yerel Codex günlüklerinden tahmin edildi."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "MiniMax kullanım özetinden ve yayımlanan kullandıkça öde fiyatlarından tahmin edildi · faturadan farklı olabilir"; "minimax_usage_amount_format" = "Kullanım: %@ / %@"; "minimax_used_percent_format" = "%@ kullanıldı"; "minimax_service_text_generation" = "Metin Üretimi"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index e0bf19ce91..dba9967229 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "провайдерів"; "(System)" = "(Система)"; +"7d" = "7д"; "30d" = "30д"; "A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; "API key" = "Ключ API"; @@ -923,6 +924,7 @@ "Weekly" = "Щотижня"; "not detected" = "не виявлено"; "Estimated from local Codex logs for the selected account." = "Оцінено з локальних журналів Codex для вибраного облікового запису."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Оцінено за підсумком використання MiniMax і опублікованими тарифами pay-as-you-go · може відрізнятися від рахунку"; "minimax_usage_amount_format" = "Використання: %@ / %@"; "minimax_used_percent_format" = "Використаний %@"; "minimax_service_text_generation" = "Генерація тексту"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index ecc7afed71..6433d84c40 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "nhà cung cấp"; "(System)" = "(Hệ thống)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình này hoàn tất trước khi thêm"; "API key" = "API khóa"; @@ -919,6 +920,7 @@ "Weekly" = "Không phát hiện được"; "not detected" = "hàng tuần"; "Estimated from local Codex logs for the selected account." = "Được ước tính từ nhật ký Codex cục bộ cho tài khoản đã chọn."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Ước tính từ tóm tắt sử dụng MiniMax và bảng giá trả theo mức dùng đã công bố · có thể khác hóa đơn"; "minimax_usage_amount_format" = "Mức sử dụng : %@ / %@"; "minimax_used_percent_format" = "Đã sử dụng %@"; "minimax_service_text_generation" = "Tạo văn bản"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 777c2f83b3..bcb1f3259b 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 提供商"; "(System)" = "(System)"; +"7d" = "7 天"; "30d" = "30 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; "API key" = "API 密钥"; @@ -55,6 +56,26 @@ "CLI paths" = "CLI 路径"; "CLI sessions" = "CLI 会话"; "Caches" = "缓存"; +"Cache hit: %@" = "缓存命中率:%@"; +"Cache hit" = "缓存命中"; +"%@ usage" = "%@ 调用量"; +"Latest usage" = "最新调用量"; +"7d tokens" = "近 7 天调用量"; +"Last 7 days: %@ tokens" = "近 7 天:%@ token"; +"Last 7 days" = "近 7 天"; +"Usage trend" = "调用趋势图"; +"MiniMax 7 day token usage trend" = "MiniMax 近 7 天 token 趋势"; +"Language models only; data may be delayed" = "当前仅统计语言模型,数据可能有延迟"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ 输入 · %@ 输出 · 缓存命中 %@"; +"%@ input · %@ output · %@ cache hit" = "%@ 输入 · %@ 输出 · 缓存命中 %@"; +"Total consumed" = "累计调用量"; +"Daily peak" = "单日峰值"; +"Active days" = "活跃天数"; +"Active days: %@" = "活跃天数:%@"; +"Streak: %@ days" = "连续使用:%@ 天"; +"Usage rank: top %@%%" = "用量排名:前 %@%%"; +"Token usage details" = "Token 用量详情"; +"Day totals" = "当日汇总"; "Cancel" = "取消"; "Check for Updates…" = "检查更新…"; "Check for updates automatically" = "自动检查更新"; @@ -125,6 +146,7 @@ "Enabled" = "已启用"; "Error" = "错误"; "Error simulation" = "错误模拟"; +"Expires: %@" = "有效期至 %@"; "Expose troubleshooting tools in the Debug tab." = "在“调试”标签中显示故障排除工具。"; "Failed" = "失败"; "False" = "假"; @@ -657,6 +679,7 @@ "cost_header_estimated" = "费用(估算)"; "cost_estimate_hint" = "根据本地日志估算 · 可能与账单不同"; "Estimated from local Codex logs for the selected account." = "根据所选账户的本地 Codex 日志估算。"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "根据用量详情与官方按量定价估算 · 可能与账单不同"; "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "未检测到启用 AI Assistant 的 JetBrains IDE。请安装 JetBrains IDE 并启用 AI Assistant。"; "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "未配置 OpenRouter API 令牌。请设置 OPENROUTER_API_KEY 环境变量,或在“设置”中配置。"; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "未找到 z.ai API 令牌。请在 ~/.codexbar/config.json 中设置 apiKey,或设置 Z_AI_API_KEY。"; @@ -737,6 +760,14 @@ "Top method" = "主要方法"; "30d cash" = "近 30 天费用"; "30d billing history from MiniMax web session" = "来自 MiniMax Web 会话的近 30 天账单历史"; +"MiniMax web session expired" = "MiniMax 网页会话已过期"; +"MiniMax web session account does not match" = "MiniMax 网页会话与 API 账号不匹配"; +"No MiniMax browser session found" = "未找到 MiniMax 浏览器会话"; +"MiniMax web data endpoints are unavailable" = "MiniMax 网页数据接口不可用"; +"MiniMax web session unavailable" = "MiniMax 网页会话不可用"; +"Re-import MiniMax session from browser" = "从浏览器重新导入 MiniMax 会话"; +"Open MiniMax login page" = "打开 MiniMax 登录页"; +"MiniMax web session: %@" = "MiniMax 网页会话:%@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 账单数据可能延迟。"; "Rate limit: %d / %@" = "速率限制:%d / %@"; "Key remaining" = "密钥剩余额度"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index a9c37f979e..277f30b82e 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 提供者"; "(System)" = "(系統)"; +"7d" = "7 天"; "30d" = "30 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; "API key" = "API 金鑰"; @@ -56,6 +57,26 @@ "CLI paths" = "CLI 路徑"; "CLI sessions" = "CLI 工作階段"; "Caches" = "快取"; +"Cache hit: %@" = "快取命中:%@"; +"Cache hit" = "快取命中"; +"%@ usage" = "%@ 使用量"; +"Latest usage" = "最新使用量"; +"7d tokens" = "近 7 天 token"; +"Last 7 days: %@ tokens" = "近 7 天:%@ token"; +"Last 7 days" = "近 7 天"; +"Usage trend" = "使用量趨勢"; +"MiniMax 7 day token usage trend" = "MiniMax 近 7 天 token 趨勢"; +"Language models only; data may be delayed" = "目前僅統計語言模型,資料可能會有延遲"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ 輸入 · %@ 輸出 · 快取命中 %@"; +"%@ input · %@ output · %@ cache hit" = "%@ 輸入 · %@ 輸出 · 快取命中 %@"; +"Total consumed" = "累計使用量"; +"Daily peak" = "單日峰值"; +"Active days" = "活躍天數"; +"Active days: %@" = "活躍天數:%@"; +"Streak: %@ days" = "連續使用:%@ 天"; +"Usage rank: top %@%%" = "用量排名:前 %@%%"; +"Token usage details" = "Token 用量詳情"; +"Day totals" = "當日彙總"; "Cancel" = "取消"; "Check for Updates…" = "檢查更新…"; "Check for updates automatically" = "自動檢查更新"; @@ -669,6 +690,7 @@ "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列 App。CodexBar 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 CodexBar。"; "cost_header_estimated" = "費用(估算)"; "cost_estimate_hint" = "根據本機記錄估算 · 可能與帳單不同"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "根據用量詳情與官方按量定價估算 · 可能與帳單不同"; "copilot_device_code" = "裝置碼已複製到剪貼簿:%1$@\n\n請到以下網址驗證:%2$@"; "copilot_waiting_text" = "請在瀏覽器中完成登入。\n登入完成後,此視窗會自動關閉。"; "vertex_ai_login_instructions" = "要追蹤 Vertex AI 使用量,請透過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 依照瀏覽器提示登入\n4. 設定你的專案:gcloud config set project PROJECT_ID\n\n要現在開啟終端嗎?"; @@ -774,6 +796,14 @@ "Top method" = "主要方法"; "30d cash" = "近 30 天費用"; "30d billing history from MiniMax web session" = "來自 MiniMax 網頁工作階段的 30 天帳單記錄"; +"MiniMax web session expired" = "MiniMax 網頁工作階段已過期"; +"MiniMax web session account does not match" = "MiniMax 網頁工作階段與 API 帳號不相符"; +"No MiniMax browser session found" = "找不到 MiniMax 瀏覽器工作階段"; +"MiniMax web data endpoints are unavailable" = "MiniMax 網頁資料端點無法使用"; +"MiniMax web session unavailable" = "MiniMax 網頁工作階段無法使用"; +"Re-import MiniMax session from browser" = "從瀏覽器重新匯入 MiniMax 工作階段"; +"Open MiniMax login page" = "開啟 MiniMax 登入頁面"; +"MiniMax web session: %@" = "MiniMax 網頁工作階段:%@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 帳單資料可能延遲。"; "Rate limit: %d / %@" = "速率限制:%d / %@"; "Key remaining" = "金鑰剩餘額度"; @@ -1015,7 +1045,6 @@ "minimax_service_coding_plan_search" = "Coding Plan 搜尋"; "Clearing removes old plan-mode files." = "會移除舊的 plan-mode 檔案。"; "%.0f%% %@" = "%2$@ %1$.0f%%"; -"<1%% %@" = "%1$@ <1%%"; "%@: %@%% used" = "%@:已使用 %@%%"; "terminal_app_subtitle" = "「開啟終端」動作使用的終端"; "Admin API key" = "Admin API 金鑰"; diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 6fce93f050..d0dbd7c1a0 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -272,6 +272,21 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { NSWorkspace.shared.open(url) } + @objc func reimportMiniMaxWebSession() { + CookieHeaderCache.clear(provider: .minimax) + Task { + await self.performStoreRefresh( + for: .minimax, + refreshOpenMenusWhenComplete: true, + interaction: .userInitiated) + } + } + + @objc func openMiniMaxLoginPage() { + guard let url = self.dashboardURL(for: .minimax) else { return } + NSWorkspace.shared.open(url) + } + func dashboardURL( for provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index ed17647d6d..bf75e27935 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -16,6 +16,7 @@ extension StatusItemController { Self.creditsHistoryChartID, Self.costHistoryChartID, Self.usageHistoryChartID, + Self.miniMaxUsageSummaryChartID, Self.storageBreakdownID, Self.statusComponentsID, Self.zaiHourlyUsageChartID, @@ -86,6 +87,14 @@ extension StatusItemController { } else { false } + case Self.miniMaxUsageSummaryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendMiniMaxUsageSummaryChartItem(to: menu, provider: provider, width: width) + } else { + false + } case Self.storageBreakdownID: if let providerRawValue = placeholder.toolTip, let provider = UsageProvider(rawValue: providerRawValue) @@ -160,6 +169,12 @@ extension StatusItemController { } else { false } + case Self.miniMaxUsageSummaryChartID: + if let provider = identity.provider { + self.appendMiniMaxUsageSummaryChartItem(to: menu, provider: provider, width: width) + } else { + false + } case Self.storageBreakdownID: if let provider = identity.provider { self.appendStorageBreakdownItem(to: menu, provider: provider, width: width) @@ -237,6 +252,8 @@ extension StatusItemController { identity.provider.map(self.costHistoryRenderSignature(for:)) ?? "missing-provider" case Self.usageHistoryChartID: identity.provider.map(self.usageHistoryRenderSignature(for:)) ?? "missing-provider" + case Self.miniMaxUsageSummaryChartID: + identity.provider.map(self.miniMaxUsageSummaryRenderSignature(for:)) ?? "missing-provider" case Self.storageBreakdownID: identity.provider.map(self.storageBreakdownRenderSignature(for:)) ?? "missing-provider" case Self.statusComponentsID: @@ -254,6 +271,25 @@ extension StatusItemController { ].joined(separator: "|") } + private func miniMaxUsageSummaryRenderSignature(for provider: UsageProvider) -> String { + guard provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary + else { + return "none" + } + let days = usage.days.suffix(30).map { day in + let models = day.models.map { + "\($0.model):\($0.inputToken):\($0.outputToken):\($0.cacheReadToken):\($0.totalToken)" + }.joined(separator: ";") + return "\(day.date):\(day.totalToken):\(day.cacheHitPercent ?? -1):\(models)" + }.joined(separator: "|") + return [ + usage.lastUpdateTime ?? "", + usage.dailyTokenUsage.suffix(30).map(String.init).joined(separator: ","), + days, + ].joined(separator: "|") + } + private func costHistoryRenderSignature(for provider: UsageProvider) -> String { guard let snapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return "none" } return [ diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift new file mode 100644 index 0000000000..5462a17219 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift @@ -0,0 +1,5 @@ +import AppKit + +extension StatusItemController { + static let miniMaxUsageSummaryChartID = "miniMaxUsageSummaryChart" +} diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift new file mode 100644 index 0000000000..e508256db8 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift @@ -0,0 +1,94 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + @discardableResult + func addMiniMaxUsageSummaryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { + guard let submenu = self.makeMiniMaxUsageSummarySubmenu(provider: provider, width: width) else { return false } + let item = NSMenuItem(title: L("Token usage details"), action: nil, keyEquivalent: "") + item.isEnabled = true + item.representedObject = "miniMaxUsageSummarySubmenu" + item.submenu = submenu + menu.addItem(item) + return true + } + + func makeMiniMaxUsageSummarySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { + guard self.settings.showOptionalCreditsAndExtraUsage, + provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return nil + } + if let width { + return self.makeHostedSubviewPlaceholderMenu( + chartID: Self.miniMaxUsageSummaryChartID, + provider: provider, + width: width) + } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.miniMaxUsageSummaryChartID, provider: provider) + } + + func minimaxShowsUsageSummaryOnMainCard(provider: UsageProvider) -> Bool { + guard provider == .minimax, self.settings.showOptionalCreditsAndExtraUsage else { return false } + guard let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return false + } + return true + } + + @discardableResult + func appendMiniMaxUsageSummaryChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + guard self.settings.showOptionalCreditsAndExtraUsage, + provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return false + } + + if !self.menuCardRenderingEnabledForController { + let chartItem = NSMenuItem() + chartItem.isEnabled = true + chartItem.representedObject = Self.miniMaxUsageSummaryChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } + + final class HostingRelay { + weak var hosting: MenuHostingView? + var minimumHeight: CGFloat = 1 + } + let relay = HostingRelay() + let showsSummaryKPIs = !self.minimaxShowsUsageSummaryOnMainCard(provider: provider) + let chartView = MiniMaxUsageSummaryChartMenuView( + usage: usage, + showsSummaryKPIs: showsSummaryKPIs, + onHeightChange: { height in + let resolved = max(height, relay.minimumHeight) + relay.hosting?.applyMeasuredHeight(width: width, height: resolved) + }, + width: width) + let hosting = MenuHostingView(rootView: chartView) + relay.hosting = hosting + let fittedHeight = self.hostedSubviewFittingHeight(for: hosting, width: width) + relay.minimumHeight = fittedHeight + hosting.applyMeasuredHeight(width: width, height: fittedHeight) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = true + chartItem.representedObject = Self.miniMaxUsageSummaryChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift new file mode 100644 index 0000000000..dd7e9a3831 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift @@ -0,0 +1,74 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + func addMiniMaxUsageSummarySectionIfNeeded(to menu: NSMenu, context: MenuCardContext) { + let provider = context.currentProvider + let width = context.menuWidth + let addedSummary = self.addMiniMaxUsageSummaryMenuItemIfNeeded(to: menu, provider: provider, width: width) + let addedRecovery = self.addMiniMaxWebSessionRecoveryItemsIfNeeded(to: menu, provider: provider) + let addedSource = self.addMiniMaxWebSessionSourceIfNeeded(to: menu, provider: provider) + guard addedSummary || addedRecovery || addedSource else { return } + menu.addItem(.separator()) + } + + @discardableResult + private func addMiniMaxWebSessionSourceIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard provider == .minimax, + self.settings.showOptionalCreditsAndExtraUsage, + let usage = self.store.snapshot(for: provider)?.minimaxUsage, + case let .valid(sourceLabel) = usage.webSessionState + else { return false } + let item = NSMenuItem( + title: L("MiniMax web session: %@", sourceLabel), + action: nil, + keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + return true + } + + @discardableResult + private func addMiniMaxWebSessionRecoveryItemsIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard provider == .minimax, + self.settings.showOptionalCreditsAndExtraUsage, + let usage = self.store.snapshot(for: provider)?.minimaxUsage, + usage.usageSummary == nil, + usage.pointsBalance == nil, + usage.webSessionState != .notChecked + else { return false } + + let statusTitle = switch usage.webSessionState { + case .expired: + L("MiniMax web session expired") + case .accountMismatch: + L("MiniMax web session account does not match") + case .unavailable(reason: .noBrowserSession): + L("No MiniMax browser session found") + case .unavailable(reason: .keychainAccessDisabled): + L("Keychain access is disabled in Advanced, so browser cookie import is unavailable.") + case .unavailable(reason: .endpointsUnavailable): + L("MiniMax web data endpoints are unavailable") + case .notChecked, .valid: + L("MiniMax web session unavailable") + } + let status = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + + let reimport = NSMenuItem( + title: L("Re-import MiniMax session from browser"), + action: #selector(self.reimportMiniMaxWebSession), + keyEquivalent: "") + reimport.target = self + menu.addItem(reimport) + + let login = NSMenuItem( + title: L("Open MiniMax login page"), + action: #selector(self.openMiniMaxLoginPage), + keyEquivalent: "") + login.target = self + menu.addItem(login) + return true + } +} diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 46f8d241f1..e1842339bd 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -25,7 +25,7 @@ extension UsageStore { Task { @MainActor [weak self] in guard let self else { return } guard self.tokenSnapshots[.codex] == nil else { return } - guard let result = await self.costUsageFetcher.loadCachedCodexTokenSnapshotResult( + guard let snapshot = await self.costUsageFetcher.loadCachedCodexTokenSnapshot( now: now, codexHomePath: scope.codexHomePath, historyDays: historyDays) @@ -39,15 +39,8 @@ extension UsageStore { else { return } - self.tokenSnapshots[.codex] = result.snapshot + self.tokenSnapshots[.codex] = snapshot self.tokenErrors[.codex] = nil - if let lastRefreshAt = result.lastRefreshAt, - now.timeIntervalSince(lastRefreshAt) >= 0, - now.timeIntervalSince(lastRefreshAt) < self.tokenFetchTTL - { - self.lastTokenFetchAt[.codex] = lastRefreshAt - self.lastTokenFetchScope[.codex] = "\(scope.signature)|historyDays=\(historyDays)" - } } } @@ -77,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 } @@ -84,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/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+WebEnrichment.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+WebEnrichment.swift deleted file mode 100644 index 0338fdf0bb..0000000000 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+WebEnrichment.swift +++ /dev/null @@ -1,59 +0,0 @@ -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 - - 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) - 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) - } -} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 9cef976d00..ef65c44d07 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -442,10 +442,15 @@ public struct MiniMaxUsageFetcher: Sendable { enrichedSnapshot = snapshot } - return try await self.attachingTokenPlanCreditIfAvailable( + 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 { 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 f25ec267f1..bb724eef30 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -17,6 +17,7 @@ extension MiniMaxUsageSnapshot { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, @@ -36,6 +37,7 @@ 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, @@ -43,6 +45,26 @@ extension MiniMaxUsageSnapshot { 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) @@ -59,6 +81,7 @@ extension MiniMaxUsageSnapshot { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, pointsBalanceExpiresAt: expiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, @@ -85,6 +108,7 @@ extension MiniMaxUsageSnapshot { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: pointsBalance, pointsBalanceExpiresAt: expiresAt ?? self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index f8eecf89f0..943d2a6a32 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -25,6 +25,7 @@ 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? @@ -91,6 +92,7 @@ public struct MiniMaxUsageSnapshot: Sendable { updatedAt: Date, services: [MiniMaxServiceUsage]? = nil, billingSummary: MiniMaxBillingSummary? = nil, + usageSummary: MiniMaxUsageSummary? = nil, pointsBalance: Double? = nil, pointsBalanceExpiresAt: Date? = nil, subscriptionExpiresAt: Date? = nil, @@ -107,6 +109,7 @@ 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 @@ -126,6 +129,7 @@ public struct MiniMaxUsageSnapshot: Sendable { updatedAt: self.updatedAt, services: self.services, billingSummary: billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, @@ -145,6 +149,7 @@ public struct MiniMaxUsageSnapshot: Sendable { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, 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/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift index 8c2fa3edc8..683562b02d 100644 --- a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -445,7 +445,7 @@ public struct MiniMaxDiagnosticDetails: Codable, Sendable { self.resetsAt = snapshot.resetsAt self.pointsBalance = snapshot.pointsBalance self.pointsBalanceExpiresAt = snapshot.pointsBalanceExpiresAt - self.usageSummaryPresent = false + 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 6259d1dd03..d2a0d2ff4b 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -82,10 +82,9 @@ public enum UsageFormatter { public static func percentText(_ percent: Double, suffix: String) -> String { let clamped = min(100, max(0, percent)) - if clamped > 0, clamped < 1 { - return self.localized("<1%% %@", suffix) - } - return self.localized("%.0f%% %@", clamped, suffix) + let text = self.localized("%.0f%% %@", clamped, suffix) + guard clamped > 0, clamped < 1 else { return text } + return text.replacingOccurrences(of: "0%", with: "<1%") } public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { @@ -260,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) @@ -271,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 ? "-" : "" @@ -283,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) @@ -414,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/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 720d85d81d..92ccc14852 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1127,8 +1127,9 @@ struct MiniMaxUsageParserTests { #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 == 1) + #expect(await transport.requests().count == 2) } @Test @@ -1175,9 +1176,19 @@ struct MiniMaxUsageParserTests { } @Test - func `web enrichment attaches credit when group matches`() async throws { + 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, @@ -1209,13 +1220,24 @@ struct MiniMaxUsageParserTests { groupID: "verified-group") #expect(attempt.receivedWebData) + #expect(attempt.snapshot.usageSummary?.latestSnapshotTokens == 100) #expect(attempt.snapshot.pointsBalance == 20000) } @Test - func `web enrichment ignores credit when group does not match`() async throws { + 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, @@ -1247,6 +1269,7 @@ struct MiniMaxUsageParserTests { #expect(attempt.accountMismatch) #expect(!attempt.receivedWebData) + #expect(attempt.snapshot.usageSummary == nil) #expect(attempt.snapshot.pointsBalance == nil) } @@ -1456,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/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index 8b95778e6c..6be3c49022 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -74,7 +74,7 @@ struct MiniMaxTokenPlanCreditTests { } @Test - func `web usage fetch still enriches credit when billing history disabled`() async throws { + 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) @@ -87,6 +87,12 @@ struct MiniMaxTokenPlanCreditTests { 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, @@ -110,6 +116,7 @@ struct MiniMaxTokenPlanCreditTests { 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 @@ -321,6 +328,93 @@ struct MiniMaxTokenPlanCreditTests { #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() 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/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 e3d82a9772..e24066d8c5 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -54,12 +54,6 @@ struct UsageFormatterTests { #expect(UsageFormatter.percentString(1) == "1%") #expect(UsageFormatter.percentString(101) == "100%") #expect(UsageFormatter.usageLine(remaining: 99.9, used: 0.1, showUsed: true) == "<1% used") - // Values in (0.5, 1) round up to "1%" under %.0f, so the old post-format - // "0%" -> "<1%" replacement missed them. percentText must show "<1%" - // across the whole sub-1% range, matching percentString above. - #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "<1% used") - #expect(UsageFormatter.usageLine(remaining: 99.25, used: 0.75, showUsed: true) == "<1% used") - #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "<1% left") let usedWindow = RateWindow(usedPercent: 0.1, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let leftWindow = RateWindow(usedPercent: 99.9, windowMinutes: nil, resetsAt: nil, resetDescription: nil) @@ -72,7 +66,6 @@ struct UsageFormatterTests { UsageFormatter.setLocalizationProvider { key in switch key { case "%.0f%% %@": "%2$@ %1$.0f%%" - case "<1%% %@": "%1$@ <1%%" case "usage_percent_suffix_left": "剩余" case "usage_percent_suffix_used": "已使用" default: key @@ -82,8 +75,6 @@ struct UsageFormatterTests { #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "剩余 22%") #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "已使用 78%") - #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "剩余 <1%") - #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "已使用 <1%") } @Test @@ -325,6 +316,21 @@ struct UsageFormatterTests { #expect(UsageFormatter.tokenCountString(-42) == "-42") } + @Test + func `token count string supports fixed two decimal abbreviations`() { + #expect(UsageFormatter.tokenCountString(88000, fractionDigits: 2) == "88.00K") + #expect(UsageFormatter.tokenCountString(2500, fractionDigits: 2) == "2.50K") + #expect(UsageFormatter.tokenCountString(7_576_430, fractionDigits: 2) == "7.58M") + #expect(UsageFormatter.tokenCountString(1_100_000_000, fractionDigits: 2) == "1.10B") + } + + @Test + func `optional percent string keeps two decimal places`() { + #expect(UsageFormatter.optionalPercentString(74.6) == "74.60%") + #expect(UsageFormatter.optionalPercentString(3.8) == "3.80%") + #expect(UsageFormatter.optionalPercentString(nil) == "—") + } + @Test func `clean plan maps O auth to ollama`() { #expect(UsageFormatter.cleanPlanName("oauth") == "Ollama") diff --git a/docs/screenshots/minimax-usage/menu-card-inline-dashboard.png b/docs/screenshots/minimax-usage/menu-card-inline-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..f221ade8765287f4696ebdaad48e53e685f53aab GIT binary patch literal 67082 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

0Q^w)Q+?Iz=VGFrSPyC(XO7h_3enR0^Sj^`$=y5 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 tIwm<;8YJJ+;j?}5CAd$@DAF@_~dZmR5@pg#2c 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 nH&~8BpaJnSZvAw#BW;Y8N|G@Hl#weWuc*>wl) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..35ee67e05c17ce9ef0b7c5f5a1a5109156a12154 GIT binary patch literal 67702 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 P|6f?~@6t~}f2aQkaGs&m literal 0 HcmV?d00001 diff --git a/docs/screenshots/minimax-usage/subscription-utilization.png b/docs/screenshots/minimax-usage/subscription-utilization.png new file mode 100644 index 0000000000000000000000000000000000000000..04339a01f3b546d1f6915e8c906fc62d3a10b49d GIT binary patch literal 42609 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 QqtKO+U6?Pc(fT#>f3tIW?EnA( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d862f5c984e33b148371f3c7bbe1c1b7f29c1ae9 GIT binary patch literal 92733 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

05GUnkL@78t9wbtc{1>5gBw8mil%L zz1G<2(^OgQywfLd=SlJfNKA5NvP3K(EtZ+&&CBf?8v^Ojb!9#Ds5Z<1%4w~?b;pfJ zYar$;Ow1(G@_Pp(Mhnl*4%U#B^?s3G^WIv{Ofe>Rv=b)FEqw}kpZb3B%3+Bz@<7ew z|B&7J=ZO5vW6;w4NuW~N|AXvywEdIae~n|frm;{-H6Y4@4`T6(lFojOFYgD@+>t@k zco362XHSe(djQFx)-lLfa$UJ@$L70s)yI09BSkAz8Imr3FEU$;IuZZWq%H@{KWDo3V*B5!M%MIjHT@LO+X5*qA{CBu}!g{j4L( z0sDxnlK%@wz<+BC zY6u;(>yv4_q^(~diXKBE!&He~ypV}K4DtKi1>{ya6T-p)?rx(10lt{CGr0lBGLkLf z9f*US4fH|kz-Z+9`-9%2=o@ABRvt4pg(E51AH5IJ56og=)|I&t%2r>Q%c=041R3rU{A+dV8QGM7LMC}zY3f6 zq~%U9V2n@H1PH6S04n{aaR9OWtCa=tQCE{0csX;g#m94mvBoTPWHLsmp4GMaJ)Bj_ zQ!Vf*kW9MAzu?obcy4lko0)|l-AtK9xR#=;A@=1A1srgdk+}(l0|L-*smk^js;8(q zg74#o$3erUPR(b|tCN*msjcs6a+4L^-jjWsdbm~$hN~zs!y@i>Wl``xmvY&*GIjgXpz3U3m-)3QWx2d~oK& z^izIez5y{;`^P-e$>I!!LXyB6xdy2ZZKp-5Xz0aZK~Ep?t;#=UAnvttxAhSsnZ3)s z=w7D5OBGPfwR5RPEptD!r7p`;4|?4}`;l18LUPQ5q6B9stM-nP4trk0VV~8McmOt zbTtn}`z^_FZ9c?wC)Q<@`H%uuPK@(ooOzwEnj{Pni6)h0t?r(be34>UsauS)QCAbQw|5 z`|I`UI|zllst93NJXV8NO+xD9?A+CPH@#$xAmCOqdd)cm@0v*K`XTDPuK7uqKB0nz zg9TMZ_#W#yZn3w*U6R|a5!}2cb2dCwM$NdO6lDo(%Ue9=Q(s~_Th^xa)<5ncQ|g@P z#4m8YfooD9-#QIs&B&G+Z@tJKdlwHBczz2^E>h)n3)6SuIs3w~obrZE23L#&>LyAI z%s9??wUvi5*g0iif|Nz!Mp|5;x+q+6UmC7kMBPqP#zkzavB2?Q zLdbD-M#XHB5g#DTCR!`hA=V!7HE~oQ&MiX%)RCoC_&O1l{Dxqctyc7imcLRhFIXJL zVT?zu%_R5ip>xu__)TC_dzW)V9k?_%xa7lKq@ekfJBm_$>1l&e0d&wi^34Hon!}8P zqv0OSP_Hi&?PqKwj_x$i$ju&x{SN*1oi+Z|&;H7eBH*cD<-`FX*q2?6N3 zE(S%U_S$pyDylp;3}aQTpl(r;e_r}h?* z9Qb+`)+NCfgQmMbn|`!fYk4=sq*kxZ9VJMVpNt@IY_=9{cMk#u`WP5Gxio~6MJTAM zYsMf7(i%U=m-RoHDb;K6EEo{fbs2tlN)6JA~g@==g6CpooMv{0MII82<~T|G^PfWfyn^I?`;j`G<;-)_b9V`j5x z)f$wdtP#gmg2$TA+e`(`zYw)X<9mS-6;rYT830!PY=@?j~L z-$NKI~PmO?=$0F zWqh!-s{5nJ@yGa&wr0zV+?q>^OTzOMiDdl_&OcTw-c{7sR?S5_v%jtKYW%LP;#zCK z`hE(Z=%g(V>i2AFmO6Ti0(6X^obXz~YT4U^nw2Z!vTre1-I z*FSNZ!yKEh5wUtSQoV=P5-^|R$IC*G_S*Y@MypK)nTkCdKv%%c@dp5m11f)F^mz9M zuc4!m{VNKif1>aTw$nWE%X3Y*z3op7^r^saGlvYNe_@lfYnUWJcgrE8TRr!?w*Z}~ zfJ!vlot!>@TG9N|efPUjmWE#e&(clVa|lGchTAM;x^wHh&w1kC1Sq%;(lrJTue7Ir zg{_n0-wj(H;X_`f*x3)KI?q2Ias|D!|yCxreJ)c+qG`ZZ^+5xUMfj{gCn z>lyk_?Eeo4B@qCBPiLLK2zTRXPksD}L2*6*|CupNmXN2SeYZm=&_~>+ljw_fe%&9e zY^8H*&}#t?uWd=}f04rdR-@F(JysB#><(MLm{&Z7d+~tE!atpeH9v`()hz?Cn?wXh z>yj9cGOI;ICV!5IOuYGkRqb{}Zp8I^&wqQ2_b(kv)NQN<6%Fmj=?qcqceJL1b2Phe?vjL9i+B!k3^r17*Vvo|4W7?J@k3O&f;+tFNE^b)DmgjYy*{Ga; z03>Zs^G9@wFO8WnMtXXtl`NS6pv@~w)5PM#fVwKkgNVdkPq3a0U5wvIXlY$WPE}z8 z;$?k?@P5M*5=CV#uUd5Ek&N23gS@vhl;yReHpi%yU=kBs397#mxkukhz^KVaf$$85 z>}g8|vll_u^WsA&2+XM?DOX*==Gtg6^2>4(Zt-_-Cc^>iTch0l9Co;ojKu{mK9+Ca z#>cPW&_sRN--=EmF}X7&UTJK$QDj{=1$!n0l*QjEj1O8U#Y|~C#)jeH`C^KAnyWu=-8xst0*dpOirq?{^h7d}9LdpF5V^iYcldY7Q6 z_I$;F`hGpTzC?JdK$xfgrZJb%7?P#Gs7ekaYb*L=l{HjDk*h4Ch+PvHfrNB-6{os* zX0BkjRA0az!r!tmy0sH@ZONikX+lfPjj6wJ)_1n-qNdWh&r zSZw5{bz3y{cQ6EC=zJ1k|x=HS}_%*)?er72HanEo_ zL++rEGrg!^j&ond9Kx!8%0skIaQIw(Kz;mTt`!k@JMa$mGc3U!NW~o+9@10s_ws_3 zYDV-bODp}ugKkuO``J4x+(y2OyKkMMqV*eev;@>ul)%g9USGb^7(Hf#bu!Vt8EiBh zrqPBLi)(5R&dh1dO<+r$igO*Y6yn?#Au-cO(ddjC3ABLobc~N@=-`cbOY9TAIZ%SP z@|!_zWW!=9Z-WCwcf0pMThZcjVS-1)o29Y}RtK>kg`sNVYMzXFsdP!y!CcZwV`S={ zFQaw}caux^|q#1 zXi#mm5xu1IPuO-F$a}~NLS*Hb1XNXb13lr4Q>22 zWpAhfdb*I_&wbF6^k)V8kL1*UaquBS)I;=^jUb<1}%kTOMO zD9r~y(S5U^@l>~6N<%rNiIe@y<{&BULt^gn3y%jEbv034u&F%J15tHf+0mjD_I% zceTww4qFrG_wDK>^?t_P_I?QaiGj=R{-(>IOtI9<@BsGEc+JNc?}8Mr+dx$o6nOdC zfFczxF+fmA>6H`A=6v|D~h9#i(GqKZGd z>c6LOkyb>ZK>G`$F{6CQkr8d%3xIi84dHY1OMyJXl?LXc!#eMt%-0{ygkti5z#&TK z>i1T`QN`6h+o?R5?Voqb`f>OPB~r*E{qGJnnt0McAjl=4hcC%-(qWgT_FbvR2_iz{ z(l1lSRoB@nO&k??J%M#A>cA*d zWYW$-OF*u7LO@QFf#ORH)6JaEH}CZG4@!L8re$k!q|P~tX;U{W>CLa2raBoDsE+@p z>rCP5U*_t|Ojoxuo!_`Gr(j2L!*NI-kI7DO1rg5=en0Tg|E5d{cao^m?GDoksddQ( zfE?WU75TKBXY)H1lxflW!=>zSpfYc~%4$Y`p}o-piaOS7VJoW8d5E0*e#^jZpUqv+ z3nfk)xSe{I+Gw*u=^>T3v#kY0LaHNk?&?t!QMn|&An!f(YSm+?QB;q|6I{D>d+RXR z$JfVY$+1K}E2r|Onefe?7J~bQ9^6Pp;}Y%RjmvyD1!Ii~%Q-DN;l^g2-N6Jx5vPFy z13HhzQ=+5etk0}C!$!6YnF$jLh9ujexv~OTbQ>Z!GCJHV0yxnHk-i*N$7QxA&$yC4 zO6^K1b73V%d~#`1w=<(}P%v?9&r_#hf(n{8#$3g`AtTsv3FOYpbnkM|P5P)c+@_A_ zC5J~%O`YH}_~@2qj`o@i*5-x1KaE8g)Mq{=&Cp~yH9=IcefTigeb7zv3NOvZl+kI? zJJ_a1MrUgKR)9VbAO~=BR~M4cE$s)hRmC?-^j#@{*mTeMU6u^(ATe_8AIPUSBRm1H zEw*6sTyaVZi?(MQoojQ+anV5#T-qBvkyIdZ+eo$mtXRMTeojrP zp$G&b5!gdbr1x>SX-cu~?P-aSX0SIN#~#p|UGOtMuxYqYZL(`h?VNV>D&p1W-S4kE z4`~K{XQ?0@8I1D??c&)kk3cJ#nELVznHY!Xg*)!cONk$|R3^>}g`=`se`17ykxn(u zdy^x$r^eMg6BBejzRuiprW@^2cqIplVJJKq{1S+@oUJ`;_J^s=ak${h!GKTZdk^C7 z(|f3p!3L=SyiAwVV3D*>Bvk~9|A^L*%~s54jk_w7r+h-{gwhRSVc{@QFNL&M*c0FHRmFHk4x)OjOlQA( zMApC~*%y(c8kvyDG6L2t9*io)RAc7+lS$Fn^M~1IX?|9V=hl8@rRe`0-5gyo%lMEy zvlo8eM4pZi5&7}6fair38(SX24VIK#!8FHG%4{rOP7{2Kwj}hz@1opz@n;q5GPAjWEQM-D{!;5x=<=9gs3R{|%|n~&usc1S*q_~q zxbT_XR;o@{yfv8jLzEBPf>Yk<8io4gCn5?VV$bjg8~VE_~Un6+nsF zJ$xnhA)S-oD7-%AZTWtq8EKS73T$X%I#N2#uSHa5}#v6hz3_NKe>15J+PX7@L!ci%Wg zmwXuhq{bH$ld>vUA$%*ns;Vk@m`E}t9WM6njpq-f!n3v+Db@TPqs&v!+NKkqUHA7k z?D;V{{mGYZv_ZRaIG=8~XWX9JscCx|#yM@|7PX-b<)GHTmFnKBkTYMUT30IZJtZ>( z!*q3Jeka?RiVl@u%vD}gY0T_VC3{?%x-qNF(w7!!BbIn-2963KM;nO-6xlkDNl+Fv zB9YC>hzdPTu2_qH(@%b#zS4CQOxX+!2S-^yUbFnfKv;iw+e_CS6tVjuF}S%y>W2hg zRDgRQqLoHM&!xgnMV0BM>-oLoGir|xISc4r>!bP`imIS5SvkoKSeC4CLmPGfdy+jqGD-9AJv?%|?DF7m}*D zR4#!r4(;1C9AnQ6&Ds zWo(rh1_sq}t`!E0<`<`Jt-2fC@cTHc)OD&S@YtnI)dfDlQm!(QT{+an2zmO&WKHRC zcCGfE#86_V8=j2JlcFzU*N-)G&Q&tK1K&t-`$J7#4nik4W5$=qUk)rXY~up&xn-9# za5?3U2pSn7S-O{)a`R&j+Sh~%I(Yri%HQW2c+7RNl)Ph?9r(L+Ec&F%j{PZd5&cT3 z2-n@0QZ6kk>pD1^_7!BTOK^5>5V+4WwF1&mZ0%2IA^V(IsNfRpCMIfu@dG<&e2)h@ z>aS-7G|mIZeqDKvtu}a3PLiMCg%o@n!UVbP z%=5~ht3N55tWQQkZK~uH+TM$M5s4L>mb!{Aspzn?6(Y#n21~r-X{p@Q~`_P`&;LIet)k)xE_hS;|{} zdX-THV;d~@b!z!@Ay9HTK^`UsF)+s4Q@T*sjV*;WmHmoD%}0j2I`=Mc;f3KH7+`)> zFyAHXyao=}@k(gL{;*wx$X%ff)@-zj**9}2JadJRDd-7i2orZlobd{s<(k+4%cGb% z1N$wFNJmclKKW50jR&|YqO^j;7O9M-yL4_)=puGlp|XrlbyG=Ydxx}gCQ7gp2fLuQ z(?zm+NzABcHNtf)`A{lf^My;U>-r)~Z<=Nxci}r1lidYovur!N>B>zX`v|M}@oy5W z^~9WwQzC?~7WDDnSqyzwuC%v{in6;8)HKATW`fznX0V_FL-P{Fn(1tt?+VlM=V0_k z76@U*FUZEcU*n)e|H0x=Vws64VjX7=SqLtl%Yoho>#=mp~sg zKg4iHZmr)JzZi(&24`;5h_=^i&fcR|JA>A3u`gDo?%+j%o>cbGo+0M>dmoLA0XlYD zlHMGw-QC_z+c#eZZ6m%G#<4Z>xT@NPJUPHo)t+z?N+vnA^Zo9YnOk}oiD^JH@x8(; zW1QxLWLdPzg3xT)nqS34c{ya%3apa4qust!Ql~MI(NN`c9D=1dm!AF&_j%8-QsaVG zz)uXI$??~gk|Ha|vSvFw@i{$gF5wf!W3We{kBWj?ybj#ZR`Pt(MOXTk?O9&Y@M@R z6_^=wM!uetHLr-q093}K^<<_tt2xCJLiHP3Y=Q-Sgalv>t6qj>%fvqQwp=c=f|0Qq zCL73_Cyb;j{yF(w6BA8_vDdwc(>LQ0H3Bf^e9xxfLH0K>8O56w4lEAo#}&RVg0A_EFIaDGpPQ%4&ug9dQ!$N$I|JeLne3d$rk>|oslqt66!*K#1DhN&5TX0Zpp1pARCVQ%G5lP^BbdTtBxjq|W!_Rr^ zb(3yN-x7W`a#Y|uRL$9a*&aMI&P4Ie_4TUpNpdxwDXH#@18vYfpemaqfXVDd*X3QN zZ0EEYZG&jEY@5TR+maNisnpPL%kf_Dl;Fps0XCt0(uC@*Vj-krj5e({xci0wET074 zZ3|_rl*|C5fYG4EzFAwFB0)s`lb-7AB&1;FR&F7tfa-diuU{FS;o|+Aye8WjUT(+^ zYeMzsZdho2u;GN(iCfq`gWHpM-lBB1be`Hs4=B9G-k4Nsag=j4C(FfRwzbu(w(6T? z$0I#*U00*F{9Wjjjy*>)B}Ix&5&JW-V1c1wM>?ggjU#UNAR}->-s3tj7{m$~(|xJZ zsm9k3ARn4j=7kxt{&?M&8-%iIoMcp8>R>UK+))>5Vm7qqG{>=$3^6I@%R$1EA&z)R-A!)mRr3{A3jY?p$7|Y=~EI_5&8o?kJd754hCoRwP8-+D)(}q#8nAo^t>ZHKNYd zn;?5Xw(9N)0(J8=`)me+AOSi(-_vicpIyCDMon`$Y|F{-aO)-vU^u!{*2+GNhl+@L zOf?G9d|R7-on2dCzIfltvAU4dRqPUGW&i#v#@EJ&5R+k^&Qu_zPY1q42+18GL?}yX ztZJ}ZmN*LZakhGC#TqPq}ienlZ07<*;;YS5Ylx_$o&=S$Gos^oYPO zl6&ek*mOc~NL7_S1kK-CXj|%lc%;$m`?93hg%9QGO0l;d($vGr82PPpzjGWfeT@bK zN2h8>;_HkAY>#isW(8(h8BS$r$DTz3lxME32Z|P03+?MK=eas(%sqWYo&XSRnKLJ)*7B-h-$*>e`36FUJCtqQKPtBc2?uxwK+py(Xcpk z$cm3BhZTo^6gg-OP&bi^`~)WJ`TH4LvB#_Ky=d>=iZ7J*&j9*F*G;e^MewgeKY^t^ zEC0L3+@Vf%t#cjp;^8Ar9oKF-N|z2@ayFwk6+wUc?w;?_yLeZ7c|VYbT_dG-ZJYbM z>tSj6XuXV;_t%D1O}+K2a05SVThK4)&^iOj-AjIz>ca}PD+WkUGar5PT;Bwm8(wZ@o zvxo0*@7|lOxBUKzz`-^xO#0U#Vn_Pg#*MEHdH-SoJ$Cxtf1JIH&MCdQ^U@ot7xOa9 z#^|BmJXb^_uE(JLA4ZsJCIZ(nI3Jk29@|3VVcto^4?>&=%7zkta84}3ul zirZjxu)3wowEccTNMOHdx3I1L-6iW!G%Ejs`cFU^#$G|o=)Rz#aQ++hivPyw2XyNS z9qjvmlE)$QuS?;@NoK!-4!u=(Eb{DkXP!UM4dPiGyG8$kK5=wX#`1ccY6R|o8j$|o z9$7nY)$hym{$>&Dt(yPRj^$r;692|rjllW8w9~P$d)AYzZM-vL4-InARK26yEiQ(3@p>liY0umL<2O7_ttZ_pckM3#j|~JL7afz$(B3cn9KXZT??QV^nGLhI%=RcbGo?)^ zh0~uKp~ieqCq6~Om*yiwXto<5y_FRtC!%1M~XDh6A^jYU8^7CY@$h} zS~rYo%WjQKPZ}ESG#28NEex(r8-7u+uXhfIxGU-QXj@ky2M0L4#Gl?Ytr-he{(OWD zyrBEaQVp5ia$rqJ38V~`wy1o^yl2}l+a-0iB_1IZJ%?Y_oL3*6+})!NG+y3Ko(;V9L~~O)w*LyTg1)=jm5i)l{rJB%Xj&AjK#2jqiKw?jEau&o^;{lPxaw_Jfjg zGCNvX$ILm`0&qo?*G>ZEQ=|EE{`-u#5KvJ^=#it`CO27;t({qaZ(qXc&M|t29VeUf z<$kPU_2TA&c>yV_xfXrMawiRKE&e=BkS})wBR?_KZvH|)T46qZbY_R-7{TS#!r4N| zy;U{;HdfY9RPeYO(o8jOvls=Z3M2(T#dnAn8^=-O{Qjm>!MtE7MI{<&Rc-8WZ%|KV zW9{`4x8JmziThkKfk18vL|iP@x<=s%E8}ooDvvKM|Dv*}Vac0LSz94URPL6#TS7*8 z88qfVPON`b=xiFNp~@e(+mzNSQ@m)o=F2J&SY7|z9lZxSjLHmn+WsAnFD@a11udvi7;~IM7*e79-p)pfhtRcgl{lRK$r{rF&dbw$nrQYwHJP};$B~@f=di9yf z@%7aJA>~agUka9qBYcbAlB3<&N?RF<;|Fu3#6=99h6$$Nkkn-ymvu7p>zW#4MNRko zl$5gDo3;f`U5v>&eLjbGLGYpw)1gKw`>7J8v zfGKT3O;33%CguYMs_vzNUChKKYeaeI{JAsk8g!V~DEPqYE5Lj%u*eVU+Oh20PAGJ@ln8cvGa3oK0?>`6!l3cIUDx}B~ zlVZusGH$|}bkSNqC6o^(;g*L0CE&eX8+i7L25v45lu*`y2KE}wqt(*MQ4|ad&a1<^ zOmZWY>Z-$5%Pp*v7sFaFm`49mTQvbqkHxCMv!@cad>{HG(JgG5R3J$QhyAszu!?07 zqv+#qOLk?QU0Hyb0;{f@mRepitBPfI76-2BFYYXUc5jd_noUb1H=ppRXcv7^aM;RS zx8KKSu62lyZN)xw?97l}b1*2_E(1f+d>R=^GJSpDNROSLlyF^31fhQ+(c9I~B_@(* z3aG6wmEgg4$hy11o7L`%ho@-MZ%{N`DhV<9p-bpUM4#KeM(Dd%FC)0Aj)TG!s zbrOCiYEMfb$D>zlqOZ&`t!Q~7)n{Wv>n4It$l;nW*P7NIS%cCV(Nl((Kbo+eyMZCc z{08IHy4JeRO`V#_@F&J?jZZQCH}-}`!1kK@H@FO$raRAcn<;snNB2Ue{laZ_yr0g` ze#4wdJmbF$@`)2q_k(kO@%O7i4ZBY1%!uaMp~!itee!R9HO#k82TXN7-^mhSbaC<8 zPH$kEp=dX{gdG((QV5oEHQIbK+4g+Lr%BL_Qd*nYjjw9>{^nLjOB)jprnJ)GVbRG$ z7ne?k*e4?Z%$Cs2!O1rO=JP^KH3Y zNuJ-s)eOb2M?SMBFf7{GRsRH>+|f-*i{4;DC}M^f$WghNxhFO-Q7pRwxIUb`$UY5L z!n!BE;D1u#YGL4V*diBkUqDN5=9pWf4$?X`;wYdb(pRng!pZmE*e4sB*X}OGQ-*E} z)m4KiC+dw|F6j@`&kIKQ(1(^`=%r!0#}GxQPK(c7SkDQ~oLu}?0e!wN+7_C$;oJ!` zCDV*!C_h1+W^(;0n6U(ZTeyX@2``|VU;q72jQMl=pBS2IKQSEiU%8Tw`AD?yaCgv( zvCOHBcA^R0l3Mf?fRs_t1ntv%kB+3{=v zP-`*uEZ%Mjj>>;Z^U~e-rku+mlDW<$BcRCPLLxi{Ma8l86>k-m3kMf&wk}|+d36s> z#p1h7dFgd3rz;l~&rE+3;bep>l`G{$%YF z^~dCrfv-74bnK$8o4%}mBsE#|JK_$h@!M12tMh)pcL|7RcbFI61&3uUB5HtkjHRX3 zQ=VUw3G0*>cOxPWG{>!+x-@AWhli6?Fnj*uL+6K6J1;dS+}ZPl!9p zK99SGBYd<=K^rfECDxT^a$#*#nYP8Qk$QeRlY~aFe9;Lu`tBk?9h8}?>RMxxUs#CX z13zHpXU!<2SZfgbQAIa4or?;`4~c7(RsTA6K;HsT7$`gbx}-Ta;GKd39!sdKq2~PV zq_>b56Uw@K?Yhf;YiLN8zPU*ckG=_F{*#BDREkX{;mr9~k((Fn#-+ zo{w!Hh;C)}7Qbs|)V;omr%UN^!`r^7s7B&5n;Briu8HDEeYlnxr$f!;>6GK@3IjAq zj!|-)C`EbK>#3NzW(yr2@a)O{aAg!E+SpFq(cBMKnld9*KY#WdQO%wo#VUN)s}_?^ z$=ek(epPPR1b6ZCk|q(u=*?NjU?VuhnVjFb!%85p-$i_9V8Il}?eN`qdZ@k+|Ae}H zOp5z6mmOwjMTz$OF1ih~x0A1HbJ&C5%knHw=hbjfYpD&x1>w&*Vs5iyKR#9?k!2Lq z*#*Rh@{ADUa4;;$J_;7&(9zYs{dhX3?hybR(^W00^?7!L7{E#EhT1K(dLBmiop8Zl z#FP(1u|NlptvfcixOB|<;p7}wz6BzUy{?G;i6J7=X;9&`NvN&mt;>Sl-blfV zlK7F@sES?ypDqA@5Q;6%7_Q5EE{gqSo~OIkI#_=)V{b0+(A<+M3Mw=Sdg1+|xf9fs zr6BMTajfPXiHKaE1JoDe8ei4dZy7okmWCOJQrN@UA^XqndXFVYaa_5un_Y>t_v{wg zPfr?b!Ag_+1`@~n?oJ+_RtSBHSvRfhZgAAdsU9-ja0H;_NZVm${v>5S_mswhO&;%k z#+g?oy$IKtGJNLren924>GcZOTDTU`Mu>5YQ|EFC1RTrI3GTw~kq?e)y5wZ9E!W>> z;O3?G|8|%qOgdA4S;RZ(9DX!7$6>=(Z|X#H4AcrsrM*utjnT=(@Cd`TbN;ZEsdA3j z!^jN@aH~~83pzQSTjIO-DAo=tf4`wjm|LysMOM3I?)l|2hrhn}c1FA?(G-f(muM}jc$mfi0s;CJ6bFfC>me9n}ncB*|KS#gVWo*q9i5Yq;w9dy1Bl}(-n8# z$waxHy%&KdT{+%alQZEyPD(y#rj=FdehC=))R~>0@>(0Yb?b*kKTd`-srOV^Y;KJf z|B%<{J>iU|0#BKj-V!-8b#19zJ^e)k14Z0bD2Tm0Vz9#<7Bk$Yvi6!ehO!Iu6NSvp znAx8gP9LgMQRuH2U)A~w!>eD-4l8=t946GhBYjX(>q^z-mmh3r9|QfoRpn8BaZr)V z$LxeLpAj492k(gTE;{~^TcJPLYgsvA@7Q40w%nzy%}IOa)e+meg5xw|wAhlr93}B} zGG&fAj|*&z;U4bWVFChiAxJ%?x)Z42%1bb4YR*9)Uda2C%s0fHp#_`8?oC>C`Wt?p znR~Dq8joYg@}e~oKNAb=JkhSTST@P?(!kKu1*oXGZDI8UT6nQV@Mx5;wR7?eYTuQ} z&~T$65)&(XL%&bf5h-}9ddrvqo595#V(sX(H28|tw(uAc4(t;2iY^su*{>3R`uY0~ zP7bH$BBm3lL&LU1XBauj**8f!rRX1DvPPW|WHju3L^z2|AgTANU)=$^4^g4N1`m%I z`+IS0$vYL!-3ffTDr%Whd%rVe=+>#@NOf_af&+;Nm0g!jMn%DgPTC2H-fa&~dfP=- zNb^jVLw3{9C`v@;LkFwsN0jaQw)YaJ#-RAi38%5iZ#~LN-Nw$=szl~@xNbn{{5vGm zGXsE)BqUUY2IGzpC$K0NNF&ex^!C(psvMqzq|;?to$Ghnb#beTOF5sST3JOIM{cYi z+&VL5wCQPb)rU0aqBL*8c2~Y9Qu@zSzo;uhVJ{Q|YZt#Ek4Gq6Q%U5l;eR;5 zX(z87!o$?=jHA-$Tv`gbRM?F4&}hmI8U|%EklqC%6EnMQD)?6ui`l>%77n?M) z^6G_xbBDZYE($a;gIWSaJz};GQMCMe8Gw0pb-C=SjQW;hxFe)Nedc8o(vA{F{V)ao z0)J|L`WA|;27?Y=X1a!q2pO0ZX(`v$I)Hxt(r5Zb4$ez#CXTXw zrKW4Bu!qNR`&LW_XyyqXJh7{{qpBRgGP zy|p`eWtSfi+J819AjGvXY7%1^G@H_Pg_jGQ)x5w+y{vYe-d=*b5A>2hsySn`yJBQn z?Z^`?)rTHokL4PLPkgPb)E)L}T(**u^%=jzhU3h&mb%&`7$H_3GH}cAvj(}A&6uf_ z0T}GN;;0?+ky)AZe=+vf0Z~0)*f=U7Ez-gg0@5MUuprVMyVMejfOL0CcOytH9lLaw zfPl1gw=^sz9meN((Qp0U_n-G4*gJRT%$ak~jhQp&dEiuy&MkJm&ZCr@EvnNblK4of zJLpA3y>gEK{m7@-fkAh3`75RhaND%pkmxP!J-K_-S z1Ytt&F9c@f0JciAAYCLW{vbv_!f+g>Hdb2PdJm@RRZ=En|El^@qG6Es58&&+(`tc4 zTI@jmmJb&Y;t;lQF7j_Fj)mNQzi2aXo9zrW0!6pEQPB?6T3??WUPCicRNhf5Wl2Ueh5}EU(W{v*)w> zVPTin*5~_(93P_`>1&I08i$#-3{dOrW6_^Kr&5kUdgTuDCV|31wxq;U6G!ox;+m&F zZ~6{m_37Oni3yoLlFMY_AIc`=xNzCc`eQ38T_*82uU1Xkru)RS( zi*b6&HYZbf-a60j0Pp<@JMZf{(@a7|EK^hJxL5dZXSX$SpVHo6k*ZzE&sJnGM67M4 zv{cmORb)-uA#=%g8qANZ-#o~GiSK|FK;Jx5-VR$u4pzjf|j!u7ea*sgN>{n)Zq8y-8Pq{0a0 zsI>iZkACT$bx^v(F`EDLZCraotat~B5Zuo~T$E>KnB^rNmR3{b?#td@KWw|?zr29z zTdh7Qdb3O0SRwjp=tVK#MTer^-l1DR2AKK*{yPOmxM`wiq8hKZl*0%wZ$_wBFhx0} z&~~ABj>V3{Gp0tsVl!l0_-R5hr#H25pcraMahK{{Qdwzvs3LMP1_B$fw%&1>6b{ON z5GQ+kf37C&A>bqW^ZH(;=C&Z2`Iy+kgZQA(MvKLLRkF5XAyM;*%7P1#9iK=XJ>ZKp z<3s3UcRRY;T=X4=4tCFe<=`j7@9__GMA)Y}r3n++>($Fjz_|d0M!AJI;Zj0%Q`bDJ zF-hI{E1Da08@yHep{#)Dq!s8qc6^{uaK9#ybb;OYYzvst1lkT#vQZ^DJ=`pU{`XC< z1l$a1B&|tK0Qvg=_45O6yvm0q1SN^63@-|>{fN)XQcL?ikxWa|Wht<~g7R!W{87i1r!3qlQ}}I9%3rOD z$zb%r1kSz-^57YDF@bKi*G>Si-!tUrhZ=PR5iX@t^vBE}#34*hEtV zr(oQGs=z`wIiS;r&jWTULm(9AUj;G#)k{wQ5q7gl&mWO$Bk(-+ABG*+H)BKe&4L(l z7mxp{@~>hkj?iyuxU?vyzaqd=e-+A2k)r1%llYHeBK{giGGTd7y z?yu3c<>=+y3r&1w^8VQ4KjoC5PC*X$zqKHDV-j3r;T27C>hp@alg9*>x$Et55JP+# z?w?U)eE)Dk_Qw)aL+b!1hXG1e$p+y)_i-!QA>p^Wu2?rmEFVaqTw_^Uvuv?{hyJu58HKtsn9S{eFWx zuPs3@~19LssoA`nb!@bidYono=EPl1(82^E?G(ebd90dEs(Y zmpHGuCnlR04}EB~{;e;JEYrMcO*=#SB?n2vtyNezKVzPUmHbd}+KBw|h!<-K-?R%v zM@P#bcvf%i;B8idaeO13XX$L)DKNAI^B7r9W@&UeE-uW*WJ0aKKs09R&L&_5OSQVd zh>Wd~X9yW@!8Y7#A^UWHJNouTNPP2N3vxyuX;MkFToP~}F}_t}YW%qojMF^Xz{>HP zAFQGltE1IXmu}lCx#f>`ghto%{pf`XYD+j>ZxP=+b$0M@-GKF6$=JQt6;0*|f1 zA$P3!)xBw5U7L49`~%y@g0aw+d@8qt{s$n>b5u=iOl)!u#e%5?eI-LGRs{~O{xaT` z2se3gnJ?K6!GwITf!+#MD%@Zu>6H~0o}I?z<=M!y6am{I__uYj2QRA@{c4hPhpv-{=~t%++0EcJOOL~1IgY0KNKa2X#k;#${-ww!yuZa zoRXM$)zz8>S%KgN`i~^1B-RhaM=FELML?mHd!f84Nrm4dD0f$w<(~G@M-VC@dm`|v z?z|)d%+}p_j#v~d#5g)uv^y_#8%S0c#sD}{miM4n&lItTz8CnU9&3Iy)>nCUKd~yh zUQ|s>ayo~mlvYbqR$BnJj=kNqt+$rV4*-{`(VmyvPe0g^GV zXV9YyTB!?NBJe70@y9(A>b3cg$yKGVlQ&r{F~TB#i|rf6fG z`9oa8ar)U(YUJuq40ro zE{xB|xI^L%>r9i9MwM<+AsY=o4N-*vbkS!0`DWtGn?yZ!xu=8%rYvBVNJ;=g5O_HC zmg9Cmx%C18fGRUjTEbf`a?3uNz`rImdGS8{iE}JH$to7Hzg&t_kcl5H!3B=LJV$tVYR!JmGVz%OPlvoYK%A1c8Wa=gv=xI4gjhQ% zwAZ>viV0LNGhGcrc-R2Cop2}j`i)ZHtERAZA`i!rfESrToh*>7;W#X0PMknQCWNj5 zptQUla)Lr^RZ5s-1g7uvM1BatAZJyOB+(2kdMR_?p^yoEknJV#MdYGsK2d~$e%nPd@i_tQW1AyZc<$&XjI0GakeVt@ZrpG08CN_6vx5Sr(7dCjquN6ax0 zyC^IZa*UbpJkM!a$bpr!a;KnB^Q^4M2s;r^uFn4}7wqLsl zX)>0x%D}LCJiBWD(YinYiS%NzJt7JnB0GC=l8AkT+vNFdv0k8DPJN{Unioj?TFDBL z$az{FpB{nx30NpOFI6-&ajcwd=q5B>Og4icM9p~)?68L}e`NIwDn_gqgq*1{&_@Ju zCCXve=Z`4t7F(xZhVg~*MF$c{;wn!O8Cjke-cl8e%}CRI<$qpV>-_0ky^?LSQMryj z2<$3NO5*--NT0;fmM8veK<3#IDrY$!ufkfqwPl#o(2%_|6#J`94oR$Ma*n@3ybD-+ zRj+ind?cn;Myo}Bk&bAx`5N7Yk)WiksU&zR-I4EIj-Y3h!xGgFuoele*NWG0^ZoQS ztj;4QV+nJI#@|aj%Q1FCuVR~QFm-oj-^Gr1i0l;e>dn1902M-OcCKu4ikBRkkz`p2 zPNj=K<$W=BDs3-}z9BaKBz?h4@YVdbDOcLMQ1=q%6^*~=;N(I4l4@Ia2#)& z5qy(e0vCTiQ1j81*O`{-lzT?~;Ha8?I*uAm4k@pQ{8jiUN5amT$wZKsOb*oQM>$P) zyMX0WK~d9#s74d9kM&>P9G-2k3%j*y%aoM!k(>#zpuVMzFa^vxSS-B12YzJpGH9%% zobaW_DKRh0X5521to?`)2cP93=f?CTIjq=+J=&cM58E5v{lE{Or8K~? z^+}S6dQrGY%MN4dG$#x$4vYYGozqgWTF#pcjo}!~ryF zth}CH!Z|Cj?Y4kJXM?U6P1GT(I8AI(!7qBA+cQ600mZrFDvhVi9knX@-Tw@6DAYW2PBm=N;lv`%B+Xy&qc6NlmoS&&MnS1M)zdw0xg#M#N@krH_5T^ z@LUI@7;VZ3OoA4?gp27a6znP!i?m@@t%^!wvy{QpXimxV{c+&#k(McSIThTe5rIN; z4`4IVX!o(l_4F&MmU<*ZY6!X@xUI5eo3Tx!8pCf&kI20%a*&NMRBo;rwQ{0@X;dAz zb8YBGlM^b)N~UlEg&J(ZrEW5Xs2Q#&(+P44F;bfsc_C*rvV2u0e%OZQR>|O_rG?cJ z(7`(%N;W(u6yLIE|6YgVT{zRzJ&4nUoadVupn6Lf?upMBQ?QfWQuD~5lB`WiNzw%o zSi?5Aa=l8Tk7sAcyDGmkjXA*B#1@bZe=%&yJOo}I&aA<&BM2o0jjypr!%7&V>n6a?r|MCzv-g}6y4^p+vizlQy##p)`rbMR>Y>vsRzUWOH*PUmznV3;#%*cVOdj3baHPI%xd(RCG#I&T~qjXj++JfmS5+Gz0ob^cBfLY9JyZ z8IRP-IH{nV!(qFTQ`m43D!p`7;n7EyT(iekiz*a#o*ojhGUjUlvH$C9#M;~fKHx9S{rI@dmkDVDDjREADN zN*J8?4$ifkq*ysWG>f{$lyfC_?`O(!1&!2m5$=m{WBDq%uesGABZuwMH#8h4$-NpM z?pf{a#P{!tN^Lf1gGZK?KG^2fYbeemOBp3#{Tr5C z!a_UI=sL3bEfWTYLWPLXoKE^k4dU)r!Ps)4U0<9XuZ?#67J-ujJ|-2r{BQ`0Q}Q)< zy(N|Uy0y?vpS)$}8?B?O-!jq0@xw!fe7?reY{JruZ+Rk~EigolSFRF}rNpaH%dv3e zdEC`!X+07)3nxz(_|+NZLUL&h#&|QWqh$D?WOtP+&mopMrkv34S8=g}g!53jH5cV7 z_X>FnyKa;e+j)71>a>QIw*2*wN!F;RwHq-&eZKufu22{9y;j3vwacjCN8!PWaE@y^ zw^<2YphX@DsjVHA`i$`B__cvff4FI0^P&&q^~W!fV42doqKa`5Sf3*W+B87rSL%Qh2!Os!(d8A0deM zywXldUNlsM&C5MVBs=K*%9c$Cx40?oQ7PP7wlHDJW|=yU+nPvamWrVSR}wLhnmlUz1+5d(&6Z?Hx5P zKS%JHfw~g6nD`7Nn&jrE+n*`ag^lb4&NkhbONx`<6&xYl{c&OcXx`_Sj7u@@A=D*N zfGy%pamo&-R60yRXbHR>3e@-U-g zAoa$#y4$NN2=0>{oZ=j(<<<2bC-za6pqi8n0#!y@=8i-k!k}~$l@qm}J=pOVIn|%V z_K2uB{AtzhFl02d$nC1WK;@Owx@Z~0lgKq)-4+*}6i9t}^$!4I&*YAMVJ1P-BJa3_$ zIL6az^S2~Y#5~oHqqMZoH2IMLffc`pv*w#;_cN?)4OFE`UTWM^sVg%x=9nY`rq3Isn-S%m7LJ-KeE@~QH5#0awa8Cad z-kQY+KKBee1Q7k#H+9CL7#f0YR}T}=Q4_XoHnz#hX&IW77w09_A`^M%mI#D@v<<0E z-eNv!K}AM|zj#_i_%U^bs@YiVizks|qOW62F;oy7ML=Ic& zc4vjGE}2Azl*+d$VG&PIzN6wAePXV?qBHL$HKCe&9%<@*UvO7;^ON?|yL{rcl#F6d z$}xvjx-dJTy5H59$wVDFg=qtf_BtJH6DcaEG zt-gzxuC2TK?4D)00sW3)N%Iad_ak zALdGVPFgEF*2|wS^niLq5fiaMqAi>vzv!E|w;)>YgYVxrWOt8Q{Fp!HU3775WO}L^ zs|{&r6^$O(k}7z_+N_U>ArKFHayUv|d>tUTdSk4#4$}!?rsT|8co?Tr~8e^gc*IJR0#69 zVnfa&ba&^N7Gnxbr~~Jvpo>uLR{M#rSyYteK9Z8uB=dxpdk%h7VcF|cGet6zUl59= zd>bY^Hpx-U zOe#t8(`qp2?_&dR=9MOfimWy?LJt8_>5%eL2ABsK|Qqk+)+=tz}9Kb#=1 zOA)0gmeMrxw|n`Whg3gvWO3#MO8kQigJk>E%q#L^8wzV@T753kK>4wrw^$r;T`8Q* z{K3HL`Gw|07Y}P@FD9+OnN0v9_{qMD8Er|j+W_@VZb|YPxhPfY4)vyYh#BX3TyAb? z)>(Vuc%5K<5>?jGno)cKJq9xe)2I^8VpOSRDUx%VJ6eVzr}HtNOF!M&dD)3oIE7@T zTAsyb&BS7E3hX^uyb8&Iut$>c@@NN}wOgzishKVGqMo!?t8-3q&+)4lk1%I2hQJi{ zKWOT^1AFInZY>H(_I%Rk&E;;%uCi8>8QxcJpKHRMt~TA8%x{k*B^fQItp>P| zX8_70tn^Vt`chsQebv-}4PWIatrjV|q+Z#XPxr3G{5NarVX$fE!`EO8(VgT1$}D1~ zvuJZ6ROw{3=^J~xoNmqC7B)kXMP*eVTMGARxzK(i0;w9&X(jCxKKBQ&bqx6j%U;yR zoS~uH9)AYyjMNU>GYLGhHkZ+8RU5WUI8LNwbjg$Q-xg{WrdiXmDe`3^Q6CUR8TB6W z4#bR{XL580R+Z8y=+=amJEOEQO}0qkEw1dZGjGiNyfUW{0D-CI@-QzMfsElmGh{G! z>+jCA|pMY{ND~7uzt%Uofe6m`{X7P)aKI z<}ql!goKvmA(UUmzs;0I-U|_!_Ly)qR+cR~nEJ-*-<4_k9LF#{?j15`8xr%3Ua3|k zvSDf=(^9CaXvSFeek$J6UNiMOSYqW8Od_JUc~9vB9u7Wb(eay%5qz0riTq&>M9YRkL#+i8Zi6L0H9&_h9Li9uxa;$|=*!Zf4n zj$EiIO)$w?gi-HT3{062!}8A%x+g`_8lI1v*l|X}mh>Kso9v6wI?Ad~9Tg9<8EkaVU|W)>gKLN(#aPc0#EW4$_L%-mYL+n82Opds0sfWnNF_e6fcYVT24su3P+d~o zmo=F!@TpD6BWQRel0aKd?f0_AKWIW^9KvRNKrZP9^!*41BkVi1n~2v>u`A5qmZdpD zN95#m zABdU|&^2u|nO5lM{NxgHuC(5_%x{9~>ZvHQr|Fc?lki>eZL5A&AxRX%df|uZ>3ZeZ z>w2%o@wkgn-;jI3E4EeMBE!r#u|2e7k~9OY&{vjRJ9l zJjxt2-b&d$_tIId<^EYQY2ETws1b4UCzXmZQUke(kGRTEczEo&9eLV9EsN>NEeuoWbY8F3gX!CmKqey=q}Fit|eq*BqpeBbVa zb|WNmaL`K*Do-D*#;&&@c5dzD373oC8Sv2hP5e`~V%(KlxoBNBvLGO%)OhWnEP6^j zUi82&!|jCCVg&7Yf2kHuLJn5=g0jP~8U`bb>+~ucBPm%}o;C8ik88D4f)<5;gjqb{ zo%nh$A+tysoC|Tu%R?3xAkA~P`H{q1@{+Uey+pxo-NH4d^FB#gw2xLNf z;*RVs%F7Wq3Ph}c{&53st72>Siagne-X-j@e(w<`L?WC|twlwwN^=&M#JKK4#s9K4 z^=fTxBi@)!a74$nVkSLyVC_ZsFu9(-fEKjKt?oOcJ@i58UQ8N9l_~-Z!FB8eqpK5l0+6z7eLSzp!g70dpblu5%w$+uT zWeSC!s)@x!QuOY$^`T){n@^Gb_{p6Th)BjW?mmf!`O=+fT2V>aH&~^UE6hc_{E|8{ z@z6o&nDU;p&oZk$c|*;ftquIy%J|_sDh4U}d!K%{;t<5yOp}!k8`Mo{RZpqW3O;Y% z+o42(dp@vdfB)UbDP{Xnc%OC4mQy&Ef_3hm-BIhPr~Zi(o6fm*G6<*HQ%E}rE+fcJ z4D5Mw{do8{xZP1M7FkvOtKm;G-nzaqtbx`k`0TS@gMv;QpS2p`MZiSWfXwBnQI zWQv!!^rUu+wqc%6oe?)~;fv7Fq&hz0Y!)XCeLpDn;B@&<#mQL^zmA`oL*%mj`e4{d zH?XoIw6L8J@AR@)bQ+hO)a5d-RnodJJx?_Ft4moiq9~C(g|MaDe`_1OD{v0mcfCkn zuw&mvxFZtf=Q6mu6W4$Fn^ev^X?w**!rm(_ndX(loy9aF%t_8ildE{nT|#m~<6JS9 zqI5e>XnKlGa$RMVliHiJ3dQ*1TRa)x@ftk&tSTI9rAWYRIg}jQeum%KUNkl^uO_+x zd-9Wz8QnCS1<&(_ZXv-s-Q+cl-{q`RlA|0Mkb7K=2>3qu#7orrGgI+M(X_UU z`HaoV!jP}>0Rqp0`N2q#u?!OGD4`0m9Q58ldN4ImL~@v1=J@oL{DOXTP`oV8m!<1c z`({gg9W7J1Zbd^nHd6FSi@WqC5mgkm?swh^xh8cN64*9;hNO|QgfnUQAGBVkh8_nW zvtX>}-58(GZx2pEkF&$IpM+a(ksg@3l8!XK+zCXlt&4}P82qj~+Z({_ja=ApEUFuW zYR@PSdcoW%nNp-e>vK#Ko4+m@=eJp9aoW{D6=!s`!1Y8^6D=<|z)9iGf&nQ<4^ovY zKwz>A39|EH@Yc-ij3T40T%<@2%|4Y{}~9cq&n;7 z4!w5(`o_J~2ZMW=!2_Y^Q7ZOi=aa{rjX2Fp%Npx3B`5CJ%^5-mUz!$M#j4?@W^G2S zF2C|>b`0qwV-uNGUVLV@hViQ2PvdEm$rX%g(KvnBqoR>~Ko}_T_@k?mnvay75x6JG zp1AI;?sJF7e#pI#J>TEDTQBT@vdv=_YnP*olm!Z4KGfQiwezKRMlbkyG?b|MmD96M zhH&98?Y4>Hu^o0ova&VQA*(Eh1R4An4(%TK)(S&Z(M|BRudb*xo_=BEtDb@rZo>|# zWtY-yb-KnGZE?1axvS=3qQnO;(RbWLob{qGJ8G;Ht@Z$9Yr^mtU=g%;m(kxu*xBgb z1M1Tt(RoA=S#_~TM)3uB-~%5&Z)Ak7slVETb>b>l7qib;vA#q6C9b3$O0dGFB@~s4 z0?j>7O2#CAH0lB7CKg`$CJlq_zMhBm5=4kYx0?bpw1y!KFj(tFZgL`49bf!+YE0~N z(vJ-%uT=~yPW1QojZD*}NM49zHDJSEq}06n`o22@_uNzehcq$*Ic4u~*tQA%X+j5m8v#%}E<; zs~S1UY!H69u+e|c#;esz%Tn%?b7Q8vFU~wy_7SHA+iH2k;e|`DkRbydqgh+NKYd5Y zTNfr&srv4a%wYZ6g)85>0`*L2pl|26qZWap-t?m_Iw6gI0}m%N@mHp-8j=D4{Bklc zvS=v{#~=hRn0SXxSo0Z_cr%wxIj@P>4u=jwB%_7J&RnbpkYC@;mXs6+ExR+8$S2I0~QWB^ttk` zLnT!VmzhDg9_oqBGe$I{Z#)xc!;4N{70LbuKz*q?0E()sDZ&BB3=>Mw_n9QKxK7He zc=WtI*JFv#ReN4ntXOvEUR&j;a?|OqNS3~|LlDeSCldBCoLX|SuH`9wt=MeRWk%jg z4nUobh=h3`)2l9-6>GJvqiN$XzNA~LH?DW#%G31r;Tqlfki z3O&MpK)I8=Yc%CKS#L!scrqk^!misG{7JIPl7eyiiBVMITWQl+ zpR?^=FV4V9DXU?rHSX?VcZl%`t_n>$vao5pf;?R@jX+wLVCf&Ub_<4y);OD0k?;?m zbu%Gztd;1rSU$`y=aWhXKmnRC%iOsz)nqxUB`%#|mYffx%0)sl8AZ{Fl zeM{hc_Xz!-_t$A{1UD(3FFT5{H0>}{uGfI)dTAwiL+re+|e>9vAz~9Zfec9GO_3XUcfVM;30KNwbvB)o{Ii5%|RyMHQ3Wy|Na!W&L904>cs)09*HKsnACI zcN3BxjIF6y#XMvefK$SP)T>wcr{zfb@i9jFWR!B>6oy>3mbubTPAR`Ue6o9%;_v)5 zVC+q1rah(huUeliQR6*ngJ%vU*nx3YL|Ug1CiTNtZn?^oIso{&R6X`W@aM|JG`6L$ z#En*wO+cCbV!d3$L-x(0tv_dFdPwZ)f^47?)w!poM|ag4N3vQqbSPO9 z%YQiJF2;%AW_}pZUT!)owiDasa)LND9)@Kp&r1SX;)T43un=s@t0so?e{ z!}oI4f$m+@>T7WCw8~4Sa2X6y1IB}X3hw@`MD0tndrFJJgLN^Wf(o*XX^x5UZu`h| zaw(4Zq1N7kUB~6vPczd&F#jOMomupDW6UZ9|coLO-+_>B6<^w?eKWN7Su?bs98zKjI+ zPrF43Iilf55L3w<{sN^lwXH8z1cH+Xd{k> z_8K0)J{1w?67YmTH!nIc(=M12oWhw#!gjkkx7$s?l6Q^OlT3)xuY_0LciSw24ggq^h0(LV%0=?m~%JxupGY z`?_09P9WWVUGY{w)9k{w7&EntP3nhihuEp8|LAMp!4n!oh>qWs+lEq!XIKsn1v0hL@oS@8tx7*ER1C1*dMZ?4jpV^Rt(bKy2LyZeCs3mWPIhL&i7r-*s zr>CS8RrC0)tEdE8)!pTMi?Wi!?`(x!|-4!mmkTW0N&)UfI%uY)0<|wy7tV15->iUZ%-@ZAjHVuAz!u`?!{d<^VsKBbh5*yDFt8^|K{&E0gVV z)mgEP*sd`DkcxrngqTi1%HA@#E6-fB_o*nbwB}Dn*s}d_v*KOKS}uR-vF4nePC9!C zGCfsf@nH(g(_}<K`AX`ip?7d3jJ-!?FmGHX zY?uT>zG3Y}{=$LL{8L#-V&YzzYf4?^#pCvFC8ydR1toqT!=z1z6~RX@f<|+9qPc1p z2}RtxM?$H|W%q`H>z}BOzqXkUEgILrOT1p(FvAdzI-2_lIu!bBu`Rh1Tl!q57|Afv zSZ!$Bss%75Ica^dtvEpD+Aobhl6W1*gTgk9YNT;F?90OA^COD^wOFm!9Dmo?`pDg~aOHWx9 z`&v3tqxt1}gHeh5AoUGHX>+Vm@|3sZjt21T!4t+J_SKw#nJ8GXo1(Am?k(Cp5*_gV zI*y6??SeC&XJbrz-o~~yG{u&x$gIfMRYf{5omqCK^>HZq!1E<1tXb+Vp~D$ za?-(?gk;h>J-$-q^RMd}6_7rekBK1vApCBVqj#O{C z!ee4O85TlslzVQ?vdFN zSHGMSEBz3HLih&$Hs!mJ4+mx~Ec!z9!<>}wIcOy`y|~7E*q&q*<6m%s7#~BYfjweE zYY~Mb-cb+H?oE}4HCnkHDpx$Ao>8o=;A4`{OX}G@oF*D#i9QVJN$|%$V#fLgt9KPB zsj~dheORaSP+y=h5f-K~S$`qUvVE3F)|w#NaCTTjKP~R$mc`W#rga+hE4WJFINEm3dh-czpW5w#ZBGu6ZRGKnX4kgC^jUr5&^2@0nuL#JZZDwn z6IbNIpDr5*7n7D04?wMuMk_Ar*RK|kN3b}R$X2&UwTv*0IeA6PzW3$1fLzzC zlXEEHXYc6-{~8@5*9%n9hLoQ#ykT&GUZaG;`nBLe?9y`i=6Dz_;~C29Q7dIuyQ{T> z4e6Bg#K^?;A1c6g-U%LAx##^?Pd9qkGleFvIF&drk|)+0*eL6!TYTpU9GBbeB^T!f zKc?QKRGLmUVnOzzNPB0IhB{V=hzV@dn3QWgJy0nl+7ZpWSaXRG_g1r(<)ZSk+y;o8 zoB}0Nb>Af8CXgYeaLi8YSTW*>;7t&A#JQ9!8dgM^X|I@iJrrYiOzv)&LF7r=go)O3WllyN*s9}Dd%xbo{QdUsbiAr{&CXIG<&V*{+l#P1f zbSPFYK;4=#j$>{iy}#H-Ej15WxP6=_;KF;_`f6zcAIRH<_cTsM|5OvkIF7xA`nXS& zxi!mKN_uasBopcHX^T=95jA6cFFP-}-YL|>6jr8CH}cp!`*U<4ZhA0?J4y|lI})8v z>B^sP#@LC?7Kw{Ze#hYCW_L#S>10*W166ylysbLCVoDAd@j+oWf>D#T?rEZebS*&F z9>;|+3LvNFU@-8w&zJ3K?L^rRLC=)>FYKI`CqrstWzsJ|x#4AVE`)4+T)57)5c83M3DIiB4a^SfhS zwzED@S9M?zwFe_4cy{L{++yAnaEU!_!;DXZpp5v}9{->fDq6R(4b|R_mxUXk7P)Z8 zS3SB2HK8Uw6rv|hF>S`l@v4tvYub%0Z4*S?kNl$6CU^^kUb5d8-u;gXR*sBLGFJUo zzGW;d`O2!i4YfQyH_BHMp8WQD2`91@2+1gV{Pvy>F!>-LOzMR?RxCjMyegIBADl59 zcK4!qts?$SOB5T}{!pTJ%5;PCRbGC(g2Ntf%7p;@E8maogY3EL{f2bIAY@bgHx4bA zf(Id*Bq;WVt5S#qbDIf@W=E`qKej7!0O#jbaa@^OA4{m{*U0|6k;(P_VyCShs9*U& zNl1Gh3}_;+`z!XmsE_XMbOCeR?W?MTnxkF@RVA zsc}!u>G(7Fzg5mlE>&gz7#xsW=FQAIAo5tT<{%T0*w5_)TpTeU)X*RI@=VZa z7xUTM=o`87sH8h_ZFlZ?T&U+JBlaPcx&9_cuC4K6VF}gl36a(D9mDw}WP4$d~ zyj^rp4Rl`0HN5gRE29Re-^Kuzv5U{9!;^h8HTSE~CNQuUiCQ$6Fd+RJVFwp$+y4<~6n~VdM?DRyuZD zT39hNf2AVM`UfqqS1txH_`$l^6S=5HPC-4E&}}dq4bw=aUS^R)d z*7*Di>zjei%-cB7z&bp{GijgjbDDV7&xag8A!Bf+U7R!Knd|`J+sW^AwI>oUHAwW1 zaLxuVioa=ew^?34(u-uGeXQe83-iNxL|86yAD5eG)Iozp+`lqN4M+u|F9sT8gy;4- zKybKUwJ-Seebc}fuLGpZkT>PAwDtH_-Nbz^!uwMsrZ!e|!tH2u036}a$DWapi8F$6 z#96>N*Rno6a>f^h7-12 w~l0Q{hU1}b59!hxdu9@#nUzwV9%07cNrXqb1g*Zo4? zR{>f->)u!U9M}ysqG3AX2)#SXxKlK;@A_k|+?TMl*LU4v-&JMdfAX7Z?*}};utN)G zx!pR6U#%rX9Va07O3smiq1pVW+8F8Y{|JZ*iHo=;0d)SKG74Rf>|8N<&*cABqcHiY z1laL^yJn|dH(C!12XBPKfflnJR^B* ztM1}?qy;omDgFx8D&1>zXCutTYBqHPO70AY$+Gwh2Xl2-&uH|9NmODJC;`nJ8IoqH zG=hiTy2t@l)Kl={0K?&HDn9KV*Hhs}Btz>93mVcj7^-;P&fPB_H&|Wvsh}q*uaR(2 zWitlzmxDHw#<+PrUU^tp*b&1}d$y!gEv1l@lx|5Gu4@x_;QfAzPxHpf%L#=W-R87T z18m?1S5R69^wXWw>uNzhR%W*2y#F2r)#V~3BVZl8jnaZ1rKFra;I&YS^=7Ve4RlAzG893l( z2V6|xg$&#dc=q9hJZg_<-qaXo0QVhntvhfj{-^M+i>$jdk4!$v{qeWB|B8(4^Z3|w z=Y#)K76byE5GbwzrClJwe>D7e32+2-`}OXY|98W%cYocoW9sg{xs%2I)1>wG125J; zXag8=5#AAiw6>L10Dr^Y{vWgejE~_vP0Zn5_uC188k>3GQ5581AC(8V6XIJ&^Q~s_ zeNr;0V}LcpI%$DdghR{hL%aiTqceqslOKl4dCYrYRZ+7KyKShHQ!P51vCR6L3jjB$ ziTpY**)4&vz5?-{CH9Ygu1Yn3JU(uQD$^oL`T@MANJ_~UQBVB3nb(t}_-n#(DkIbm zc>U-hIKsa|)q2>eYcv?xsI=Y_XV01b)PyrjR1I7g^4jYs1nbcJJ&^iRe2FbVQY3?} z^;7Zvo1_y(kIa=$K%5hc32R`5n5yGHXbjBjA5Q)VWHZygmT_En{(+Yr2_4Qsc;2f! zO?)2@dAfu#EXL$o1|tr9l82ZGN7?a>)J-(D9j6smJXnhJ)H{6NGYslN37b!SM8Wh- zut<2sl(ZPamKp>y1HC+TX`I?UPkzMd#0sI@wA6?i<}4a{m#qnw=lL$YN0;S~^u)hW z2L=oMhybH963E^5=$$2fn-^$6l8$A$9=j;ZRZ7~(S95z!(_a+PFt|FV`98ZmT0!Z> z1?)z$oqI@~7!Vj}%;daZU>hya_~l5g3+%TQnZ1?KI}eX!qpe_$qWCrCT6rE>yGQcg zK#fddCbs@}g0}C|r%m_Phr`f9f>QV`_>p9mX4W{<*hbL_Wo?#- z8k>&Na#Ch~pkit2wKScAX2(h6D7MlJIOddOa`cuX!M)ekpYMB~@8|dYo(l3*g>1U3Iy zW^K1JCcPJ@^{ZV697q+MHYtfQtc^>US*$B`$Z)zN4RnXPSY{F2i+xi9LRdvfiG{tH zaN;<2%}wg%6REA1&(*GS?Kt=zDmEPlk8_w4~Qo}QNx5*KYHFjIn4)R_eJ0&Y;5>&pxsNLQ904tIHU+MBv>pCtD zf~(R~39Ld?YkND+Rl9*Rh!q0W1w-mq9 z9^u0Aaa7(tYX60+s82t}-F;L)OxojYm;80X-|q9LBp$V#nfsfsVDoVJUs@Q;-O~Bu zja8~=CB?UH)IJyuL-#JU6fX!gvf!N+v8u+C`mCzPb&Gs~OdmnWgc z#f{UVq|CjCF_mBH6K95A`0nd;qN(~$&RgqkN{9QLhAkV9#f(Rumy z`6j%MhZMFxKQGgM_&ktb8y7O!Q1&7hGW#Co0gcjC6!=gnat0pTJBX5+jul6wuA9z9 zgq#7id#c|{04C?)p(P1cx}z{>f{q{}0CYHMxGWf8DBJCWn>wtei_cHOv2ezWS&0x? z4F&-bw?|pojtm!}!KhhoNfCln0e1kOHvW(^fJm7z2(L>c&Vxw3JNmvX^v9Tgg`ri{ zGKeGA_Ay~ly-{AVH;|^9~tNhV-`Q#yUWgz%CB&4@GcYms{MY<-W|K?~PTUYUi+d+w8*k2gays-In_SH3%|> zB0`=u^lcG5Ioc{nep))g2D*|)fVt|s!X2ns!b=ktEd^k-d?(knuZaE%ybf^4*;fYF#cs1 zEd#&cG|ui}QIhYmdq)8|*9iO2g630a=WaHg-uti)VcGQZBt8lL-4NfUz`!?MvL(n; zak1{A`+7`7sVZNN?i3&3OEb2Is?l=FjLAzE#e*+_VW1e^37!&XNy(pmH(kdBhIY7< zqvAUHHV7swd300eG8;sN?@2ThI1_nD=B&6Iw*!KPMI3 zA3s|$M;Oj!+3djc<4XH5g1bePUb+zO6dfqu0GUW%T4*k{y8jC!%gQsu&oUYdHO?G0 zkih%E2S5bDhDE_KZD6$OIMxV?&M)w9wXWmnXeG5IK`WBf?^`U7(sLA8F3Ccq!ohiGXh& zvcQ7@kt(ow@7eJV@bhH&9^qj+q9sn%ZUlMfW1-uZxsfVvkM5IHCC-~1a%|A--F@S- zC(}Y6E>BVE5}7r#ig((bOp`y_>pyoEYtoif;q0EtWoUGhQ`u&aOl2cuJY?EnA( literal 0 HcmV?d00001 From 4bacff4eb57bd25ebd765bba75fcf994b9b10e09 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:50:34 +0800 Subject: [PATCH 3/5] Fix CI test compile issues on usage dashboard PR. Drop duplicate instant-open helper, remove prewarm test without implementation, and align browser gate tests with upstream. Co-authored-by: Cursor --- .../StatusMenuMiniMaxPrewarmTests.swift | 48 ------------------- 1 file changed, 48 deletions(-) delete mode 100644 Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift diff --git a/Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift b/Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift deleted file mode 100644 index a45ad1fd16..0000000000 --- a/Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -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) - } -} From e6eb1388d457bd69353975f261a11417af237557 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:50:51 +0800 Subject: [PATCH 4/5] Fix duplicate instant-open helper and browser gate tests. Co-authored-by: Cursor --- .../CodexBarTests/BrowserDetectionTests.swift | 78 ++++++++++++++++--- .../StatusMenuInstantOpenTests.swift | 14 ---- 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index d0deecad18..b585d655a0 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -213,7 +213,7 @@ struct BrowserDetectionTests { } @Test - func `keychain interaction suppresses chromium cookie source during cooldown`() { + func `keychain interaction suppresses chromium family during cooldown`() { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } @@ -234,6 +234,7 @@ struct BrowserDetectionTests { return .allowed } operation: { #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) #expect( BrowserCookieAccessGate.shouldAttempt( .chrome, @@ -290,16 +291,62 @@ struct BrowserDetectionTests { } @Test - func `user initiated cookie import allows chromium keychain sources requiring interaction`() { + func `recorded browser denial suppresses automatic family and permits explicit source retry`() { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } + let start = Date(timeIntervalSince1970: 1500) + var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { + BrowserCookieAccessGate.recordIfNeeded( + BrowserCookieError.accessDenied(browser: .arc, details: "denied"), + now: start) KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in - .interactionRequired + preflightCount += 1 + return .allowed } operation: { + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari, now: start.addingTimeInterval(1)) == true) + } + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate + .shouldAttempt(.chrome, now: start.addingTimeInterval(2)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(2)) == true) + #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc)) + BrowserCookieAccessGate.recordAllowed(for: .arc) + } + } + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(3)) == true) + } + } + } + + #expect(preflightCount == 2) + } + + @Test + func `denied explicit cookie read closes retry scope`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + let start = Date(timeIntervalSince1970: 1700) + BrowserCookieAccessGate.recordDenied(for: .arc, now: start) + + KeychainAccessGate.withTaskOverrideForTesting(false) { + BrowserCookieAccessGate.withExplicitRetry { ProviderInteractionContext.$current.withValue(.userInitiated) { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(1))) + #expect(BrowserCookieAccessGate.claimExplicitRetryCookieReadIfNeeded(for: .arc)) + + BrowserCookieAccessGate.recordDenied(for: .arc, now: start.addingTimeInterval(2)) + + #expect(BrowserCookieAccessGate.shouldAttempt(.arc, now: start.addingTimeInterval(3)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.edge, now: start.addingTimeInterval(3)) == false) } } } @@ -352,7 +399,7 @@ struct BrowserDetectionTests { } @Test - func `browser keychain interaction suppresses only that browser`() throws { + func `browser keychain interaction suppresses family and permits scoped explicit retry`() throws { BrowserCookieAccessGate.resetForTesting() defer { BrowserCookieAccessGate.resetForTesting() } @@ -372,14 +419,25 @@ struct BrowserDetectionTests { if label == firstDiaLabel { return .interactionRequired } return .notFound } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true) - #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false) - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == true) - #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(1)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(60)) == false) + } + BrowserCookieAccessGate.withExplicitRetry { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate + .shouldAttempt(.chrome, now: start.addingTimeInterval(61)) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.dia, now: start.addingTimeInterval(61)) == true) + #expect(BrowserCookieAccessGate + .shouldAttempt(.edge, now: start.addingTimeInterval(61)) == false) + } + } } } - #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstChromeLabel]) + #expect(queriedLabels == [firstChromeLabel, firstDiaLabel, firstDiaLabel]) #expect(queriedLabels.allSatisfy { allowedLabels.contains($0) }) } 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 { From 1b4a721b7c4804f9af6365a5bf0991e2886983a4 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 13:43:07 +0800 Subject: [PATCH 5/5] Fix CI regressions on usage dashboard PR. Restore Codex empty-credits guard and upstream cached token hydration TTL tracking while keeping MiniMax token-cost support. Co-authored-by: Cursor --- Sources/CodexBar/MenuCardView+Costs.swift | 1 + Sources/CodexBar/UsageStore+TokenCost.swift | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 405b2c0931..9298fd7dfb 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -47,6 +47,7 @@ extension UsageMenuCardView.Model { error: String?) -> String? { guard metadata.supportsCredits else { return nil } + if metadata.id == .codex, credits == nil, error == nil { return nil } if metadata.id == .amp, let ampUsage = snapshot?.ampUsage, let ampCredits = self.ampCreditsLine(ampUsage) diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index e1842339bd..543396b9f7 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -25,7 +25,7 @@ extension UsageStore { Task { @MainActor [weak self] in guard let self else { return } guard self.tokenSnapshots[.codex] == nil else { return } - guard let snapshot = await self.costUsageFetcher.loadCachedCodexTokenSnapshot( + guard let result = await self.costUsageFetcher.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: scope.codexHomePath, historyDays: historyDays) @@ -39,8 +39,15 @@ extension UsageStore { else { return } - self.tokenSnapshots[.codex] = snapshot + self.tokenSnapshots[.codex] = result.snapshot self.tokenErrors[.codex] = nil + if let lastRefreshAt = result.lastRefreshAt, + now.timeIntervalSince(lastRefreshAt) >= 0, + now.timeIntervalSince(lastRefreshAt) < self.tokenFetchTTL + { + self.lastTokenFetchAt[.codex] = lastRefreshAt + self.lastTokenFetchScope[.codex] = "\(scope.signature)|historyDays=\(historyDays)" + } } }