From 4e3e744ca128794ecfe6575f242e9a5d0a76b4be Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:14:31 +0800 Subject: [PATCH 1/7] 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 4232e487916438dfb753ed9d48f5d209e72974f4 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 11:48:59 +0800 Subject: [PATCH 2/7] Align browser gate tests with upstream explicit-retry behavior. Co-authored-by: Cursor --- .../CodexBarTests/BrowserDetectionTests.swift | 78 ++++++++++++++++--- 1 file changed, 68 insertions(+), 10 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) }) } From 45599ee2925292066abac77d1c731412ca5077af Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 12:23:42 +0800 Subject: [PATCH 3/7] Restore Codex empty-credits guard in menu card costs. Keep optional credits hidden when Codex has no credits snapshot so Buy Credits remains available without rendering an error-only credits section. Co-authored-by: Cursor --- Sources/CodexBar/MenuCardView+Costs.swift | 1 + 1 file changed, 1 insertion(+) 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) From 814add198ce0375ee6105f217cce96275f1ea8af Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 14:03:02 +0800 Subject: [PATCH 4/7] Align MiniMax web fetch tests with credit-only PR scope. Drop usage_summary host expectations that belong to the dashboard follow-up PR. Co-authored-by: Cursor --- Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift | 1 - Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift index 62430573ad..2c99461acf 100644 --- a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift +++ b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift @@ -85,7 +85,6 @@ struct MiniMaxCurrentTokenPlanResponseTests { "platform.minimaxi.com", "platform.minimaxi.com", "www.minimaxi.com", - "www.minimaxi.com", ]) } diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index 32551184b8..24bc4e3964 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -336,7 +336,6 @@ struct MiniMaxTokenPlanChangeTests { "platform.minimaxi.com", "www.minimaxi.com", "www.minimaxi.com", - "www.minimaxi.com", ]) } From 2b6d15e73634ef1858e4b9f25e8c1896e8d410ce Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 21:37:24 +0800 Subject: [PATCH 5/7] Drop usage_summary expectation from credit-only web fetch test. Credit PR enrichment only hits token_plan_credit; usage_summary belongs to the dashboard follow-up. Co-authored-by: Cursor --- Tests/CodexBarTests/MiniMaxProviderTests.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 720d85d81d..e836bd004a 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1051,7 +1051,7 @@ struct MiniMaxUsageParserTests { } @Test - func `web usage fetch still enriches credit and usage summary when billing history disabled`() async throws { + func `web usage fetch still attempts credit enrichment 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) @@ -1061,12 +1061,6 @@ struct MiniMaxUsageParserTests { 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, @@ -1093,7 +1087,7 @@ struct MiniMaxUsageParserTests { 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" }) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan/usage_summary" } == false) } @Test From 0f399347ef71c4307e930bc5fe17ab53314ef37d Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 23:10:50 +0800 Subject: [PATCH 6/7] Fix MiniMax token-plan inactive status surfacing When token plan subscription is not active but `points_balance` exists, do not treat the response as a blocking API error; keep rendering credits. Co-authored-by: Cursor --- .../MiniMax/MiniMaxUsageFetcher.swift | 14 ++++++- .../CodexBarTests/MiniMaxProviderTests.swift | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 9cef976d00..646e30ad6a 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -905,10 +905,22 @@ enum MiniMaxUsageParser { if let status = baseResponse?.statusCode, status != 0 { let message = baseResponse?.statusMessage ?? "status_code \(status)" let lower = message.lowercased() + let tokenPlanInactiveMessage = + lower.contains("token plan") && + (lower.contains("no active") || lower.contains("not active") || lower.contains("inactive") || lower.contains("subscription")) + let hasPointsBalance = payload.data.pointsBalance.map { $0 >= 0 } ?? false if status == 1004 || lower.contains("cookie") || lower.contains("log in") || lower.contains("login") { throw MiniMaxUsageError.invalidCredentials } - throw MiniMaxUsageError.apiError(message) + // MiniMax may return a non-zero status when token plan subscription is not active, + // while still including `points_balance` (credits) that haven't expired. + // In that case, treat it as a partial/benign envelope instead of surfacing a + // blocking user-facing error. + if tokenPlanInactiveMessage, hasPointsBalance { + MiniMaxUsageFetcher.log.debug("MiniMax coding plan status ignored: \(status) \(message)") + } else { + throw MiniMaxUsageError.apiError(message) + } } guard !payload.data.modelRemains.isEmpty else { diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index e836bd004a..685147ee39 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -777,6 +777,44 @@ struct MiniMaxUsageParserTests { } } + @Test + func `does not throw when token plan is inactive but points_balance exists`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { + "status_code": 1001, + "status_msg": "no active token plan subscription" + }, + "data": { + "current_subscribe_title": "Token Plan Plus", + "points_balance": "14000", + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 100, + "current_interval_usage_count": 25, + "current_interval_remaining_percent": "75", + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 100, + "current_weekly_usage_count": 40, + "current_weekly_remaining_percent": "60", + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(json.utf8), + now: now) + + #expect(snapshot.pointsBalance == 14000) + } + @Test func `throws on error in data wrapper`() { let json = """ From 3f42c35b4d4acf3956618f4e2d8ac1f1dc040bc2 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Wed, 8 Jul 2026 23:23:11 +0800 Subject: [PATCH 7/7] Soft-fail inactive MiniMax token plan replies. Keep empty-quota snapshots when Token Plan is inactive so recharge credit enrichment can still run instead of blocking the whole MiniMax refresh. Co-authored-by: Cursor --- .../MiniMax/MiniMaxUsageFetcher.swift | 39 ++++++++++++++----- .../CodexBarTests/MiniMaxProviderTests.swift | 25 ++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 646e30ad6a..c54ba66ca6 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -902,28 +902,39 @@ enum MiniMaxUsageParser { now: Date = Date()) throws -> MiniMaxUsageSnapshot { let baseResponse = payload.data.baseResp ?? payload.baseResp + var tokenPlanInactive = false if let status = baseResponse?.statusCode, status != 0 { let message = baseResponse?.statusMessage ?? "status_code \(status)" let lower = message.lowercased() - let tokenPlanInactiveMessage = - lower.contains("token plan") && - (lower.contains("no active") || lower.contains("not active") || lower.contains("inactive") || lower.contains("subscription")) - let hasPointsBalance = payload.data.pointsBalance.map { $0 >= 0 } ?? false if status == 1004 || lower.contains("cookie") || lower.contains("log in") || lower.contains("login") { throw MiniMaxUsageError.invalidCredentials } - // MiniMax may return a non-zero status when token plan subscription is not active, - // while still including `points_balance` (credits) that haven't expired. - // In that case, treat it as a partial/benign envelope instead of surfacing a - // blocking user-facing error. - if tokenPlanInactiveMessage, hasPointsBalance { + // MiniMax returns this when Token Plan quota lanes are gone, but recharge credits + // (`token_plan_credit` / residual `points_balance`) can still be valid. Treat it as a soft + // empty-quota envelope so the pipeline can keep attaching credits instead of failing the + // whole MiniMax refresh with a blocking red error. + if self.isInactiveTokenPlanMessage(lower) { + tokenPlanInactive = true MiniMaxUsageFetcher.log.debug("MiniMax coding plan status ignored: \(status) \(message)") } else { throw MiniMaxUsageError.apiError(message) } } - guard !payload.data.modelRemains.isEmpty else { + if payload.data.modelRemains.isEmpty { + let pointsBalance = payload.data.pointsBalance + if tokenPlanInactive || (pointsBalance.map { $0 >= 0 } ?? false) { + return MiniMaxUsageSnapshot( + planName: self.parsePlanName(data: payload.data), + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + pointsBalance: pointsBalance) + } throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } @@ -1615,6 +1626,14 @@ enum MiniMaxUsageParser { return max(1, Int((Double(boostPermille) / 10.0).rounded())) } + private static func isInactiveTokenPlanMessage(_ message: String) -> Bool { + message.contains("token plan") && + (message.contains("no active") || + message.contains("not active") || + message.contains("inactive") || + message.contains("subscription")) + } + private static func shouldRenderQuotaWindow(_ input: ServiceUsageInput) -> Bool { // MiniMax Token Plan returns status 3 for quota lanes that exist in the schema but are not included in // the current subscription, for example Plus accounts receiving a video lane with 100% remaining and 0 count. diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 685147ee39..6ab1ed6ec7 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -813,6 +813,31 @@ struct MiniMaxUsageParserTests { now: now) #expect(snapshot.pointsBalance == 14000) + #expect(snapshot.services?.isEmpty == false) + } + + @Test + func `inactive token plan without remains returns empty quota snapshot`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { + "status_code": 1001, + "status_msg": "no active token plan subscription" + }, + "data": { + "model_remains": [] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(json.utf8), + now: now) + + #expect(snapshot.services == nil || snapshot.services?.isEmpty == true) + #expect(snapshot.pointsBalance == nil) + #expect(snapshot.usedPercent == nil) } @Test