From 88dfac6c959f7d7b4dde97d98515598589750662 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 00:14:11 +0800 Subject: [PATCH 01/53] Fetch MiniMax Token Plan recharge credit balance MiniMax API remains responses omit the console recharge-credit balance. Fetch it from token_plan_credit when a browser session cookie is available, including API-token refreshes that also have cached or manual cookies. Co-authored-by: Cursor --- CHANGELOG.md | 3 + .../Providers/MiniMax/MiniMaxAPIRegion.swift | 14 ++ .../MiniMax/MiniMaxProviderDescriptor.swift | 30 ++- .../MiniMax/MiniMaxSettingsReader.swift | 10 + .../MiniMax/MiniMaxSubscriptionMetadata.swift | 30 +++ .../MiniMaxTokenPlanCreditFetcher.swift | 126 ++++++++++++ .../MiniMax/MiniMaxUsageFetcher.swift | 43 ++-- .../MiniMaxUsageSnapshot+Metadata.swift | 18 ++ .../Providers/ProviderDiagnosticExport.swift | 2 + .../MiniMax/token-plan-credit-normal.json | 29 +++ .../CodexBarTests/MiniMaxProviderTests.swift | 22 +++ .../MiniMaxTokenPlanCreditTests.swift | 185 ++++++++++++++++++ .../ProviderDiagnosticExportTests.swift | 20 ++ docs/minimax.md | 4 + 14 files changed, 520 insertions(+), 16 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift create mode 100644 Tests/CodexBarTests/Fixtures/Providers/MiniMax/token-plan-credit-normal.json create mode 100644 Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 113540cdfe..13f1f0b592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git! - Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree! +- MiniMax: fetch Token Plan recharge-credit balance from the console `token_plan_credit` endpoint when a browser session cookie is available. Thanks @Yuxin-Qiao! ### Fixed - Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely. @@ -20,6 +21,8 @@ - Cost history: choose inline, submenu, or combined local-cost presentation. Thanks @Zihao-Qi! - Confetti: optionally celebrate session-limit resets with full-screen confetti, configurable beside the weekly-limit celebration in Advanced settings. Thanks @bystritskiy! - z.ai: support saved token-account team usage with account-scoped organization and project metadata. Thanks @zqbake! + +### Fixed - CLI: show session pace in text output, expose derived pace data in JSON, and honor the configured weekly work-day baseline. Thanks @kmatsunami! - Claude: add a combined "Session + Weekly" menu bar metric that shows the 5-hour session and weekly lanes together (paced on the weekly lane), matching Codex, and classify lanes by cadence so a weekly-only account is not mislabeled as a session. Thanks @Shengqiang-Zhang! diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index 56e77e2c75..f94ec771ed 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -8,6 +8,7 @@ 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 billingHistoryPath = "account/amount" public var displayName: String { @@ -37,6 +38,19 @@ 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 codingPlanURL: URL { var components = URLComponents(string: self.baseURLString)! components.path = "/" + Self.codingPlanPath diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index fab28f9f9e..473ab898a8 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -86,12 +86,40 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { throw MiniMaxAPISettingsError.missingToken } let region = context.settings?.minimax?.apiRegion ?? .global - let usage = try await MiniMaxUsageFetcher.fetchUsage(apiToken: apiToken, region: region) + var usage = try await MiniMaxUsageFetcher.fetchUsage(apiToken: apiToken, region: region) + if let cookieHeader = Self.resolveCookieHeader(context: context), + let cookie = MiniMaxCookieHeader.normalized(from: cookieHeader) + { + let fetchContext = MiniMaxUsageFetcher.WebFetchContext( + cookie: cookie, + authorizationToken: nil, + region: region, + environment: context.env, + transport: ProviderHTTPClient.shared) + usage = await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + to: usage, + context: fetchContext, + groupID: MiniMaxCookieHeader.override(from: cookieHeader)?.groupID) + } return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "api") } + private static func resolveCookieHeader(context: ProviderFetchContext) -> String? { + if let settings = context.settings?.minimax, settings.cookieSource == .manual { + return MiniMaxCookieHeader.override(from: settings.manualCookieHeader)?.cookieHeader + } + if let raw = ProviderTokenResolver.minimaxCookie(environment: context.env) { + return MiniMaxCookieHeader.override(from: raw)?.cookieHeader + } + #if os(macOS) + return CookieHeaderCache.load(provider: .minimax)?.cookieHeader + #else + return nil + #endif + } + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { guard let minimaxError = error as? MiniMaxUsageError else { return false } switch minimaxError { 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..b6b881e093 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 -> MiniMaxUsageSnapshot + { + guard snapshot.pointsBalance == nil, + MiniMaxCookieHeader.normalized(from: context.cookie) != nil + else { + return snapshot + } + + let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID + do { + let balance = try await MiniMaxTokenPlanCreditFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + return snapshot.withPointsBalanceIfMissing(balance) + } catch is CancellationError { + return snapshot + } catch let error as URLError where error.code == .cancelled { + return snapshot + } 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..e797256e27 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift @@ -0,0 +1,126 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +enum MiniMaxTokenPlanCreditFetcher { + static func fetch( + cookieHeader: String, + groupID: String?, + region: MiniMaxAPIRegion, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> Double? + { + 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: region) + 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.parseBalance(data: response.data) + } + + static func parseBalance(data: Data) throws -> Double? { + 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 self.balance(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 hostURL = URL(string: "https://\(host)")? + .appendingPathComponent("backend/account/token_plan_credit") + { + return hostURL + } + return region.tokenPlanCreditURL + } + + 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 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.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 49d208ede8..17269fb377 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) } @@ -440,27 +443,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 await self.attachingTokenPlanCreditIfAvailable( + to: enrichedSnapshot, + context: context, + groupID: groupID) } private static func fetchBillingSummary(context: WebFetchContext, now: Date) async throws -> MiniMaxBillingSummary { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift index d5048761b8..133f78517b 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -38,6 +38,24 @@ extension MiniMaxUsageSnapshot { subscriptionExpiresAt: metadata.subscriptionExpiresAt ?? self.subscriptionExpiresAt, subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt) } + + func withPointsBalanceIfMissing(_ pointsBalance: Double?) -> MiniMaxUsageSnapshot { + guard self.pointsBalance == nil, let pointsBalance, pointsBalance >= 0 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, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) + } } extension String { diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift index c12c59f857..1082c9958d 100644 --- a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -427,6 +427,7 @@ public struct MiniMaxDiagnosticDetails: Codable, Sendable { public let windowMinutes: Int? public let usedPercent: Double? public let resetsAt: Date? + public let pointsBalance: Double? public let services: [MiniMaxDiagnosticServiceUsage]? public let billingSummaryPresent: Bool @@ -438,6 +439,7 @@ public struct MiniMaxDiagnosticDetails: Codable, Sendable { self.windowMinutes = snapshot.windowMinutes self.usedPercent = snapshot.usedPercent self.resetsAt = snapshot.resetsAt + self.pointsBalance = snapshot.pointsBalance self.services = snapshot.services?.map { MiniMaxDiagnosticServiceUsage(from: $0) } self.billingSummaryPresent = snapshot.billingSummary != nil } 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/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 03dd11c874..ea50951f98 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, @@ -1198,6 +1202,24 @@ struct MiniMaxAPIRegionTests { #expect(url.path == "/v1/token_plan/remains") } + @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 wins for remains and coding plan`() { let env = [MiniMaxSettingsReader.hostKey: "api.minimaxi.com"] diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift new file mode 100644 index 0000000000..601dcf247c --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -0,0 +1,185 @@ +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 balance = try MiniMaxTokenPlanCreditFetcher.parseBalance(data: data) + #expect(balance == 20000) + } + + @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 { + 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 = 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 merges token plan credit balance`() 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.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") + } + #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 snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + #expect(snapshot.pointsBalance == 20000) + #expect(snapshot.toUsageSnapshot().providerCost?.used == 20000) + let requests = await transport.requests() + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan_credit" }) + } + + @Test + func `api usage fetch merges token plan credit when cached 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 = 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) + } + + 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" } + } + """ + + 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/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift index 5209e3596f..8ddbbf1b82 100644 --- a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -412,6 +412,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..bf4218b208 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -39,6 +39,7 @@ 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) - 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`. @@ -50,6 +51,7 @@ falls back across the provider's supported web requests when needed. ## 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 the browser session cookie. API-token remains responses do not include this balance. - Web-session billing history, when available, is mapped into the shared inline usage dashboard: - 30-day token trend. - Top model and top method breakdowns. @@ -60,6 +62,7 @@ quota card and omits the chart instead of treating the whole provider as failed. ## Key files - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` +- `Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` @@ -77,6 +80,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 972cffac68510e375ecb0d2d00ee3cb8638bfd5a Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 00:34:45 +0800 Subject: [PATCH 02/53] Address MiniMax credit balance review feedback Propagate cancellation from token-plan credit enrichment, keep credit fetches on www hosts under MINIMAX_HOST overrides, use the API region that actually served quota, preserve curl group IDs, and only merge explicit cookies into API refreshes. Fix released changelog structure and expand regression tests. Co-authored-by: Cursor --- CHANGELOG.md | 2 - .../MiniMax/MiniMaxProviderDescriptor.swift | 33 +++-- .../MiniMax/MiniMaxSubscriptionMetadata.swift | 6 +- .../MiniMaxTokenPlanCreditFetcher.swift | 6 - .../MiniMaxUsageFetcher+APIToken.swift | 55 +++++++ .../MiniMax/MiniMaxUsageFetcher.swift | 42 +----- .../CodexBarTests/MiniMaxProviderTests.swift | 8 ++ .../MiniMaxTokenPlanCreditTests.swift | 136 +++++++++++++++++- docs/minimax.md | 1 + 9 files changed, 222 insertions(+), 67 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+APIToken.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f1f0b592..def4875ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,6 @@ - Cost history: choose inline, submenu, or combined local-cost presentation. Thanks @Zihao-Qi! - Confetti: optionally celebrate session-limit resets with full-screen confetti, configurable beside the weekly-limit celebration in Advanced settings. Thanks @bystritskiy! - z.ai: support saved token-account team usage with account-scoped organization and project metadata. Thanks @zqbake! - -### Fixed - CLI: show session pace in text output, expose derived pace data in JSON, and honor the configured weekly work-day baseline. Thanks @kmatsunami! - Claude: add a combined "Session + Weekly" menu bar metric that shows the 5-hour session and weekly lanes together (paced on the weekly lane), matching Codex, and classify lanes by cadence so a weekly-only account is not mislabeled as a session. Thanks @Shengqiang-Zhang! diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 473ab898a8..1ba4813262 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -85,39 +85,38 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { guard let apiToken = ProviderTokenResolver.minimaxToken(environment: context.env) else { throw MiniMaxAPISettingsError.missingToken } - let region = context.settings?.minimax?.apiRegion ?? .global - var usage = try await MiniMaxUsageFetcher.fetchUsage(apiToken: apiToken, region: region) - if let cookieHeader = Self.resolveCookieHeader(context: context), - let cookie = MiniMaxCookieHeader.normalized(from: cookieHeader) + let preferredRegion = context.settings?.minimax?.apiRegion ?? .global + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: apiToken, + region: preferredRegion) + var usage = apiResult.snapshot + if let cookieOverride = Self.resolveExplicitCookieOverride(context: context), + let cookie = MiniMaxCookieHeader.normalized(from: cookieOverride.cookieHeader) { let fetchContext = MiniMaxUsageFetcher.WebFetchContext( cookie: cookie, - authorizationToken: nil, - region: region, + authorizationToken: cookieOverride.authorizationToken, + region: apiResult.resolvedRegion, environment: context.env, transport: ProviderHTTPClient.shared) - usage = await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + usage = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( to: usage, context: fetchContext, - groupID: MiniMaxCookieHeader.override(from: cookieHeader)?.groupID) + groupID: cookieOverride.groupID) } return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "api") } - private static func resolveCookieHeader(context: ProviderFetchContext) -> String? { + private static func resolveExplicitCookieOverride(context: ProviderFetchContext) -> MiniMaxCookieOverride? { if let settings = context.settings?.minimax, settings.cookieSource == .manual { - return MiniMaxCookieHeader.override(from: settings.manualCookieHeader)?.cookieHeader + return MiniMaxCookieHeader.override(from: settings.manualCookieHeader) } - if let raw = ProviderTokenResolver.minimaxCookie(environment: context.env) { - return MiniMaxCookieHeader.override(from: raw)?.cookieHeader + guard let raw = ProviderTokenResolver.minimaxCookie(environment: context.env) else { + return nil } - #if os(macOS) - return CookieHeaderCache.load(provider: .minimax)?.cookieHeader - #else - return nil - #endif + return MiniMaxCookieHeader.override(from: raw) } func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift index b6b881e093..4e1c70973b 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -275,7 +275,7 @@ extension MiniMaxUsageFetcher { static func attachingTokenPlanCreditIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, - groupID: String?) async -> MiniMaxUsageSnapshot + groupID: String?) async throws -> MiniMaxUsageSnapshot { guard snapshot.pointsBalance == nil, MiniMaxCookieHeader.normalized(from: context.cookie) != nil @@ -293,9 +293,9 @@ extension MiniMaxUsageFetcher { transport: context.transport) return snapshot.withPointsBalanceIfMissing(balance) } catch is CancellationError { - return snapshot + throw CancellationError() } catch let error as URLError where error.code == .cancelled { - return snapshot + 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 index e797256e27..657648a669 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift @@ -55,12 +55,6 @@ enum MiniMaxTokenPlanCreditFetcher { if let override = MiniMaxSettingsReader.tokenPlanCreditURL(environment: environment) { return override } - if let host = MiniMaxSettingsReader.hostOverride(environment: environment), - let hostURL = URL(string: "https://\(host)")? - .appendingPathComponent("backend/account/token_plan_credit") - { - return hostURL - } return region.tokenPlanCreditURL } 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.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 17269fb377..73582b3461 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -116,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, @@ -470,7 +442,7 @@ public struct MiniMaxUsageFetcher: Sendable { enrichedSnapshot = snapshot } - return await self.attachingTokenPlanCreditIfAvailable( + return try await self.attachingTokenPlanCreditIfAvailable( to: enrichedSnapshot, context: context, groupID: groupID) diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index ea50951f98..567a720748 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1220,6 +1220,14 @@ struct MiniMaxAPIRegionTests { #expect(resolved == override) } + @Test + func `host override does not rewrite token plan credit url`() throws { + let env = [MiniMaxSettingsReader.hostKey: "platform.minimaxi.com"] + let resolved = try MiniMaxTokenPlanCreditFetcher.resolveCreditURL(region: .global, environment: env) + #expect(resolved.host == "www.minimax.io") + #expect(resolved.path == "/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/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index 601dcf247c..813a5a70b3 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -37,7 +37,7 @@ struct MiniMaxTokenPlanCreditTests { } @Test - func `token plan credit enrichment is best effort when session is invalid`() async { + func `token plan credit enrichment is best effort when session is invalid`() async throws { let remainsSnapshot = MiniMaxUsageSnapshot( planName: "Max", availablePrompts: 1000, @@ -57,7 +57,7 @@ struct MiniMaxTokenPlanCreditTests { contentType: "application/json") } - let enriched = await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + let enriched = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( to: remainsSnapshot, context: MiniMaxUsageFetcher.WebFetchContext( cookie: "HERTZ-SESSION=abc", @@ -106,7 +106,78 @@ struct MiniMaxTokenPlanCreditTests { } @Test - func `api usage fetch merges token plan credit when cached cookie is available`() async throws { + 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 @@ -123,7 +194,7 @@ struct MiniMaxTokenPlanCreditTests { region: .chinaMainland, now: now, session: transport) - let enriched = await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( + let enriched = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( to: remainsSnapshot, context: MiniMaxUsageFetcher.WebFetchContext( cookie: "_token=abc; minimax_group_id_v2=2040544334402560487", @@ -137,6 +208,63 @@ struct MiniMaxTokenPlanCreditTests { #expect(enriched.pointsBalance == 20000) } + @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() diff --git a/docs/minimax.md b/docs/minimax.md index bf4218b208..89e27e0cfa 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -52,6 +52,7 @@ falls back across the provider's supported web requests when needed. ## 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 the browser session cookie. API-token remains responses do not include this balance. +- API-token refreshes only merge recharge credits when an explicit manual or environment cookie is configured; cached browser cookies are not attached automatically to avoid cross-account balance bleed. - Web-session billing history, when available, is mapped into the shared inline usage dashboard: - 30-day token trend. - Top model and top method breakdowns. From 2431a49f4bf534e0ae42a219f7e2591c2983b7d9 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 01:07:40 +0800 Subject: [PATCH 03/53] Persist MiniMax API region after global-to-CN retry. Co-authored-by: Cursor --- CHANGELOG.md | 1 + Sources/CodexBar/ProviderRegistry.swift | 9 ++++ .../CodexBar/UsageStore+TokenAccounts.swift | 10 ++++ Sources/CodexBarCLI/CLIDiagnoseCommand.swift | 3 +- Sources/CodexBarCLI/CLIUsageCommand.swift | 1 + Sources/CodexBarCLI/TokenAccountCLI.swift | 20 ++++++++ .../MiniMax/MiniMaxProviderDescriptor.swift | 3 ++ .../Providers/ProviderFetchPlan.swift | 4 ++ .../MiniMaxTokenPlanCreditTests.swift | 47 +++++++++++++++++++ 9 files changed, 97 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index def4875ee7..982db94a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git! - Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree! - MiniMax: fetch Token Plan recharge-credit balance from the console `token_plan_credit` endpoint when a browser session cookie is available. Thanks @Yuxin-Qiao! +- MiniMax: persist detected China API region after a successful global-to-CN token retry so later refreshes skip the extra probe. ### Fixed - Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely. diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 847c477d4b..005a95042d 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -81,6 +81,15 @@ struct ProviderRegistry { } } }, + providerRegionUpdater: { provider, region in + await MainActor.run { + guard provider == .minimax, + let resolved = MiniMaxAPIRegion(rawValue: region), + settings.minimaxAPIRegion != resolved + else { return } + settings.minimaxAPIRegion = resolved + } + }, costUsageHistoryDays: settings.costUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow( diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 3d84aa2835..a5c8765772 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -711,6 +711,16 @@ extension UsageStore { self.settings.stepfunToken = token } }, + providerRegionUpdater: { [weak self] provider, region in + await MainActor.run { + guard let self, + provider == .minimax, + let resolved = MiniMaxAPIRegion(rawValue: region), + self.settings.minimaxAPIRegion != resolved + else { return } + self.settings.minimaxAPIRegion = resolved + } + }, costUsageHistoryDays: self.settings.costUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: ProviderRegistry.persistentCLISessionIdleWindow( diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index c5337d4d5b..e23c0ef3a4 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -140,7 +140,8 @@ extension CodexBarCLI { browserDetection: browserDetection, selectedTokenAccountID: account?.id, tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), - providerManualTokenUpdater: tokenContext.manualTokenUpdater()) + providerManualTokenUpdater: tokenContext.manualTokenUpdater(), + providerRegionUpdater: tokenContext.regionUpdater()) let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) return ProviderDiagnosticExportBuilder.build(.init( diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 803a760ecc..0471e9f477 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -340,6 +340,7 @@ extension CodexBarCLI { selectedTokenAccountID: account?.id, tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), providerManualTokenUpdater: tokenContext.manualTokenUpdater(), + providerRegionUpdater: tokenContext.regionUpdater(), persistsCLISessions: Self.persistsCLISessions(provider: provider, command: command), persistentCLISessionIdleWindow: command.persistentCLISessionIdleWindow) let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 89a70cfae8..7a33a6299c 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -324,6 +324,26 @@ struct TokenAccountCLIContext { } } + func regionUpdater() -> ProviderFetchContext.ProviderRegionUpdater { + { provider, region in + try? Self.updateStoredRegion(provider: provider, region: region) + } + } + + private static func updateStoredRegion(provider: UsageProvider, region: String) throws { + guard provider == .minimax else { return } + let trimmed = region.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let store = CodexBarConfigStore() + var config = try store.load() ?? .makeDefault() + var providerConfig = config.providerConfig(for: provider) ?? ProviderConfig(id: provider) + guard providerConfig.region != trimmed else { return } + providerConfig.region = trimmed + config.setProviderConfig(providerConfig) + try store.save(config) + } + private static func updateStoredManualToken(provider: UsageProvider, token: String) throws { guard provider == .stepfun else { return } let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 1ba4813262..e2637f102e 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -89,6 +89,9 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( apiToken: apiToken, region: preferredRegion) + if apiResult.resolvedRegion != preferredRegion { + await context.providerRegionUpdater?(.minimax, apiResult.resolvedRegion.rawValue) + } var usage = apiResult.snapshot if let cookieOverride = Self.resolveExplicitCookieOverride(context: context), let cookie = MiniMaxCookieHeader.normalized(from: cookieOverride.cookieHeader) diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index 5d4e207be1..d7fac02ea2 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -20,6 +20,7 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { public struct ProviderFetchContext: Sendable { public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void public typealias ProviderManualTokenUpdater = @Sendable (UsageProvider, String) async -> Void + public typealias ProviderRegionUpdater = @Sendable (UsageProvider, String) async -> Void public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode @@ -36,6 +37,7 @@ public struct ProviderFetchContext: Sendable { public let selectedTokenAccountID: UUID? public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? public let providerManualTokenUpdater: ProviderManualTokenUpdater? + public let providerRegionUpdater: ProviderRegionUpdater? public let costUsageHistoryDays: Int /// Whether warm CLI helper sessions (such as the managed Antigravity `agy` /// process) may outlive a single fetch. True for long-lived hosts (the app, @@ -63,6 +65,7 @@ public struct ProviderFetchContext: Sendable { selectedTokenAccountID: UUID? = nil, tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil, providerManualTokenUpdater: ProviderManualTokenUpdater? = nil, + providerRegionUpdater: ProviderRegionUpdater? = nil, costUsageHistoryDays: Int = 30, persistsCLISessions: Bool = false, persistentCLISessionIdleWindow: TimeInterval? = nil) @@ -82,6 +85,7 @@ public struct ProviderFetchContext: Sendable { self.selectedTokenAccountID = selectedTokenAccountID self.tokenAccountTokenUpdater = tokenAccountTokenUpdater self.providerManualTokenUpdater = providerManualTokenUpdater + self.providerRegionUpdater = providerRegionUpdater self.costUsageHistoryDays = max(1, min(365, costUsageHistoryDays)) self.persistsCLISessions = persistsCLISessions self.persistentCLISessionIdleWindow = persistentCLISessionIdleWindow diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index 813a5a70b3..86b308d6dd 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -265,6 +265,31 @@ struct MiniMaxTokenPlanCreditTests { #expect(enriched.pointsBalance == 20000) } + @Test + func `resolved china region should be persisted when global default fails`() 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 preferredRegion = MiniMaxAPIRegion.global + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: "sk-cp-test", + region: preferredRegion, + now: now, + session: transport) + + let regionToPersist = apiResult.resolvedRegion != preferredRegion + ? apiResult.resolvedRegion.rawValue + : nil + #expect(regionToPersist == MiniMaxAPIRegion.chinaMainland.rawValue) + } + private static func fixtureURL(named name: String) throws -> URL { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -297,6 +322,28 @@ struct MiniMaxTokenPlanCreditTests { } """ + @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 balance = 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=\(balance.map { String($0) } ?? "nil")") + #expect(balance == 20000) + } + private static func httpResponse( url: URL, body: String, From d58793ca866d50a16ec53a6b49b91073dc9325e3 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 01:16:55 +0800 Subject: [PATCH 04/53] Re-trigger CI after cancelled macOS shards. Co-authored-by: Cursor From 8d40b54e3cd5cceab4142293f9b31c55b26a5164 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:16:56 +0800 Subject: [PATCH 05/53] chore: retrigger CI after cancelled runs. No code changes; prior macOS test shards were cancelled by cancel-in-progress from rapid pushes. Co-authored-by: Cursor From 227ed49e041c23e683011e42fdc0ce26df5720f4 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 01:52:52 +0800 Subject: [PATCH 06/53] Address ClawSweeper MiniMax credit review findings. Route credit lookups from MINIMAX_HOST, fall back to env cookies when manual settings are empty, and relax the gated live balance assertion. Co-authored-by: Cursor --- CHANGELOG.md | 1 + .../MiniMax/MiniMaxProviderDescriptor.swift | 11 +++++++---- .../MiniMaxTokenPlanCreditFetcher.swift | 18 ++++++++++++++++-- .../CodexBarTests/MiniMaxProviderTests.swift | 19 ++++++++++++++----- .../MiniMaxTokenPlanCreditTests.swift | 10 +++++++++- docs/minimax.md | 1 + 6 files changed, 48 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 982db94a93..9fdeb41390 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Claude history: keep OAuth utilization separate across account switches while preserving continuity through token refreshes. - Linux CLI: keep Claude OAuth usage subprocess-free, skip version probes, and let Auto bypass unsupported web sources. Thanks @derekszen! - Usage display: make Usage widgets follow the used-versus-remaining preference already shared by menus and Overview rows (#1738). Thanks @OlegLustenko and @FrancoLan! +- MiniMax: route Token Plan credit lookups to the matching `www.*` host when `MINIMAX_HOST` selects a China or global platform domain. ## 0.37.3 — 2026-06-28 diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index e2637f102e..7492870439 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -114,7 +114,9 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { private static func resolveExplicitCookieOverride(context: ProviderFetchContext) -> MiniMaxCookieOverride? { if let settings = context.settings?.minimax, settings.cookieSource == .manual { - return MiniMaxCookieHeader.override(from: settings.manualCookieHeader) + if let override = MiniMaxCookieHeader.override(from: settings.manualCookieHeader) { + return override + } } guard let raw = ProviderTokenResolver.minimaxCookie(environment: context.env) else { return nil @@ -277,9 +279,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 diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift index 657648a669..e9c9ce7fef 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift @@ -11,6 +11,7 @@ enum MiniMaxTokenPlanCreditFetcher { environment: [String: String], transport: any ProviderHTTPTransport) async throws -> Double? { + 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" @@ -25,7 +26,7 @@ enum MiniMaxTokenPlanCreditFetcher { "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: region) + let origin = MiniMaxSubscriptionMetadataFetcher.platformOriginURL(region: effectiveRegion) request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") request.setValue(origin.absoluteString + "/", forHTTPHeaderField: "referer") @@ -55,7 +56,20 @@ enum MiniMaxTokenPlanCreditFetcher { if let override = MiniMaxSettingsReader.tokenPlanCreditURL(environment: environment) { return override } - return region.tokenPlanCreditURL + 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 { diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 567a720748..93e43c4b92 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1221,11 +1221,20 @@ struct MiniMaxAPIRegionTests { } @Test - func `host override does not rewrite token plan credit url`() throws { - let env = [MiniMaxSettingsReader.hostKey: "platform.minimaxi.com"] - let resolved = try MiniMaxTokenPlanCreditFetcher.resolveCreditURL(region: .global, environment: env) - #expect(resolved.host == "www.minimax.io") - #expect(resolved.path == "/backend/account/token_plan_credit") + 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 diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index 86b308d6dd..b4cc95ca62 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -341,7 +341,15 @@ struct MiniMaxTokenPlanCreditTests { print("LIVE_MINIMAX_COOKIE_RESOLVED=true") print("LIVE_MINIMAX_RESOLVED_REGION=\(apiResult.resolvedRegion.rawValue)") print("LIVE_MINIMAX_BALANCE=\(balance.map { String($0) } ?? "nil")") - #expect(balance == 20000) + let balanceValue = try #require(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( diff --git a/docs/minimax.md b/docs/minimax.md index 89e27e0cfa..4d52362455 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -40,6 +40,7 @@ falls back across the provider's supported web requests when needed. - `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 host (`minimaxi.com` → `www.minimaxi.com`, `minimax.io` → `www.minimax.io`). - 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`. From 94b454e8040d183bf7c72531a363267b391c5b1b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 01:55:13 +0800 Subject: [PATCH 07/53] Re-trigger CI for ClawSweeper follow-up. Co-authored-by: Cursor From 6a3451d7bb4618b39e54a9480062c444c8839725 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:18:23 +0800 Subject: [PATCH 08/53] Avoid persisting MiniMax env fallback region --- Sources/CodexBarCLI/CLIDiagnoseCommand.swift | 2 +- Sources/CodexBarCLI/CLIUsageCommand.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 17 +++++++++++-- ...kenAccountEnvironmentPrecedenceTests.swift | 24 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index e23c0ef3a4..b7eda97504 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -141,7 +141,7 @@ extension CodexBarCLI { selectedTokenAccountID: account?.id, tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), providerManualTokenUpdater: tokenContext.manualTokenUpdater(), - providerRegionUpdater: tokenContext.regionUpdater()) + providerRegionUpdater: tokenContext.regionUpdater(for: account)) let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) return ProviderDiagnosticExportBuilder.build(.init( diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 0471e9f477..bb5234c850 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -340,7 +340,7 @@ extension CodexBarCLI { selectedTokenAccountID: account?.id, tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), providerManualTokenUpdater: tokenContext.manualTokenUpdater(), - providerRegionUpdater: tokenContext.regionUpdater(), + providerRegionUpdater: tokenContext.regionUpdater(for: account), persistsCLISessions: Self.persistsCLISessions(provider: provider, command: command), persistentCLISessionIdleWindow: command.persistentCLISessionIdleWindow) let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 7a33a6299c..b1bb6f2997 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -324,12 +324,25 @@ struct TokenAccountCLIContext { } } - func regionUpdater() -> ProviderFetchContext.ProviderRegionUpdater { - { provider, region in + func regionUpdater(for account: ProviderTokenAccount?) -> ProviderFetchContext.ProviderRegionUpdater? { + guard !self.usesEnvironmentBackedMiniMaxCredential(account: account) else { return nil } + return { provider, region in try? Self.updateStoredRegion(provider: provider, region: region) } } + private func usesEnvironmentBackedMiniMaxCredential(account: ProviderTokenAccount?) -> Bool { + guard account == nil else { return false } + if MiniMaxAPISettingsReader.apiToken(environment: [ + MiniMaxAPISettingsReader.codingPlanAPITokenKey: + self.baseEnvironment[MiniMaxAPISettingsReader.codingPlanAPITokenKey] ?? "", + ]) != nil { + return true + } + guard self.providerConfig(for: .minimax)?.sanitizedAPIKey == nil else { return false } + return MiniMaxAPISettingsReader.apiToken(environment: self.baseEnvironment) != nil + } + private static func updateStoredRegion(provider: UsageProvider, region: String) throws { guard provider == .minimax else { return } let trimmed = region.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 1966a925c2..b317edee3c 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -88,6 +88,30 @@ struct ZaiTokenAccountEnvironmentPrecedenceTests { @Suite(.serialized) @MainActor struct TokenAccountEnvironmentPrecedenceTests { + @Test + func `MiniMax CLI does not persist fallback region for environment token`() throws { + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: CodexBarConfig(providers: []), + verbose: false, + baseEnvironment: [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-environment"]) + + #expect(context.regionUpdater(for: nil) == nil) + } + + @Test + func `MiniMax CLI can persist fallback region for stored token`() throws { + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: CodexBarConfig(providers: [ + ProviderConfig(id: .minimax, apiKey: "sk-cp-stored"), + ]), + verbose: false, + baseEnvironment: [:]) + + #expect(context.regionUpdater(for: nil) != nil) + } + @Test func `token account environment overrides config API key in app environment builder`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-app") From 30cf613a5e53e176319eab96262da9cbe2b335ef Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:20:05 +0800 Subject: [PATCH 09/53] Keep MiniMax fallback region fetch-scoped --- Sources/CodexBar/ProviderRegistry.swift | 9 ----- .../CodexBar/UsageStore+TokenAccounts.swift | 10 ------ Sources/CodexBarCLI/CLIDiagnoseCommand.swift | 3 +- Sources/CodexBarCLI/CLIUsageCommand.swift | 1 - Sources/CodexBarCLI/TokenAccountCLI.swift | 33 ------------------- .../MiniMax/MiniMaxProviderDescriptor.swift | 3 -- .../Providers/ProviderFetchPlan.swift | 4 --- .../MiniMaxTokenPlanCreditTests.swift | 25 -------------- ...kenAccountEnvironmentPrecedenceTests.swift | 24 -------------- 9 files changed, 1 insertion(+), 111 deletions(-) diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index 005a95042d..847c477d4b 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -81,15 +81,6 @@ struct ProviderRegistry { } } }, - providerRegionUpdater: { provider, region in - await MainActor.run { - guard provider == .minimax, - let resolved = MiniMaxAPIRegion(rawValue: region), - settings.minimaxAPIRegion != resolved - else { return } - settings.minimaxAPIRegion = resolved - } - }, costUsageHistoryDays: settings.costUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow( diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index a5c8765772..3d84aa2835 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -711,16 +711,6 @@ extension UsageStore { self.settings.stepfunToken = token } }, - providerRegionUpdater: { [weak self] provider, region in - await MainActor.run { - guard let self, - provider == .minimax, - let resolved = MiniMaxAPIRegion(rawValue: region), - self.settings.minimaxAPIRegion != resolved - else { return } - self.settings.minimaxAPIRegion = resolved - } - }, costUsageHistoryDays: self.settings.costUsageHistoryDays, persistsCLISessions: true, persistentCLISessionIdleWindow: ProviderRegistry.persistentCLISessionIdleWindow( diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index b7eda97504..c5337d4d5b 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -140,8 +140,7 @@ extension CodexBarCLI { browserDetection: browserDetection, selectedTokenAccountID: account?.id, tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), - providerManualTokenUpdater: tokenContext.manualTokenUpdater(), - providerRegionUpdater: tokenContext.regionUpdater(for: account)) + providerManualTokenUpdater: tokenContext.manualTokenUpdater()) let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) return ProviderDiagnosticExportBuilder.build(.init( diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index bb5234c850..803a760ecc 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -340,7 +340,6 @@ extension CodexBarCLI { selectedTokenAccountID: account?.id, tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), providerManualTokenUpdater: tokenContext.manualTokenUpdater(), - providerRegionUpdater: tokenContext.regionUpdater(for: account), persistsCLISessions: Self.persistsCLISessions(provider: provider, command: command), persistentCLISessionIdleWindow: command.persistentCLISessionIdleWindow) let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index b1bb6f2997..89a70cfae8 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -324,39 +324,6 @@ struct TokenAccountCLIContext { } } - func regionUpdater(for account: ProviderTokenAccount?) -> ProviderFetchContext.ProviderRegionUpdater? { - guard !self.usesEnvironmentBackedMiniMaxCredential(account: account) else { return nil } - return { provider, region in - try? Self.updateStoredRegion(provider: provider, region: region) - } - } - - private func usesEnvironmentBackedMiniMaxCredential(account: ProviderTokenAccount?) -> Bool { - guard account == nil else { return false } - if MiniMaxAPISettingsReader.apiToken(environment: [ - MiniMaxAPISettingsReader.codingPlanAPITokenKey: - self.baseEnvironment[MiniMaxAPISettingsReader.codingPlanAPITokenKey] ?? "", - ]) != nil { - return true - } - guard self.providerConfig(for: .minimax)?.sanitizedAPIKey == nil else { return false } - return MiniMaxAPISettingsReader.apiToken(environment: self.baseEnvironment) != nil - } - - private static func updateStoredRegion(provider: UsageProvider, region: String) throws { - guard provider == .minimax else { return } - let trimmed = region.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - - let store = CodexBarConfigStore() - var config = try store.load() ?? .makeDefault() - var providerConfig = config.providerConfig(for: provider) ?? ProviderConfig(id: provider) - guard providerConfig.region != trimmed else { return } - providerConfig.region = trimmed - config.setProviderConfig(providerConfig) - try store.save(config) - } - private static func updateStoredManualToken(provider: UsageProvider, token: String) throws { guard provider == .stepfun else { return } let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 7492870439..52cd975352 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -89,9 +89,6 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( apiToken: apiToken, region: preferredRegion) - if apiResult.resolvedRegion != preferredRegion { - await context.providerRegionUpdater?(.minimax, apiResult.resolvedRegion.rawValue) - } var usage = apiResult.snapshot if let cookieOverride = Self.resolveExplicitCookieOverride(context: context), let cookie = MiniMaxCookieHeader.normalized(from: cookieOverride.cookieHeader) diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index d7fac02ea2..5d4e207be1 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -20,7 +20,6 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { public struct ProviderFetchContext: Sendable { public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void public typealias ProviderManualTokenUpdater = @Sendable (UsageProvider, String) async -> Void - public typealias ProviderRegionUpdater = @Sendable (UsageProvider, String) async -> Void public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode @@ -37,7 +36,6 @@ public struct ProviderFetchContext: Sendable { public let selectedTokenAccountID: UUID? public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? public let providerManualTokenUpdater: ProviderManualTokenUpdater? - public let providerRegionUpdater: ProviderRegionUpdater? public let costUsageHistoryDays: Int /// Whether warm CLI helper sessions (such as the managed Antigravity `agy` /// process) may outlive a single fetch. True for long-lived hosts (the app, @@ -65,7 +63,6 @@ public struct ProviderFetchContext: Sendable { selectedTokenAccountID: UUID? = nil, tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil, providerManualTokenUpdater: ProviderManualTokenUpdater? = nil, - providerRegionUpdater: ProviderRegionUpdater? = nil, costUsageHistoryDays: Int = 30, persistsCLISessions: Bool = false, persistentCLISessionIdleWindow: TimeInterval? = nil) @@ -85,7 +82,6 @@ public struct ProviderFetchContext: Sendable { self.selectedTokenAccountID = selectedTokenAccountID self.tokenAccountTokenUpdater = tokenAccountTokenUpdater self.providerManualTokenUpdater = providerManualTokenUpdater - self.providerRegionUpdater = providerRegionUpdater self.costUsageHistoryDays = max(1, min(365, costUsageHistoryDays)) self.persistsCLISessions = persistsCLISessions self.persistentCLISessionIdleWindow = persistentCLISessionIdleWindow diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index b4cc95ca62..21850aa4f4 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -265,31 +265,6 @@ struct MiniMaxTokenPlanCreditTests { #expect(enriched.pointsBalance == 20000) } - @Test - func `resolved china region should be persisted when global default fails`() 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 preferredRegion = MiniMaxAPIRegion.global - let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( - apiToken: "sk-cp-test", - region: preferredRegion, - now: now, - session: transport) - - let regionToPersist = apiResult.resolvedRegion != preferredRegion - ? apiResult.resolvedRegion.rawValue - : nil - #expect(regionToPersist == MiniMaxAPIRegion.chinaMainland.rawValue) - } - private static func fixtureURL(named name: String) throws -> URL { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index b317edee3c..1966a925c2 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -88,30 +88,6 @@ struct ZaiTokenAccountEnvironmentPrecedenceTests { @Suite(.serialized) @MainActor struct TokenAccountEnvironmentPrecedenceTests { - @Test - func `MiniMax CLI does not persist fallback region for environment token`() throws { - let context = try TokenAccountCLIContext( - selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), - config: CodexBarConfig(providers: []), - verbose: false, - baseEnvironment: [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-environment"]) - - #expect(context.regionUpdater(for: nil) == nil) - } - - @Test - func `MiniMax CLI can persist fallback region for stored token`() throws { - let context = try TokenAccountCLIContext( - selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), - config: CodexBarConfig(providers: [ - ProviderConfig(id: .minimax, apiKey: "sk-cp-stored"), - ]), - verbose: false, - baseEnvironment: [:]) - - #expect(context.regionUpdater(for: nil) != nil) - } - @Test func `token account environment overrides config API key in app environment builder`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-app") From adbc2c2d1af16c73c25c343bf1fc8cbf9400f871 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 12:33:37 +0800 Subject: [PATCH 10/53] Remove stale MiniMax region persistence release note. Co-authored-by: Cursor --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e00e7476..63708a6751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ - Menu: add an opt-in setting to refresh provider usage whenever the menu opens without changing the periodic refresh clock. Thanks @dstier-git! - Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree! - MiniMax: fetch Token Plan recharge-credit balance from the console `token_plan_credit` endpoint when a browser session cookie is available. Thanks @Yuxin-Qiao! -- MiniMax: persist detected China API region after a successful global-to-CN token retry so later refreshes skip the extra probe. ### Fixed - Claude web usage: bound stale requests so Auto can reach CLI fallback instead of hanging indefinitely. From a3137c89cdb7aa07617dba5aa30faf088fd9d674 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 12:55:18 +0800 Subject: [PATCH 11/53] Align no-billing MiniMax test with credit enrichment request. Co-authored-by: Cursor --- .../CodexBarTests/MiniMaxProviderTests.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 93e43c4b92..a1dc159e6f 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1034,13 +1034,22 @@ struct MiniMaxUsageParserTests { @Test func `web usage fetch skips billing history when optional usage is disabled`() async throws { let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let creditBody = """ + { + "remaining_credits": 20000, + "base_resp": { "status_code": 0 } + } + """ 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") + } + #expect(url.path == "/backend/account/token_plan_credit") + return Self.httpResponse(url: url, body: creditBody, contentType: "application/json") } let snapshot = try await MiniMaxUsageFetcher.fetchUsage( @@ -1054,7 +1063,10 @@ struct MiniMaxUsageParserTests { let requests = await transport.requests() #expect(snapshot.currentPrompts == 2) #expect(snapshot.billingSummary == nil) - #expect(requests.count == 1) + #expect(snapshot.pointsBalance == 20000) + let billingRequests = requests.filter { $0.url?.path == "/account/amount" } + #expect(billingRequests.isEmpty) + #expect(requests.count == 2) } @Test From 1883738d48e2743b9a40ab0e1948cd34e1b0d71a Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 13:34:23 +0800 Subject: [PATCH 12/53] Update MiniMax web fetch tests for token plan credit requests. Co-authored-by: Cursor --- ...MiniMaxCurrentTokenPlanResponseTests.swift | 7 +++++++ .../CodexBarTests/MiniMaxProviderTests.swift | 14 ++++++++++++++ .../MiniMaxTokenPlanChangeTests.swift | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift index 8a1d1c9999..a815d0b12b 100644 --- a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift +++ b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift @@ -52,6 +52,12 @@ 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") + } #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 +78,7 @@ struct MiniMaxCurrentTokenPlanResponseTests { #expect(requests.map { $0.url?.host } == [ "platform.minimaxi.com", "platform.minimaxi.com", + "www.minimaxi.com", ]) } diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index a1dc159e6f..dc9245caa8 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -301,6 +301,7 @@ struct MiniMaxCookieHeaderTests { } } +// swiftlint:disable type_body_length struct MiniMaxUsageParserTests { @Test func `signed out check ignores login copy inside scripts`() { @@ -954,6 +955,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") + } #expect(url.path == "/account/amount") #expect(url.query?.contains("aggregate=false") == true) #expect(request.value(forHTTPHeaderField: "Cookie") == "HERTZ-SESSION=abc") @@ -997,6 +1004,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" }? @@ -1178,6 +1191,7 @@ struct MiniMaxUsageParserTests { } } +// swiftlint:enable type_body_length struct MiniMaxAPIRegionTests { @Test func `defaults to global hosts`() { diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index b7a0045722..144feae58c 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -255,6 +255,12 @@ 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") + } #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 +298,12 @@ 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") + } #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 +323,7 @@ struct MiniMaxTokenPlanChangeTests { "platform.minimaxi.com", "platform.minimaxi.com", "www.minimaxi.com", + "www.minimaxi.com", ]) } @@ -594,6 +607,12 @@ 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") + } #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) From 17f2e212a7b2a9c94aeeff0220d786274ad2d377 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Thu, 2 Jul 2026 23:41:22 +0800 Subject: [PATCH 13/53] Add MiniMax usage summary dashboard and quota utilization charts. Surface 30-day token/cost KPIs on the main card, add Token usage details and Subscription Utilization submenus, and show the nearest MiniMax reset time in the menu bar. Co-authored-by: Cursor --- .../InlineUsageDashboardContent.swift | 244 +++++- .../MenuBarMetricWindowResolver.swift | 13 + Sources/CodexBar/MenuCardView+Costs.swift | 8 +- Sources/CodexBar/MenuCardView+MiniMax.swift | 20 +- .../MiniMaxUsageSummaryChartMenuView.swift | 699 ++++++++++++++++++ .../PlanUtilizationHistoryChartMenuView.swift | 3 + .../Resources/ar.lproj/Localizable.strings | 23 + .../Resources/ca.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 23 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fa.lproj/Localizable.strings | 23 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/pt-BR.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/th.lproj/Localizable.strings | 23 + .../Resources/tr.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 23 + .../zh-Hant.lproj/Localizable.strings | 2 + .../StatusItemController+Animation.swift | 3 +- ...tatusItemController+CountdownRefresh.swift | 2 +- .../StatusItemController+HostedSubmenus.swift | 28 + .../CodexBar/StatusItemController+Menu.swift | 51 +- ...tatusItemController+MenuPresentation.swift | 12 + ...troller+MiniMaxUsageSummaryConstants.swift | 6 + ...emController+MiniMaxUsageSummaryMenu.swift | 92 +++ ...ontroller+MiniMaxUsageSummarySection.swift | 13 + ...tatusItemController+OverviewSubmenus.swift | 1 + ...tusItemController+ZaiHourlyChartMenu.swift | 39 + Sources/CodexBar/StatusItemController.swift | 8 + .../CodexBar/UsageStore+PlanUtilization.swift | 24 +- Sources/CodexBar/UsageStore+TokenCost.swift | 5 +- .../Providers/MiniMax/MiniMaxAPIRegion.swift | 5 + .../MiniMax/MiniMaxProviderDescriptor.swift | 8 +- .../MiniMax/MiniMaxServiceUsage.swift | 50 +- .../MiniMax/MiniMaxSubscriptionMetadata.swift | 4 +- .../MiniMaxTokenPlanCreditFetcher.swift | 31 +- .../MiniMaxUsageFetcher+UsageSummary.swift | 37 + .../MiniMax/MiniMaxUsageFetcher.swift | 32 +- .../MiniMax/MiniMaxUsagePricing.swift | 153 ++++ .../MiniMaxUsageSnapshot+Metadata.swift | 27 +- .../MiniMax/MiniMaxUsageSnapshot.swift | 9 + .../MiniMaxUsageSummary+CostProjection.swift | 133 ++++ .../MiniMax/MiniMaxUsageSummary.swift | 362 +++++++++ .../Providers/ProviderDiagnosticExport.swift | 4 + Sources/CodexBarCore/UsageFormatter.swift | 34 +- .../MenuBarMetricWindowResolverTests.swift | 24 + .../MenuBarResetTimeDisplayTests.swift | 54 ++ Tests/CodexBarTests/MenuCardModelTests.swift | 2 + ...MiniMaxCurrentTokenPlanResponseTests.swift | 7 + .../MiniMaxMenuCardBillingTests.swift | 289 ++++++++ .../CodexBarTests/MiniMaxProviderTests.swift | 14 +- .../MiniMaxResetDescriptionTests.swift | 86 +++ .../MiniMaxTokenPlanChangeTests.swift | 19 + .../MiniMaxTokenPlanCreditTests.swift | 65 +- .../MiniMaxUsagePricingTests.swift | 127 ++++ ...iniMaxUsageSummaryChartMenuViewTests.swift | 134 ++++ .../MiniMaxUsageSummaryTests.swift | 130 ++++ ...UtilizationHistoryChartMenuViewTests.swift | 36 + Tests/CodexBarTests/UsageFormatterTests.swift | 15 + .../UsageStorePlanUtilizationTests.swift | 49 ++ .../menu-card-inline-dashboard.png | Bin 0 -> 67082 bytes .../minimax-usage/menu-card-usage-notes.png | Bin 0 -> 67702 bytes .../subscription-utilization.png | Bin 0 -> 42609 bytes .../minimax-usage/token-usage-details.png | Bin 0 -> 92733 bytes 73 files changed, 3258 insertions(+), 100 deletions(-) create mode 100644 Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift create mode 100644 Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift create mode 100644 Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift create mode 100644 Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift create mode 100644 Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxUsagePricingTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift create mode 100644 docs/screenshots/minimax-usage/menu-card-inline-dashboard.png create mode 100644 docs/screenshots/minimax-usage/menu-card-usage-notes.png create mode 100644 docs/screenshots/minimax-usage/subscription-utilization.png create mode 100644 docs/screenshots/minimax-usage/token-usage-details.png diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 76e32aaa53..dc89338bab 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -46,6 +46,14 @@ extension UsageMenuCardView.Model { return usage.displayLines } + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData + { + return Self.minimaxUsageSummaryNotes(usageSummary) + } + if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, let billing = input.snapshot?.minimaxUsage?.billingSummary @@ -161,6 +169,17 @@ extension UsageMenuCardView.Model { } private static func resolveInlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData, + let costSnapshot = primaryCostHistorySnapshot(input: input), + !costSnapshot.daily.isEmpty + { + return self.minimaxCostAndUsageSummaryInlineDashboard( + snapshot: costSnapshot, + usage: usageSummary) + } if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider), let tokenSnapshot = primaryCostHistorySnapshot(input: input), !tokenSnapshot.daily.isEmpty @@ -182,6 +201,13 @@ extension UsageMenuCardView.Model { { return Self.zaiInlineDashboard(modelUsage: modelUsage, now: input.now) } + if input.provider == .minimax, + input.showOptionalCreditsAndExtraUsage, + let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, + usageSummary.hasDisplayableData + { + return Self.minimaxUsageSummaryInlineDashboard(usageSummary) + } if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, let billing = input.snapshot?.minimaxUsage?.billingSummary, @@ -213,7 +239,7 @@ extension UsageMenuCardView.Model { } static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { - provider == .openai || provider == .mistral + provider == .openai || provider == .mistral || provider == .minimax } static func primaryCostHistorySnapshot(input: Input) -> CostUsageTokenSnapshot? { @@ -228,6 +254,11 @@ extension UsageMenuCardView.Model { return projected } return input.snapshot == nil ? input.tokenSnapshot : nil + case .minimax: + if let projected = input.snapshot?.minimaxUsage?.usageSummary?.toCostUsageTokenSnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil default: return input.tokenSnapshot } @@ -357,7 +388,9 @@ extension UsageMenuCardView.Model { emphasis: false), .init( title: tokenHistoryTitle, - value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", + value: snapshot.last30DaysTokens.map { + Self.tokenCountString($0, provider: provider) + } ?? "—", emphasis: false), ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest), points: points, @@ -551,6 +584,210 @@ extension UsageMenuCardView.Model { detailLines: details) } + private static func minimaxUsageSummaryInlineDashboard(_ usage: MiniMaxUsageSummary) -> InlineUsageDashboardModel { + let trendDays = usage.trendDays(last: 7) + let points = trendDays.map { + let tokenText = Self.minimaxTokenString($0.totalToken) + let cacheText = UsageFormatter.optionalPercentString($0.cacheHitPercent) + let cacheLine = String(format: L("Cache hit: %@"), cacheText) + return InlineUsageDashboardModel.Point( + id: $0.date, + label: Self.shortDayLabel($0.date), + value: Double($0.totalToken), + accessibilityValue: "\($0.date): \(tokenText) \(L("tokens")) · \(cacheLine)") + } + let cacheText = UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent) + let todayTitle = usage.lastUpdateTime + .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .flatMap { $0.isEmpty ? nil : String(format: L("%@ usage"), $0) } + ?? L("Today") + + return InlineUsageDashboardModel( + accessibilityLabel: L("MiniMax 7 day token usage trend"), + valueStyle: .tokens, + kpis: [ + .init( + title: todayTitle, + value: Self.minimaxTokenString(usage.latestSnapshotTokens), + emphasis: true), + .init( + title: L("7d tokens"), + value: Self.minimaxTokenString(usage.last7DaysTokens), + emphasis: false), + .init( + title: L("Cache hit"), + value: cacheText, + emphasis: false), + .init( + title: L("30d tokens"), + value: Self.minimaxTokenString(usage.last30DaysTokens), + emphasis: false), + ], + points: points, + detailLines: Self.minimaxUsageSummaryCardDetailLines(usage)) + } + + private static func minimaxLatestUsageKPITitle(_ usage: MiniMaxUsageSummary) -> String { + if let time = usage.lastUpdateTime?.trimmingCharacters(in: .whitespacesAndNewlines), !time.isEmpty { + return String(format: L("%@ usage"), time) + } + return L("Latest usage") + } + + private static func minimaxCostAndUsageSummaryInlineDashboard( + snapshot: CostUsageTokenSnapshot, + usage: MiniMaxUsageSummary) -> InlineUsageDashboardModel + { + let base = self.costHistoryInlineDashboard(provider: .minimax, snapshot: snapshot) + let cacheText = UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent) + return InlineUsageDashboardModel( + accessibilityLabel: base.accessibilityLabel, + valueStyle: base.valueStyle, + kpis: [ + base.kpis[0], + base.kpis[1], + .init( + title: self.minimaxLatestUsageKPITitle(usage), + value: self.minimaxTokenString(usage.latestSnapshotTokens), + emphasis: false), + .init( + title: L("7d tokens"), + value: self.minimaxTokenString(usage.last7DaysTokens), + emphasis: false), + .init( + title: L("Cache hit"), + value: cacheText, + emphasis: false), + .init( + title: L("30d tokens"), + value: self.minimaxTokenString(usage.last30DaysTokens), + emphasis: false), + ], + points: base.points, + detailLines: self.minimaxCostDashboardDetailLines(snapshot: snapshot, usage: usage)) + } + + private static func minimaxCostDashboardDetailLines( + snapshot: CostUsageTokenSnapshot, + usage: MiniMaxUsageSummary) -> [String] + { + var details: [String] = [] + if let topModel = Self.topCostModel(from: snapshot.daily) { + details.append("\(L("Top model")): \(Self.shortModelName(topModel))") + } + if let day = usage.snapshotDay, + let todayLine = Self.minimaxTodayBreakdownLine(day) + { + details.append(todayLine) + } + if let hint = Self.tokenUsageHint(provider: .minimax) { + details.append(hint) + } + details.append(L("Language models only; data may be delayed")) + return details + } + + private static func minimaxUsageSummaryCardDetailLines(_ usage: MiniMaxUsageSummary) -> [String] { + var details: [String] = [] + if let day = usage.snapshotDay, + let todayLine = Self.minimaxTodayBreakdownLine(day) + { + details.append(todayLine) + } + if let total = usage.totalTokenConsumed, !total.isEmpty { + details.append("\(L("Total consumed")): \(total)") + } + if let cache = usage.snapshotDay?.cacheHitPercent { + details.append( + String(format: L("Cache hit: %@"), UsageFormatter.optionalPercentString(cache))) + } + if let activeDays = usage.activeDays { + details.append(String(format: L("Active days: %@"), "\(activeDays)")) + } + if let streak = usage.currentConsecutiveDays { + details.append(String(format: L("Streak: %@ days"), "\(streak)")) + } + if let rank = usage.usageRankingPercent { + details.append( + String( + format: L("Usage rank: top %@%%"), + UsageFormatter.decimalString(rank))) + } + if let updated = usage.lastUpdateTime, !updated.isEmpty { + details.append("\(L("Updated")): \(updated)") + } + if let topModel = Self.topMiniMaxUsageSummaryModel(from: usage.days) { + details.append("\(L("Top model")): \(Self.shortModelName(topModel.model)) (\(topModel.tokens))") + } + details.append(L("Language models only; data may be delayed")) + return details + } + + private static func minimaxTodayBreakdownLine(_ day: MiniMaxUsageSummaryDay) -> String? { + if let topModel = day.models.max(by: { lhs, rhs in + if lhs.totalToken == rhs.totalToken { return lhs.model > rhs.model } + return lhs.totalToken < rhs.totalToken + }) { + let cacheText = UsageFormatter.optionalPercentString(topModel.cacheHitPercent) + return String( + format: L("%@ · %@ input · %@ output · %@ cache hit"), + Self.shortModelName(topModel.model), + Self.minimaxTokenString(topModel.inputToken), + Self.minimaxTokenString(topModel.outputToken), + cacheText) + } + guard day.totalToken > 0 else { return nil } + let cacheText = UsageFormatter.optionalPercentString(day.cacheHitPercent) + return String( + format: L("%@ input · %@ output · %@ cache hit"), + Self.minimaxTokenString(day.totalInputToken), + Self.minimaxTokenString(day.totalOutputToken), + cacheText) + } + + private static func minimaxTokenString(_ value: Int) -> String { + UsageFormatter.tokenCountString(value, fractionDigits: 2) + } + + private static func tokenCountString(_ value: Int, provider: UsageProvider) -> String { + if provider == .minimax { + return self.minimaxTokenString(value) + } + return UsageFormatter.tokenCountString(value) + } + + private static func minimaxUsageSummaryNotes(_ usage: MiniMaxUsageSummary) -> [String] { + [ + String( + format: L("Today: %@ tokens"), + self.minimaxTokenString(usage.latestSnapshotTokens)), + String( + format: L("Last 7 days: %@ tokens"), + self.minimaxTokenString(usage.last7DaysTokens)), + String( + format: L("Last 30 days: %@ tokens"), + self.minimaxTokenString(usage.last30DaysTokens)), + ] + self.minimaxUsageSummaryCardDetailLines(usage) + } + + private static func topMiniMaxUsageSummaryModel( + from days: [MiniMaxUsageSummaryDay]) -> (model: String, tokens: String)? + { + var totals: [String: Int] = [:] + for day in days { + for model in day.models { + totals[model.model, default: 0] += model.totalToken + } + } + guard let top = totals.max(by: { lhs, rhs in + if lhs.value == rhs.value { return lhs.key > rhs.key } + return lhs.value < rhs.value + }) else { + return nil + } + return (top.key, Self.minimaxTokenString(top.value)) + } + private static func deepseekInlineDashboard(_ usage: DeepSeekUsageSummary) -> InlineUsageDashboardModel { let symbol = usage.currency == "CNY" ? "¥" : "$" let points = usage.daily.suffix(30).map { @@ -721,7 +958,8 @@ struct InlineUsageDashboardContent: View { Text(line) .font(.caption) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 51d19b4e3b..07bf415e1a 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -227,6 +227,19 @@ enum MenuBarMetricWindowResolver { return windows.max(by: { $0.usedPercent < $1.usedPercent }) } + /// The quota window whose reset is soonest among the primary, secondary, and tertiary lanes. + static func nearestResetWindow(snapshot: UsageSnapshot?) -> RateWindow? { + guard let snapshot else { return nil } + let datedWindows = [snapshot.primary, snapshot.secondary, snapshot.tertiary] + .compactMap(\.self) + .compactMap { window -> (RateWindow, Date)? in + guard let resetsAt = window.resetsAt else { return nil } + return (window, resetsAt) + } + guard !datedWindows.isEmpty else { return nil } + return datedWindows.min(by: { $0.1 < $1.1 })?.0 + } + private static func mostConstrainedCursorWindow( total: RateWindow?, auto: RateWindow?, diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 239cddd37a..b91a6d9b87 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -153,6 +153,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 } @@ -275,11 +277,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 d410770619..4666349c7b 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, @@ -105,6 +105,24 @@ extension UsageMenuCardView.Model { return trimmed.isEmpty ? windowType : trimmed } + private static func miniMaxResetText(for service: MiniMaxServiceUsage, input: Input) -> String { + if service.isUnlimited { + return self.localizedMiniMaxResetDescription(service.resetDescription) + } + if let resetsAt = service.resetsAt { + switch input.resetTimeDisplayStyle { + case .absolute: + let text = UsageFormatter.resetDescription(from: resetsAt, now: input.now) + return L("Resets %@", text) + case .countdown: + let phrase = MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: input.now) + if phrase == "now" { return L("Resets now") } + return L("Resets in %@", phrase) + } + } + return Self.localizedMiniMaxResetDescription(service.resetDescription) + } + private static func localizedMiniMaxResetDescription(_ text: String) -> String { let prefix = "Resets in " guard text.hasPrefix(prefix) else { return text } diff --git a/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift b/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift new file mode 100644 index 0000000000..249e1c3708 --- /dev/null +++ b/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift @@ -0,0 +1,699 @@ +import Charts +import CodexBarCore +import SwiftUI + +@MainActor +struct MiniMaxUsageSummaryChartMenuView: View { + private struct Point: Identifiable { + let id: String + let dateKey: String + let date: Date + let totalTokens: Int + let cacheHitPercent: Double? + let day: MiniMaxUsageSummaryDay + } + + private struct DetailRow: Identifiable { + let id: String + let title: String + let subtitle: String? + let modeSubtitle: String? + let accentColor: Color + } + + private struct KPI: Identifiable { + let id: String + let title: String + let value: String + } + + private struct Model { + let points: [Point] + let pointsByDateKey: [String: Point] + let dateKeys: [(key: String, date: Date)] + let axisDates: [Date] + let barColor: Color + } + + private let usage: MiniMaxUsageSummary + private let width: CGFloat + private let showsSummaryKPIs: Bool + private let onHeightChange: ((CGFloat) -> Void)? + @State private var selectedDateKey: String? + @State private var windowDays: WindowDays = .seven + + private enum WindowDays: Int, CaseIterable { + case seven = 7 + case thirty = 30 + } + + init( + usage: MiniMaxUsageSummary, + showsSummaryKPIs: Bool = true, + onHeightChange: ((CGFloat) -> Void)? = nil, + width: CGFloat) + { + self.usage = usage + self.showsSummaryKPIs = showsSummaryKPIs + self.onHeightChange = onHeightChange + self.width = width + } + + var body: some View { + let model = Self.makeModel(usage: self.usage, windowDays: self.windowDays.rawValue) + let selectedDateKey = self.effectiveSelectedDateKey(model: model) + let detail = self.detailContent(selectedDateKey: selectedDateKey, model: model) + + VStack(alignment: .leading, spacing: Self.outerSpacing) { + if self.showsSummaryKPIs { + self.kpiGrid(Self.summaryKPIs(usage: self.usage)) + } else { + self.usageStatsSection(windowDays: self.windowDays) + } + + self.trendHeaderSection + + if model.points.isEmpty { + Text(L("No data available")) + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Chart { + ForEach(model.points) { point in + BarMark( + x: .value(L("Day"), point.date, unit: .day), + y: .value(L("tokens"), point.totalTokens)) + .foregroundStyle(model.barColor) + } + } + .chartYAxis(.hidden) + .chartXAxis(.hidden) + .chartLegend(.hidden) + .frame(height: Self.chartHeight) + .accessibilityLabel( + self.windowDays == .seven + ? L("MiniMax 7 day token usage trend") + : L("MiniMax 30 day token usage trend")) + .chartOverlay { proxy in + GeometryReader { geo in + ZStack(alignment: .topLeading) { + if let rect = self.selectionBandRect( + selectedDateKey: selectedDateKey, + model: model, + proxy: proxy, + geo: geo) + { + Rectangle() + .fill(Self.selectionBandColor) + .frame(width: rect.width, height: rect.height) + .position(x: rect.midX, y: rect.midY) + .allowsHitTesting(false) + } + MouseLocationReader { location in + self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + } + } + + if model.axisDates.count == 2 { + HStack { + Text(model.axisDates[0], format: .dateTime.month(.abbreviated).day()) + Spacer() + Text(model.axisDates[1], format: .dateTime.month(.abbreviated).day()) + } + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .frame(height: Self.axisLabelAreaHeight) + .padding(.top, -Self.outerSpacing) + } + + VStack(alignment: .leading, spacing: Self.detailSpacing) { + Text(detail.primary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.tail) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + + if !detail.rows.isEmpty { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: Self.detailSpacing) { + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame( + width: 2, + height: Self.accentHeight(for: row)) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.detailTitleLineHeight, alignment: .leading) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + if let modeSubtitle = row.modeSubtitle { + Text(modeSubtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame( + height: Self.detailSubtitleLineHeight, + alignment: .leading) + } + } + } + .frame(height: Self.detailRowHeight(for: row), alignment: .leading) + } + } + } + .scrollIndicators( + Self.detailRowsNeedScrolling(itemCount: detail.rows.count) ? .visible : .hidden) + .frame( + height: Self.detailRowsViewportHeight(rows: detail.rows), + alignment: .topLeading) + .id(selectedDateKey) + } + } + .frame( + height: Self.detailBlockHeight(rows: detail.rows), + alignment: .topLeading) + } + + ForEach(Self.footerLines(usage: self.usage), id: \.self) { line in + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(height: Self.footerLineHeight, alignment: .leading) + } + } + .padding(.horizontal, 16) + .padding(.vertical, Self.verticalPadding) + .frame(minWidth: self.width, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onChange(of: self.windowDays) { _, newValue in + self.selectedDateKey = nil + let updatedModel = Self.makeModel(usage: self.usage, windowDays: newValue.rawValue) + self.notifyHeightChange(model: updatedModel) + } + } + + private var trendHeaderSection: some View { + VStack(alignment: .leading, spacing: Self.trendHeaderSpacing) { + Text(L("Usage trend")) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + self.windowToggle + } + .frame(height: Self.trendHeaderBlockHeight, alignment: .topLeading) + } + + private var windowToggle: some View { + Picker( + selection: Binding( + get: { self.windowDays.rawValue }, + set: { self.windowDays = WindowDays(rawValue: $0) ?? .seven })) + { + Text(L("Last 7 days")).tag(WindowDays.seven.rawValue) + Text(L("Last 30 days")).tag(WindowDays.thirty.rawValue) + } label: { + EmptyView() + } + .labelsHidden() + .pickerStyle(.segmented) + .accessibilityLabel(L("Usage trend")) + } + + static let maxVisibleDetailLines = 4 + private static let kpiTitleLineHeight: CGFloat = 13 + private static let kpiValueLineHeight: CGFloat = 20 + private static let kpiCellInnerSpacing: CGFloat = 1 + private static let kpiRowHeight: CGFloat = 36 + private static let kpiGridRowSpacing: CGFloat = 6 + private static let kpiGridHeight: CGFloat = 78 + private static let trendHeaderSpacing: CGFloat = 6 + private static let trendHeaderTitleHeight: CGFloat = 14 + private static let trendHeaderPickerHeight: CGFloat = 24 + private static var trendHeaderBlockHeight: CGFloat { + self.trendHeaderTitleHeight + self.trendHeaderSpacing + self.trendHeaderPickerHeight + } + + private static let footerLineHeight: CGFloat = 28 + private static let detailPrimaryLineHeight: CGFloat = 32 + private static let detailTitleLineHeight: CGFloat = 16 + private static let detailSubtitleLineHeight: CGFloat = 13 + private static let compactDetailRowHeight: CGFloat = 36 + private static let expandedDetailRowHeight: CGFloat = 44 + private static let detailSpacing: CGFloat = 6 + private static let chartHeight: CGFloat = 114 + private static let axisLabelAreaHeight: CGFloat = 16 + private static let outerSpacing: CGFloat = 10 + static let verticalPadding: CGFloat = 10 + private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) + + private static func totalCardHeight( + rows: [DetailRow], + hasChart: Bool) -> CGFloat + { + var height = self.verticalPadding * 2 + height += self.kpiGridHeight + height += self.outerSpacing + self.trendHeaderBlockHeight + if hasChart { + height += self.chartHeight + height += self.axisLabelAreaHeight + height += self.outerSpacing + height += self.detailBlockHeight(rows: rows) + } else { + height += 20 + } + height += self.outerSpacing + height += self.footerLineHeight + return height + } + + private static func detailBlockHeight(rows: [DetailRow]) -> CGFloat { + guard !rows.isEmpty else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + self.detailSpacing + self.detailRowsViewportHeight(rows: rows) + } + + private static func detailRowsViewportHeight(rows: [DetailRow]) -> CGFloat { + let visibleRows = Array(rows.prefix(self.maxVisibleDetailLines)) + guard !visibleRows.isEmpty else { return 0 } + + let rowHeights = visibleRows.reduce(CGFloat(0)) { total, row in + total + self.detailRowHeight(for: row) + } + let spacing = CGFloat(max(visibleRows.count - 1, 0)) * self.detailSpacing + return rowHeights + spacing + } + + private static func detailRowHeight(for row: DetailRow) -> CGFloat { + self.detailRowHeight(hasModeSubtitle: row.modeSubtitle != nil) + } + + private static func detailRowHeight(hasModeSubtitle: Bool) -> CGFloat { + hasModeSubtitle ? self.expandedDetailRowHeight : self.compactDetailRowHeight + } + + private static func accentHeight(for row: DetailRow) -> CGFloat { + row.subtitle == nil && row.modeSubtitle == nil ? 14 : self.detailRowHeight(for: row) + } + + static func detailViewportRowCount(itemCount: Int) -> Int { + min(max(itemCount, 0), self.maxVisibleDetailLines) + } + + static func detailRowsNeedScrolling(itemCount: Int) -> Bool { + itemCount > self.maxVisibleDetailLines + } + + private func effectiveSelectedDateKey(model: Model) -> String? { + self.selectedDateKey ?? model.dateKeys.last?.key + } + + private func notifyHeightChange(model: Model) { + guard let onHeightChange = self.onHeightChange else { return } + let detail = self.detailContent( + selectedDateKey: self.effectiveSelectedDateKey(model: model), + model: model) + onHeightChange(Self.totalCardHeight( + rows: detail.rows, + hasChart: !model.points.isEmpty)) + } + + private func usageStatsSection(windowDays: WindowDays) -> some View { + self.kpiGrid(Self.usageStatsKPIs(usage: self.usage, windowDays: windowDays)) + } + + private static func usageStatsKPIs(usage: MiniMaxUsageSummary, windowDays: WindowDays) -> [KPI] { + var kpis: [KPI] = [] + if let total = usage.totalTokenConsumed?.trimmingCharacters(in: .whitespacesAndNewlines), + !total.isEmpty + { + kpis.append(KPI(id: "total", title: L("Total consumed"), value: total)) + } + if let peak = self.peakDayTokens(usage: usage) { + kpis.append(KPI( + id: "peak", + title: L("Daily peak"), + value: self.minimaxTokenString(peak))) + } + if let activeDays = usage.activeDays { + kpis.append(KPI(id: "active", title: L("Active days"), value: "\(activeDays)")) + } + if let spend = usage.projectedCostUSD(lastDays: windowDays.rawValue) { + kpis.append(KPI( + id: "spend", + title: self.windowSpendTitle(windowDays: windowDays), + value: UsageFormatter.usdString(spend))) + } + return kpis + } + + private static func peakDayTokens(usage: MiniMaxUsageSummary) -> Int? { + let dayPeak = usage.days.map(\.totalToken).max() ?? 0 + let trendPeak = usage.dailyTokenUsage.max() ?? 0 + let peak = max(dayPeak, trendPeak) + return peak > 0 ? peak : nil + } + + private func kpiGrid(_ kpis: [KPI]) -> some View { + VStack(alignment: .leading, spacing: Self.kpiGridRowSpacing) { + ForEach(Array(stride(from: 0, to: kpis.count, by: 2)), id: \.self) { rowStart in + HStack(alignment: .top, spacing: 0) { + ForEach(kpis[rowStart.. some View { + VStack(alignment: .leading, spacing: Self.kpiCellInnerSpacing) { + Text(kpi.title) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Self.kpiTitleLineHeight, alignment: .leading) + Text(kpi.value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.72) + .frame(height: Self.kpiValueLineHeight, alignment: .leading) + } + .frame(height: Self.kpiRowHeight, alignment: .topLeading) + } + + private static func summaryKPIs(usage: MiniMaxUsageSummary) -> [KPI] { + let latestTitle: String = { + if let time = usage.lastUpdateTime?.trimmingCharacters(in: .whitespacesAndNewlines), !time.isEmpty { + return String(format: L("%@ usage"), time) + } + return L("Latest usage") + }() + return [ + KPI( + id: "latest", + title: latestTitle, + value: Self.minimaxTokenString(usage.latestSnapshotTokens)), + KPI( + id: "7d", + title: L("7d tokens"), + value: Self.minimaxTokenString(usage.last7DaysTokens)), + KPI( + id: "30d", + title: L("30d tokens"), + value: Self.minimaxTokenString(usage.last30DaysTokens)), + KPI( + id: "cache", + title: L("Cache hit"), + value: UsageFormatter.optionalPercentString(usage.snapshotDay?.cacheHitPercent)), + ] + } + + static func summaryKPIValues(usage: MiniMaxUsageSummary) -> [String] { + self.summaryKPIs(usage: usage).map(\.value) + } + + private static func footerLines(usage: MiniMaxUsageSummary) -> [String] { + [L("Language models only; data may be delayed")] + } + + private func detailContent(selectedDateKey: String?, model: Model) -> (primary: String, rows: [DetailRow]) { + guard let key = selectedDateKey, + let point = model.pointsByDateKey[key], + let date = Self.date(from: key) + else { + return (L("Hover a bar for details"), []) + } + + let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + let primary = Self.dayDetailPrimary(dayLabel: dayLabel, point: point, usage: self.usage) + return (primary, Self.detailRows(for: point, accentColor: model.barColor, usage: self.usage)) + } + + private static func windowSpendTitle(windowDays: WindowDays) -> String { + switch windowDays { + case .seven: L("7d spend") + case .thirty: L("30d spend") + } + } + + private static func dayDetailPrimary(dayLabel: String, point: Point, usage: MiniMaxUsageSummary) -> String { + let cacheText = UsageFormatter.optionalPercentString(point.cacheHitPercent) + var parts = [ + "\(dayLabel): \(Self.minimaxTokenString(point.totalTokens)) \(L("tokens"))", + String(format: L("Cache hit: %@"), cacheText), + ] + if let costUSD = usage.projectedCostUSD(for: point.day) { + parts.append(UsageFormatter.usdString(costUSD)) + } + return parts.joined(separator: " · ") + } + + private static func makeModel(usage: MiniMaxUsageSummary, windowDays: Int) -> Model { + let sorted = usage.trendDays(last: windowDays).compactMap { day -> Point? in + guard let date = Self.date(from: day.date), day.totalToken >= 0 else { return nil } + return Point( + id: day.date, + dateKey: day.date, + date: date, + totalTokens: day.totalToken, + cacheHitPercent: day.cacheHitPercent, + day: day) + } + let axisDates: [Date] = { + guard let first = sorted.first?.date, let last = sorted.last?.date else { return [] } + if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } + return [first, last] + }() + let pointsByDateKey = Dictionary(uniqueKeysWithValues: sorted.map { ($0.dateKey, $0) }) + let brand = ProviderDescriptorRegistry.descriptor(for: .minimax).branding.color + return Model( + points: sorted, + pointsByDateKey: pointsByDateKey, + dateKeys: sorted.map { ($0.dateKey, $0.date) }, + axisDates: axisDates, + barColor: Color(red: brand.red, green: brand.green, blue: brand.blue)) + } + + private static func date(from key: String) -> Date? { + let parts = key.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) else { return nil } + var comps = DateComponents() + comps.calendar = Calendar.current + comps.timeZone = TimeZone.current + comps.year = year + comps.month = month + comps.day = day + comps.hour = 12 + return comps.date + } + + private static func detailRows( + for point: Point, + accentColor: Color, + usage: MiniMaxUsageSummary) -> [DetailRow] + { + let breakdownByModel = Dictionary( + uniqueKeysWithValues: usage.projectedModelBreakdowns(for: point.day).map { ($0.modelName, $0) }) + if !point.day.models.isEmpty { + return point.day.models + .sorted { lhs, rhs in + if lhs.totalToken == rhs.totalToken { return lhs.model < rhs.model } + return lhs.totalToken > rhs.totalToken + } + .enumerated() + .map { index, model in + let breakdown = breakdownByModel[model.model] + let subtitle = breakdown.flatMap { + UsageFormatter.modelCostDetail( + $0.modelName, + costUSD: $0.costUSD, + totalTokens: $0.totalTokens) + } ?? Self.modelTokenSubtitle(model) + return DetailRow( + id: model.model, + title: Self.shortModelName(model.model), + subtitle: subtitle, + modeSubtitle: + "\(L("cache-hit input")): \(Self.minimaxTokenString(model.cacheReadToken)) · " + + "\(L("output")): \(Self.minimaxTokenString(model.outputToken))", + accentColor: accentColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + } + + let cacheMiss = max(0, point.day.totalInputToken - point.day.totalCacheReadToken) + let breakdown = breakdownByModel["Day totals"] + let subtitle = breakdown.flatMap { + UsageFormatter.modelCostDetail( + $0.modelName, + costUSD: $0.costUSD, + totalTokens: $0.totalTokens) + } ?? + "\(L("cache-hit input")): \(Self.minimaxTokenString(point.day.totalCacheReadToken)) · " + + "\(L("cache-miss input")): \(Self.minimaxTokenString(cacheMiss))" + return [ + DetailRow( + id: "summary", + title: L("Day totals"), + subtitle: subtitle, + modeSubtitle: + "\(L("output")): \(Self.minimaxTokenString(point.day.totalOutputToken)) · " + + "\(L("Caches")): \(Self.minimaxTokenString(point.day.totalCacheCreateToken))", + accentColor: accentColor), + ] + } + + private static func modelTokenSubtitle(_ model: MiniMaxUsageSummaryModel) -> String { + let cacheText = UsageFormatter.optionalPercentString(model.cacheHitPercent) + return "\(Self.minimaxTokenString(model.totalToken)) \(L("tokens")) · " + + String(format: L("Cache hit: %@"), cacheText) + } + + private static func breakdownAccentOpacity(for index: Int) -> Double { + max(0.35, 1.0 - (Double(index) * 0.15)) + } + + private static func shortModelName(_ name: String) -> String { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 26 else { return trimmed } + return String(trimmed.prefix(25)) + "…" + } + + private static func minimaxTokenString(_ value: Int) -> String { + UsageFormatter.tokenCountString(value, fractionDigits: 2) + } + + private func selectionBandRect( + selectedDateKey: String?, + model: Model, + proxy: ChartProxy, + geo: GeometryProxy) -> CGRect? + { + guard let key = selectedDateKey, + let index = model.dateKeys.firstIndex(where: { $0.key == key }), + let plotAnchor = proxy.plotFrame + else { return nil } + let plotFrame = geo[plotAnchor] + let date = model.dateKeys[index].date + guard let x = proxy.position(forX: date) else { return nil } + let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20) + let slotWidth = abs(nextDayX - x) + let halfWidth = slotWidth * 0.25 + 2 + return CGRect( + x: plotFrame.origin.x + x - halfWidth, + y: plotFrame.origin.y, + width: halfWidth * 2, + height: plotFrame.height) + } + + private func updateSelection( + location: CGPoint?, + model: Model, + proxy: ChartProxy, + geo: GeometryProxy) + { + guard let location, + let plotAnchor = proxy.plotFrame + else { return } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { return } + let xInPlot = location.x - plotFrame.origin.x + guard let date: Date = proxy.value(atX: xInPlot) else { return } + guard let nearest = self.nearestDateKey(to: date, model: model) else { return } + + if self.selectedDateKey != nearest { + self.selectedDateKey = nearest + self.notifyHeightChange(model: model) + } + } + + private func nearestDateKey(to date: Date, model: Model) -> String? { + model.dateKeys.min(by: { + abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) + })?.key + } +} + +extension MiniMaxUsageSummaryChartMenuView { + static func _dayDetailPrimaryForTesting(usage: MiniMaxUsageSummary, dateKey: String) -> String? { + guard let day = usage.days.first(where: { $0.date == dateKey }), + let date = Self.date(from: dateKey) + else { + return nil + } + let point = Point( + id: day.date, + dateKey: day.date, + date: date, + totalTokens: day.totalToken, + cacheHitPercent: day.cacheHitPercent, + day: day) + let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) + return Self.dayDetailPrimary(dayLabel: dayLabel, point: point, usage: usage) + } + + static func _detailViewportHeightForTesting(modeSubtitlePresence: [Bool]) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.detailRowsViewportHeight(rows: rows) + } + + static func _detailBlockHeightForTesting(modeSubtitlePresence: [Bool]) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.detailBlockHeight(rows: rows) + } + + static func _totalCardHeightForTesting(modeSubtitlePresence: [Bool], hasChart: Bool) -> CGFloat { + let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in + DetailRow( + id: "\(index)", + title: "Row \(index)", + subtitle: "Subtitle", + modeSubtitle: hasModeSubtitle ? "Mode" : nil, + accentColor: .blue) + } + return self.totalCardHeight(rows: rows, hasChart: hasChart) + } +} diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index b704754af5..7b24d1b582 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -258,6 +258,9 @@ struct PlanUtilizationHistoryChartMenuView: View { { names.insert(.opus) } + case .minimax: + if snapshot.primary != nil { names.insert(.session) } + if snapshot.secondary != nil { names.insert(.weekly) } default: let windows = [snapshot.primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) + (snapshot.extraRateWindows?.filter(\.usageKnown).map(\.window) ?? []) diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 07761c2d26..01f6c82646 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(النظام)"; +"7d" = "7 يومًا"; "30d" = "30 يومًا"; "A managed Codex login is already running. Wait for it to finish before adding " = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة "; "API key" = "مفتاح API"; @@ -52,6 +53,26 @@ "CLI paths" = "CLI المسارات"; "CLI sessions" = "جلسات CLI"; "Caches" = "التخزين المؤقت"; +"Cache hit: %@" = "معدل إصابة التخزين المؤقت: %@"; +"Cache hit" = "إصابة التخزين المؤقت"; +"%@ usage" = "استخدام %@"; +"Latest usage" = "أحدث استخدام"; +"7d tokens" = "استخدام 7 أيام"; +"Last 7 days: %@ tokens" = "آخر 7 أيام: %@ رمز"; +"Last 7 days" = "آخر 7 أيام"; +"Usage trend" = "اتجاه الاستخدام"; +"MiniMax 7 day token usage trend" = "اتجاه استخدام MiniMax لمدة 7 أيام"; +"Language models only; data may be delayed" = "نماذج اللغة فقط؛ قد تتأخر البيانات"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ إدخال · %@ إخراج · إصابة تخزين %@"; +"%@ input · %@ output · %@ cache hit" = "%@ إدخال · %@ إخراج · إصابة تخزين %@"; +"Total consumed" = "إجمالي الاستهلاك"; +"Daily peak" = "ذروة اليوم"; +"Active days" = "أيام النشاط"; +"Active days: %@" = "أيام النشاط: %@"; +"Streak: %@ days" = "سلسلة: %@ يومًا"; +"Usage rank: top %@%%" = "ترتيب الاستخدام: أفضل %@%%"; +"Token usage details" = "تفاصيل استخدام الرموز"; +"Day totals" = "إجمالي اليوم"; "Cancel" = "إلغاء"; "Check for Updates…" = "تحقق من التحديثات..."; "Check for updates automatically" = "تحقق تلقائيا من التحديثات"; @@ -123,6 +144,7 @@ "Enabled" = "مفعل"; "Error" = "خطأ"; "Error simulation" = "محاكاة الخطأ"; +"Expires: %@" = "تنتهي الصلاحية %@"; "Expose troubleshooting tools in the Debug tab." = "اعرض أدوات استكشاف الأخطاء في تبويب التصحيح."; "Failed" = "فشل"; "False" = "خطأ"; @@ -917,6 +939,7 @@ "Weekly" = "الأسبوعي"; "not detected" = "لم يتم اكتشافه"; "Estimated from local Codex logs for the selected account." = "تم التقدير من سجلات Codex المحلية للحساب المختار."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "مُقدَّر من ملخص استخدام MiniMax وأسعار الدفع حسب الاستخدام المنشورة · قد يختلف عن الفاتورة"; "minimax_usage_amount_format" = "الاستخدام: %@ / %@"; "minimax_used_percent_format" = "المستخدم %@"; "minimax_service_text_generation" = "توليد النصوص"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 8072f8f39e..fd414d2b13 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " proveïdors"; "(System)" = "(Sistema)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espera que acabi abans d'afegir "; "API key" = "Clau d'API"; @@ -1023,6 +1024,7 @@ "Est. total (%@): %@" = "Total estimat (%@): %@"; "Est. total (30d): %@" = "Total estimat (30 d): %@"; "Estimated from local Codex logs for the selected account." = "Estimat a partir dels registres locals de Codex per al compte seleccionat."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimat a partir del resum d'ús de MiniMax i els preus publicats de pagament per ús · pot diferir de la factura"; "Google accounts" = "Comptes de Google"; "Google OAuth" = "Google OAuth"; "Hourly Tokens" = "Tokens per hora"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 5d6ffb0161..62038bb276 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "Anbieter"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Eine verwaltete Codex-Anmeldung läuft bereits. Warten Sie, bis der Vorgang abgeschlossen ist, bevor Sie ihn hinzufügen"; "API key" = "API-Schlüssel"; @@ -914,6 +915,7 @@ "Weekly" = "Wöchentlich"; "not detected" = "nicht erkannt"; "Estimated from local Codex logs for the selected account." = "Geschätzt aus lokalen Codex-Protokollen für das ausgewählte Konto."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Geschätzt anhand der MiniMax-Nutzungsübersicht und veröffentlichter Pay-as-you-go-Preise · kann von der Rechnung abweichen"; "minimax_usage_amount_format" = "Verwendung: %@ / %@"; "minimax_used_percent_format" = "Verbraucht %@"; "minimax_service_text_generation" = "Textgenerierung"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 1259c9eca0..f408f5046d 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "A managed Codex login is already running. Wait for it to finish before adding "; "API key" = "API key"; @@ -52,6 +53,26 @@ "CLI paths" = "CLI paths"; "CLI sessions" = "CLI sessions"; "Caches" = "Caches"; +"Cache hit: %@" = "Cache hit: %@"; +"Cache hit" = "Cache hit"; +"%@ usage" = "%@ usage"; +"Latest usage" = "Latest usage"; +"7d tokens" = "7d tokens"; +"Last 7 days: %@ tokens" = "Last 7 days: %@ tokens"; +"Last 7 days" = "Last 7 days"; +"Usage trend" = "Usage trend"; +"MiniMax 7 day token usage trend" = "MiniMax 7 day token usage trend"; +"Language models only; data may be delayed" = "Language models only; data may be delayed"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ cache hit"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ cache hit"; +"Total consumed" = "Total consumed"; +"Daily peak" = "Daily peak"; +"Active days" = "Active days"; +"Active days: %@" = "Active days: %@"; +"Streak: %@ days" = "Streak: %@ days"; +"Usage rank: top %@%%" = "Usage rank: top %@%%"; +"Token usage details" = "Token usage details"; +"Day totals" = "Day totals"; "Cancel" = "Cancel"; "Check for Updates…" = "Check for Updates…"; "Check for updates automatically" = "Check for updates automatically"; @@ -123,6 +144,7 @@ "Enabled" = "Enabled"; "Error" = "Error"; "Error simulation" = "Error simulation"; +"Expires: %@" = "Expires: %@"; "Expose troubleshooting tools in the Debug tab." = "Expose troubleshooting tools in the Debug tab."; "Failed" = "Failed"; "False" = "False"; @@ -917,6 +939,7 @@ "Weekly" = "Weekly"; "not detected" = "not detected"; "Estimated from local Codex logs for the selected account." = "Estimated from local Codex logs for the selected account."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimated from MiniMax usage summary and published pay-as-you-go pricing."; "minimax_usage_amount_format" = "Usage: %@ / %@"; "minimax_used_percent_format" = "Used %@"; "minimax_service_text_generation" = "Text Generation"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index df8966d716..25bc8cd1ea 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " proveedores"; "(System)" = "(Sistema)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir "; "API key" = "Clave de API"; @@ -667,6 +668,7 @@ /* Cost estimation */ "cost_header_estimated" = "Coste (estimado)"; "cost_estimate_hint" = "Estimado a partir de registros locales · puede diferir de tu factura"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimado a partir del resumen de uso de MiniMax y los precios publicados de pago por uso · puede diferir de la factura"; /* Popup panels */ "No usage configured." = "No hay uso configurado."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 99faf9f03b..b0c7c72efd 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(سیستم)"; +"7d" = "7 روز"; "30d" = "30 روز"; "A managed Codex login is already running. Wait for it to finish before adding " = "یک ورود Codex مدیریت شده هم اکنون در حال اجرا است. صبر کنید تا تمام شود و بعد را اضافه کنید"; "API key" = "کلید API"; @@ -52,6 +53,26 @@ "CLI paths" = "مسیرهای CLI"; "CLI sessions" = "جلسات CLI"; "Caches" = "کش ها"; +"Cache hit: %@" = "نرخ اصابت کش: %@"; +"Cache hit" = "اصابت کش"; +"%@ usage" = "مصرف %@"; +"Latest usage" = "آخرین مصرف"; +"7d tokens" = "مصرف ۷ روز"; +"Last 7 days: %@ tokens" = "۷ روز گذشته: %@ توکن"; +"Last 7 days" = "۷ روز گذشته"; +"Usage trend" = "روند مصرف"; +"MiniMax 7 day token usage trend" = "روند ۷ روزه توکن MiniMax"; +"Language models only; data may be delayed" = "فقط مدل‌های زبانی؛ داده ممکن است با تأخیر باشد"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ ورودی · %@ خروجی · اصابت کش %@"; +"%@ input · %@ output · %@ cache hit" = "%@ ورودی · %@ خروجی · اصابت کش %@"; +"Total consumed" = "کل مصرف"; +"Daily peak" = "اوج روزانه"; +"Active days" = "روزهای فعال"; +"Active days: %@" = "روزهای فعال: %@"; +"Streak: %@ days" = "پیوستگی: %@ روز"; +"Usage rank: top %@%%" = "رتبه مصرف: %@%% برتر"; +"Token usage details" = "جزئیات مصرف توکن"; +"Day totals" = "جمع روز"; "Cancel" = "لغو"; "Check for Updates…" = "به روزرسانی ها را بررسی کنید..."; "Check for updates automatically" = "به طور خودکار به روزرسانی ها را بررسی کنید"; @@ -123,6 +144,7 @@ "Enabled" = "فعال"; "Error" = "خطا"; "Error simulation" = "شبیه سازی خطا"; +"Expires: %@" = "انقضا: %@"; "Expose troubleshooting tools in the Debug tab." = "ابزارهای عیب یابی را در تب اشکال زدایی آشکار کنید."; "Failed" = "شکست خورد"; "False" = "نادرست"; @@ -917,6 +939,7 @@ "Weekly" = "هفتگی"; "not detected" = "شناسایی نشد"; "Estimated from local Codex logs for the selected account." = "برآورد شده از لاگ های محلی Codex حساب انتخاب شده."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "برآورد شده از خلاصه مصرف MiniMax و قیمت‌گذاری رسمی pay-as-you-go · ممکن است با صورتحساب متفاوت باشد"; "minimax_usage_amount_format" = "استفاده: %@ / %@"; "minimax_used_percent_format" = "%@ استفاده شده"; "minimax_service_text_generation" = "تولید متن"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index c2c44fde26..911543dd51 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " fournisseurs"; "(System)" = "(System)"; +"7d" = "7j"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Une connexion Codex gérée est déjà en cours d'exécution. Attendez qu'il soit terminé avant d'ajouter"; "API key" = "Clé API"; @@ -914,6 +915,7 @@ "Weekly" = "Hebdomadaire"; "not detected" = "non détecté"; "Estimated from local Codex logs for the selected account." = "Estimé à partir des journaux Codex locaux pour le compte sélectionné."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimé à partir du résumé d'utilisation MiniMax et des tarifs pay-as-you-go publiés · peut différer de la facture"; "minimax_usage_amount_format" = "Utilisation : %@ / %@"; "minimax_used_percent_format" = "Utilisé %@"; "minimax_service_text_generation" = "Génération de texte"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 5c60b470b1..538646b0a3 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " penyedia"; "(System)" = "(Sistem)"; +"7d" = "7 hari"; "30d" = "30 hari"; "A managed Codex login is already running. Wait for it to finish before adding " = "Login Codex terkelola sudah berjalan. Tunggu hingga selesai sebelum menambahkan "; "API key" = "Kunci API"; @@ -919,6 +920,7 @@ "Weekly" = "Mingguan"; "not detected" = "tidak terdeteksi"; "Estimated from local Codex logs for the selected account." = "Diperkirakan dari log Codex lokal untuk akun yang dipilih."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Diperkirakan dari ringkasan penggunaan MiniMax dan harga pay-as-you-go yang dipublikasikan · dapat berbeda dari tagihan"; "minimax_usage_amount_format" = "Penggunaan: %@ / %@"; "minimax_used_percent_format" = "Terpakai %@"; "minimax_service_text_generation" = "Pembuatan Teks"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index d21b25cd1d..66467c774c 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " provider"; "(System)" = "(Sistema)"; +"7d" = "7 g"; "30d" = "30 g"; "A managed Codex login is already running. Wait for it to finish before adding " = "È già in corso un accesso gestito a Codex. Attendi che termini prima di aggiungere "; "API key" = "Chiave API"; @@ -919,6 +920,7 @@ "Weekly" = "Settimanale"; "not detected" = "non rilevato"; "Estimated from local Codex logs for the selected account." = "Stima basata sui log locali di Codex per l'account selezionato."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Stimato dal riepilogo utilizzo MiniMax e dai prezzi pay-as-you-go pubblicati · può differire dalla fattura"; "minimax_usage_amount_format" = "Utilizzo: %@ / %@"; "minimax_used_percent_format" = "Usato %@"; "minimax_service_text_generation" = "Generazione testo"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index eab39c27ef..a29a179630 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 件のプロバイダ"; "(System)" = "(システム)"; +"7d" = "7日"; "30d" = "30日"; "A managed Codex login is already running. Wait for it to finish before adding " = "管理対象の Codex ログインがすでに実行中です。完了を待ってから追加してください "; "API key" = "API キー"; @@ -911,6 +912,7 @@ "Weekly" = "週間"; "not detected" = "未検出"; "Estimated from local Codex logs for the selected account." = "選択したアカウントのローカル Codex ログから推定しています。"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "用量サマリーと公式従量課金料金から推定 · 請求額と異なる場合があります"; "minimax_usage_amount_format" = "使用量: %@ / %@"; "minimax_used_percent_format" = "使用済み %@"; "minimax_service_text_generation" = "テキスト生成"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index f620235b3b..32c033f4bd 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 공급자"; "(System)" = "(시스템)"; +"7d" = "7일"; "30d" = "30일"; "A managed Codex login is already running. Wait for it to finish before adding " = "관리되는 Codex 로그인이 이미 실행 중입니다. 추가하기 전에 완료될 때까지 기다리세요. "; "API key" = "API 키"; @@ -883,6 +884,7 @@ "Weekly" = "주간"; "not detected" = "감지되지 않음"; "Estimated from local Codex logs for the selected account." = "선택한 계정의 로컬 Codex 로그를 기반으로 추정됨."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "사용 요약 및 공식 종량제 요금 기준 추정 · 청구서와 다를 수 있음"; "minimax_usage_amount_format" = "사용량: %@ / %@"; "minimax_used_percent_format" = "%@ 사용됨"; "minimax_service_text_generation" = "텍스트 생성"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 72b140836a..64f3e400a3 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(Systeem)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Er is al een beheerde Codex-aanmelding actief. Wacht tot het klaar is voordat je het toevoegt"; "API key" = "API-sleutel"; @@ -914,6 +915,7 @@ "Weekly" = "Wekelijks"; "not detected" = "niet gedetecteerd"; "Estimated from local Codex logs for the selected account." = "Geschat op basis van lokale Codex-logboeken voor het geselecteerde account."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Geschat op basis van het MiniMax-gebruiksoverzicht en gepubliceerde pay-as-you-go-tarieven · kan afwijken van de factuur"; "minimax_usage_amount_format" = "Gebruik: %@ / %@"; "minimax_used_percent_format" = "Gebruikt %@"; "minimax_service_text_generation" = "Tekst genereren"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 67d5d7db2d..0b68cba152 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " providers"; "(System)" = "(System)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Trwa już zarządzane logowanie Codex. Poczekaj na jego zakończenie, zanim dodasz "; "API key" = "Klucz API"; @@ -919,6 +920,7 @@ "Weekly" = "Tydzień"; "not detected" = "nie wykryto"; "Estimated from local Codex logs for the selected account." = "Oszacowano na podstawie lokalnych logów Codex dla wybranego konta."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Oszacowano na podstawie podsumowania użycia MiniMax i opublikowanych cen pay-as-you-go · może różnić się od rachunku"; "minimax_usage_amount_format" = "Użycie: %@ / %@"; "minimax_used_percent_format" = "Wykorzystano %@"; "minimax_service_text_generation" = "Generowanie tekstu"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index bff238f1fc..672fd04619 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " provedores"; "(System)" = "(Sistema)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar "; "API key" = "Chave de API"; @@ -911,6 +912,7 @@ "Weekly" = "Semanal"; "not detected" = "não detectado"; "Estimated from local Codex logs for the selected account." = "Estimado a partir de logs locais do Codex para a conta selecionada."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimado a partir do resumo de uso do MiniMax e dos preços publicados de pay-as-you-go · pode diferir da fatura"; "minimax_usage_amount_format" = "Uso: %@ / %@"; "minimax_used_percent_format" = "Usado %@"; "minimax_service_text_generation" = "Geração de texto"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 9dced37eb4..527f5eebff 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " leverantörer"; "(System)" = "(System)"; +"7d" = "7 d"; "30d" = "30 d"; "A managed Codex login is already running. Wait for it to finish before adding " = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till "; "API key" = "API-nyckel"; @@ -839,6 +840,7 @@ "Weekly" = "Vecka"; "not detected" = "inte hittad"; "Estimated from local Codex logs for the selected account." = "Uppskattat från lokala Codex-loggar för valt konto."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Uppskattat från MiniMax-användningssammanfattning och publicerade pay-as-you-go-priser · kan skilja sig från fakturan"; "minimax_usage_amount_format" = "Användning: %@ / %@"; "minimax_used_percent_format" = "Förbrukat %@"; "minimax_service_text_generation" = "Textgenerering"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 852eff87a5..4829476c51 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "ผู้ให้บริการ "; "(System)" = "(ระบบ)"; +"7d" = "7 วัน"; "30d" = "30 วัน"; "A managed Codex login is already running. Wait for it to finish before adding " = "การเข้าสู่ระบบ Codex ที่มีการจัดการกําลังทํางานอยู่แล้ว รอให้เสร็จก่อนที่จะเพิ่ม "; "API key" = "ปุ่ม API"; @@ -52,6 +53,26 @@ "CLI paths" = "เส้นทาง CLI"; "CLI sessions" = "CLI เซสชัน"; "Caches" = "แคช"; +"Cache hit: %@" = "อัตราแคชฮิต: %@"; +"Cache hit" = "แคชฮิต"; +"%@ usage" = "การใช้งาน %@"; +"Latest usage" = "การใช้งานล่าสุด"; +"7d tokens" = "การใช้งาน 7 วัน"; +"Last 7 days: %@ tokens" = "7 วันที่ผ่านมา: %@ โทเค็น"; +"Last 7 days" = "7 วันที่ผ่านมา"; +"Usage trend" = "แนวโน้มการใช้งาน"; +"MiniMax 7 day token usage trend" = "แนวโน้มโทเค็น MiniMax 7 วัน"; +"Language models only; data may be delayed" = "นับเฉพาะโมเดลภาษา ข้อมูลอาจล่าช้า"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ อินพุต · %@ เอาต์พุต · แคชฮิต %@"; +"%@ input · %@ output · %@ cache hit" = "%@ อินพุต · %@ เอาต์พุต · แคชฮิต %@"; +"Total consumed" = "ใช้ไปทั้งหมด"; +"Daily peak" = "สูงสุดรายวัน"; +"Active days" = "วันที่ใช้งาน"; +"Active days: %@" = "วันที่ใช้งาน: %@"; +"Streak: %@ days" = "ใช้ต่อเนื่อง: %@ วัน"; +"Usage rank: top %@%%" = "อันดับการใช้งาน: สูงสุด %@%%"; +"Token usage details" = "รายละเอียดการใช้โทเค็น"; +"Day totals" = "รวมรายวัน"; "Cancel" = "ยกเลิก"; "Check for Updates…" = "ตรวจสอบการอัปเดต..."; "Check for updates automatically" = "ตรวจสอบการอัปเดตโดยอัตโนมัติ"; @@ -123,6 +144,7 @@ "Enabled" = "เปิดใช้งาน"; "Error" = "ข้อผิดพลาด"; "Error simulation" = "การจําลองข้อผิดพลาด"; +"Expires: %@" = "หมดอายุ %@"; "Expose troubleshooting tools in the Debug tab." = "แสดงเครื่องมือการแก้ไขปัญหาในแท็บ แก้ไขข้อบกพร่อง"; "Failed" = "ล้มเหลว"; "False" = "เท็จ"; @@ -917,6 +939,7 @@ "Weekly" = "รายสัปดาห์"; "not detected" = "ตรวจไม่พบ"; "Estimated from local Codex logs for the selected account." = "ประมาณการจากบันทึก Codex ท้องถิ่นสําหรับบัญชีที่เลือก"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "ประมาณการจากสรุปการใช้งาน MiniMax และราคาแบบจ่ายตามการใช้งานที่เผยแพร่ · อาจแตกต่างจากใบแจ้งหนี้"; "minimax_usage_amount_format" = "การใช้งาน: %@ / %@"; "minimax_used_percent_format" = "ใช้ไป %@"; "minimax_service_text_generation" = "การสร้างข้อความ"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 525a0676a2..e1b0946096 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " sağlayıcı"; "(System)" = "(Sistem)"; +"7d" = "7 gün"; "30d" = "30 gün"; "A managed Codex login is already running. Wait for it to finish before adding " = "Yönetilen bir Codex girişi zaten çalışıyor. Eklemeden önce bitmesini bekleyin "; "API key" = "API anahtarı"; @@ -914,6 +915,7 @@ "Weekly" = "Haftalık"; "not detected" = "algılanmadı"; "Estimated from local Codex logs for the selected account." = "Seçili hesap için yerel Codex günlüklerinden tahmin edildi."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "MiniMax kullanım özetinden ve yayımlanan kullandıkça öde fiyatlarından tahmin edildi · faturadan farklı olabilir"; "minimax_usage_amount_format" = "Kullanım: %@ / %@"; "minimax_used_percent_format" = "%@ kullanıldı"; "minimax_service_text_generation" = "Metin Üretimi"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 8d3b548d46..45abc5d3e5 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "провайдерів"; "(System)" = "(Система)"; +"7d" = "7д"; "30d" = "30д"; "A managed Codex login is already running. Wait for it to finish before adding " = "Керований вхід до Codex вже запущено. Перш ніж додавати, зачекайте, поки він закінчиться"; "API key" = "Ключ API"; @@ -914,6 +915,7 @@ "Weekly" = "Щотижня"; "not detected" = "не виявлено"; "Estimated from local Codex logs for the selected account." = "Оцінено з локальних журналів Codex для вибраного облікового запису."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Оцінено за підсумком використання MiniMax і опублікованими тарифами pay-as-you-go · може відрізнятися від рахунку"; "minimax_usage_amount_format" = "Використання: %@ / %@"; "minimax_used_percent_format" = "Використаний %@"; "minimax_service_text_generation" = "Генерація тексту"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index bceca2f614..9d3a4754e3 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = "nhà cung cấp"; "(System)" = "(Hệ thống)"; +"7d" = "7d"; "30d" = "30d"; "A managed Codex login is already running. Wait for it to finish before adding " = "Đăng nhập Codex được quản lý đã chạy. Đợi quá trình này hoàn tất trước khi thêm"; "API key" = "API khóa"; @@ -910,6 +911,7 @@ "Weekly" = "Không phát hiện được"; "not detected" = "hàng tuần"; "Estimated from local Codex logs for the selected account." = "Được ước tính từ nhật ký Codex cục bộ cho tài khoản đã chọn."; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Ước tính từ tóm tắt sử dụng MiniMax và bảng giá trả theo mức dùng đã công bố · có thể khác hóa đơn"; "minimax_usage_amount_format" = "Mức sử dụng : %@ / %@"; "minimax_used_percent_format" = "Đã sử dụng %@"; "minimax_service_text_generation" = "Tạo văn bản"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 8477fc24c4..6f2e23b466 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 提供商"; "(System)" = "(System)"; +"7d" = "7 天"; "30d" = "30 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "托管 Codex 登录已在运行。请等待其完成后再添加 "; "API key" = "API 密钥"; @@ -55,6 +56,26 @@ "CLI paths" = "CLI 路径"; "CLI sessions" = "CLI 会话"; "Caches" = "缓存"; +"Cache hit: %@" = "缓存命中率:%@"; +"Cache hit" = "缓存命中"; +"%@ usage" = "%@ 调用量"; +"Latest usage" = "最新调用量"; +"7d tokens" = "近 7 天调用量"; +"Last 7 days: %@ tokens" = "近 7 天:%@ token"; +"Last 7 days" = "近 7 天"; +"Usage trend" = "调用趋势图"; +"MiniMax 7 day token usage trend" = "MiniMax 近 7 天 token 趋势"; +"Language models only; data may be delayed" = "当前仅统计语言模型,数据可能有延迟"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ 输入 · %@ 输出 · 缓存命中 %@"; +"%@ input · %@ output · %@ cache hit" = "%@ 输入 · %@ 输出 · 缓存命中 %@"; +"Total consumed" = "累计调用量"; +"Daily peak" = "单日峰值"; +"Active days" = "活跃天数"; +"Active days: %@" = "活跃天数:%@"; +"Streak: %@ days" = "连续使用:%@ 天"; +"Usage rank: top %@%%" = "用量排名:前 %@%%"; +"Token usage details" = "Token 用量详情"; +"Day totals" = "当日汇总"; "Cancel" = "取消"; "Check for Updates…" = "检查更新…"; "Check for updates automatically" = "自动检查更新"; @@ -125,6 +146,7 @@ "Enabled" = "已启用"; "Error" = "错误"; "Error simulation" = "错误模拟"; +"Expires: %@" = "有效期至 %@"; "Expose troubleshooting tools in the Debug tab." = "在“调试”标签中显示故障排除工具。"; "Failed" = "失败"; "False" = "假"; @@ -651,6 +673,7 @@ "cost_header_estimated" = "费用(估算)"; "cost_estimate_hint" = "根据本地日志估算 · 可能与账单不同"; "Estimated from local Codex logs for the selected account." = "根据所选账户的本地 Codex 日志估算。"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "根据用量详情与官方按量定价估算 · 可能与账单不同"; "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "未检测到启用 AI Assistant 的 JetBrains IDE。请安装 JetBrains IDE 并启用 AI Assistant。"; "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "未配置 OpenRouter API 令牌。请设置 OPENROUTER_API_KEY 环境变量,或在“设置”中配置。"; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "未找到 z.ai API 令牌。请在 ~/.codexbar/config.json 中设置 apiKey,或设置 Z_AI_API_KEY。"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 94c4ef5f73..54b5b01fd0 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -2,6 +2,7 @@ " providers" = " 提供者"; "(System)" = "(System)"; +"7d" = "7 天"; "30d" = "30 天"; "A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; "API key" = "API 金鑰"; @@ -661,6 +662,7 @@ "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列 App。CodexBar 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 CodexBar。"; "cost_header_estimated" = "費用(估算)"; "cost_estimate_hint" = "根據本機記錄估算 · 可能與帳單不同"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "根據用量詳情與官方按量定價估算 · 可能與帳單不同"; "copilot_device_code" = "裝置代碼已複製到剪貼簿:%1$@\n\n請到以下網址驗證:%2$@"; "copilot_waiting_text" = "請在瀏覽器中完成登入。\n登入完成後,此視窗會自動關閉。"; "vertex_ai_login_instructions" = "要追蹤 Vertex AI 使用量,請透過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 依照瀏覽器提示登入\n4. 設定你的專案:gcloud config set project PROJECT_ID\n\n要現在開啟終端嗎?"; diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 6fbda9c8a5..6962ead4c0 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -758,9 +758,10 @@ extension StatusItemController { self.store.weeklyPace(provider: provider, window: window, now: now) } case .resetTime: + let resetWindow = self.menuBarResetTimeWindow(for: provider, snapshot: snapshot) return MenuBarDisplayText.displayText( mode: mode, - percentWindow: percentWindow, + percentWindow: resetWindow, showUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, now: now) diff --git a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift index 50820ec0a1..f7c1aa9dec 100644 --- a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift +++ b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift @@ -16,7 +16,7 @@ extension StatusItemController { } let resetDates = self.menuBarCountdownProviders().compactMap { provider in - self.menuBarMetricWindow( + self.menuBarResetTimeWindow( for: provider, snapshot: self.store.snapshot(for: provider))?.resetsAt } diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 7b036c2117..99f2fc67a9 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -16,6 +16,7 @@ extension StatusItemController { Self.creditsHistoryChartID, Self.costHistoryChartID, Self.usageHistoryChartID, + Self.miniMaxUsageSummaryChartID, Self.storageBreakdownID, Self.statusComponentsID, Self.zaiHourlyUsageChartID, @@ -86,6 +87,14 @@ extension StatusItemController { } else { false } + case Self.miniMaxUsageSummaryChartID: + if let providerRawValue = placeholder.toolTip, + let provider = UsageProvider(rawValue: providerRawValue) + { + self.appendMiniMaxUsageSummaryChartItem(to: menu, provider: provider, width: width) + } else { + false + } case Self.storageBreakdownID: if let providerRawValue = placeholder.toolTip, let provider = UsageProvider(rawValue: providerRawValue) @@ -160,6 +169,12 @@ extension StatusItemController { } else { false } + case Self.miniMaxUsageSummaryChartID: + if let provider = identity.provider { + self.appendMiniMaxUsageSummaryChartItem(to: menu, provider: provider, width: width) + } else { + false + } case Self.storageBreakdownID: if let provider = identity.provider { self.appendStorageBreakdownItem(to: menu, provider: provider, width: width) @@ -237,6 +252,8 @@ extension StatusItemController { identity.provider.map(self.costHistoryRenderSignature(for:)) ?? "missing-provider" case Self.usageHistoryChartID: identity.provider.map(self.usageHistoryRenderSignature(for:)) ?? "missing-provider" + case Self.miniMaxUsageSummaryChartID: + identity.provider.map(self.miniMaxUsageSummaryRenderSignature(for:)) ?? "missing-provider" case Self.storageBreakdownID: identity.provider.map(self.storageBreakdownRenderSignature(for:)) ?? "missing-provider" case Self.statusComponentsID: @@ -254,6 +271,17 @@ extension StatusItemController { ].joined(separator: "|") } + private func miniMaxUsageSummaryRenderSignature(for provider: UsageProvider) -> String { + guard provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary + else { + return "none" + } + return usage.days.suffix(30).map { + "\($0.date):\($0.totalToken):\($0.cacheHitPercent ?? -1):\($0.models.count)" + }.joined(separator: "|") + } + private func costHistoryRenderSignature(for provider: UsageProvider) -> String { guard let snapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return "none" } return [ diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1ef999dd03..18eb811a3b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -36,7 +36,6 @@ extension StatusItemController { static let costHistoryChartID = "costHistoryChart" static let usageHistoryChartID = "usageHistoryChart" static let storageBreakdownID = "storageBreakdown" - static let statusComponentsID = "statusComponents" func shortcut(for action: MenuDescriptor.MenuAction) -> (key: String, modifiers: NSEvent.ModifierFlags)? { switch action { @@ -466,7 +465,8 @@ extension StatusItemController { surface: .liveCard) let hasCreditsHistory = codexProjection?.hasCreditsHistory == true let hasUsageBreakdown = codexProjection?.hasUsageBreakdown == true - let hasCostHistory = self.settings.costSummaryShowsSubmenu(for: currentProvider) && + let hasCostHistory = currentProvider != .minimax && + self.settings.costSummaryShowsSubmenu(for: currentProvider) && (self.store.tokenSnapshot(for: currentProvider)?.daily.isEmpty == false) let canShowBuyCredits = self.settings.showOptionalCreditsAndExtraUsage && codexProjection?.canShowBuyCredits == true @@ -760,6 +760,7 @@ extension StatusItemController { { menu.addItem(.separator()) } + self.addMiniMaxUsageSummarySectionIfNeeded(to: menu, context: context) if self.addZaiHourlyUsageMenuItemIfNeeded( to: menu, provider: context.currentProvider, @@ -1470,7 +1471,9 @@ extension StatusItemController { if provider == .openai { return self.makeOpenAIAPIUsageSubmenu(provider: provider, width: width) } - if UsageStore.tokenCostRequiresProviderSnapshot(provider) { + if UsageStore.tokenCostRequiresProviderSnapshot(provider), + provider != .minimax + { return self.makeCostHistorySubmenu(provider: provider, width: width) } if provider == .zai { @@ -1479,45 +1482,6 @@ extension StatusItemController { return nil } - func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { - guard let timeLimit = snapshot?.zaiUsage?.timeLimit else { return nil } - guard !timeLimit.usageDetails.isEmpty else { return nil } - - let submenu = NSMenu() - submenu.delegate = self - let titleItem = NSMenuItem(title: L("MCP details"), action: nil, keyEquivalent: "") - titleItem.isEnabled = false - submenu.addItem(titleItem) - - if let window = timeLimit.windowLabel { - let item = NSMenuItem(title: String(format: L("mcp_window"), window), action: nil, keyEquivalent: "") - item.isEnabled = false - submenu.addItem(item) - } - if let resetTime = timeLimit.nextResetTime { - let reset = self.settings.resetTimeDisplayStyle == .absolute - ? UsageFormatter.resetDescription(from: resetTime) - : UsageFormatter.resetCountdownDescription(from: resetTime) - let item = NSMenuItem(title: String(format: L("mcp_resets"), reset), action: nil, keyEquivalent: "") - item.isEnabled = false - submenu.addItem(item) - } - submenu.addItem(.separator()) - - let sortedDetails = timeLimit.usageDetails.sorted { - $0.modelCode.localizedCaseInsensitiveCompare($1.modelCode) == .orderedAscending - } - for detail in sortedDetails { - let usage = UsageFormatter.tokenCountString(detail.usage) - let item = NSMenuItem( - title: String(format: L("mcp_model_usage"), detail.modelCode, usage), - action: nil, - keyEquivalent: "") - submenu.addItem(item) - } - return submenu - } - private func makeUsageBreakdownSubmenu(width: CGFloat? = nil) -> NSMenu? { let breakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( from: self.store.openAIDashboard?.usageBreakdown ?? []) @@ -1568,7 +1532,8 @@ extension StatusItemController { } private func hasProviderNativeCostHistorySubmenu(provider: UsageProvider) -> Bool { - UsageStore.tokenCostRequiresProviderSnapshot(provider) && + provider != .minimax && + UsageStore.tokenCostRequiresProviderSnapshot(provider) && self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false } diff --git a/Sources/CodexBar/StatusItemController+MenuPresentation.swift b/Sources/CodexBar/StatusItemController+MenuPresentation.swift index 70dba06660..713808cecb 100644 --- a/Sources/CodexBar/StatusItemController+MenuPresentation.swift +++ b/Sources/CodexBar/StatusItemController+MenuPresentation.swift @@ -477,6 +477,18 @@ struct MenuCardSectionContainerView: View { @ViewBuilder let content: () -> Content var body: some View { + Group { + if self.refreshMonitor != nil { + TimelineView(.periodic(from: .now, by: 60)) { _ in + self.cardContent + } + } else { + self.cardContent + } + } + } + + private var cardContent: some View { self.content() .environment(\.menuItemHighlighted, self.highlightState.isHighlighted) .environment(\.menuCardRefreshMonitor, self.refreshMonitor) diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift new file mode 100644 index 0000000000..7b67e81ea9 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryConstants.swift @@ -0,0 +1,6 @@ +import AppKit + +extension StatusItemController { + static let miniMaxUsageSummaryChartID = "miniMaxUsageSummaryChart" + static let statusComponentsID = "statusComponents" +} diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift new file mode 100644 index 0000000000..fc9603870f --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift @@ -0,0 +1,92 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + @discardableResult + func addMiniMaxUsageSummaryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider, width: CGFloat) -> Bool { + guard let submenu = self.makeMiniMaxUsageSummarySubmenu(provider: provider, width: width) else { return false } + let item = NSMenuItem(title: L("Token usage details"), action: nil, keyEquivalent: "") + item.isEnabled = true + item.representedObject = "miniMaxUsageSummarySubmenu" + item.submenu = submenu + menu.addItem(item) + return true + } + + func makeMiniMaxUsageSummarySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { + guard provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return nil + } + if let width { + return self.makeHostedSubviewPlaceholderMenu( + chartID: Self.miniMaxUsageSummaryChartID, + provider: provider, + width: width) + } + return self.makeHostedSubviewPlaceholderMenu(chartID: Self.miniMaxUsageSummaryChartID, provider: provider) + } + + func minimaxShowsUsageSummaryOnMainCard(provider: UsageProvider) -> Bool { + guard provider == .minimax, self.settings.showOptionalCreditsAndExtraUsage else { return false } + guard let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return false + } + return true + } + + @discardableResult + func appendMiniMaxUsageSummaryChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + guard provider == .minimax, + let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, + usage.hasDisplayableData + else { + return false + } + + if !self.menuCardRenderingEnabledForController { + let chartItem = NSMenuItem() + chartItem.isEnabled = true + chartItem.representedObject = Self.miniMaxUsageSummaryChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } + + final class HostingRelay { + weak var hosting: MenuHostingView? + var minimumHeight: CGFloat = 1 + } + let relay = HostingRelay() + let showsSummaryKPIs = !self.minimaxShowsUsageSummaryOnMainCard(provider: provider) + let chartView = MiniMaxUsageSummaryChartMenuView( + usage: usage, + showsSummaryKPIs: showsSummaryKPIs, + onHeightChange: { height in + let resolved = max(height, relay.minimumHeight) + relay.hosting?.applyMeasuredHeight(width: width, height: resolved) + }, + width: width) + let hosting = MenuHostingView(rootView: chartView) + relay.hosting = hosting + let fittedHeight = self.hostedSubviewFittingHeight(for: hosting, width: width) + relay.minimumHeight = fittedHeight + hosting.applyMeasuredHeight(width: width, height: fittedHeight) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = true + chartItem.representedObject = Self.miniMaxUsageSummaryChartID + chartItem.toolTip = provider.rawValue + submenu.addItem(chartItem) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift new file mode 100644 index 0000000000..7bea490d7d --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift @@ -0,0 +1,13 @@ +import AppKit +import CodexBarCore + +extension StatusItemController { + func addMiniMaxUsageSummarySectionIfNeeded(to menu: NSMenu, context: MenuCardContext) { + let provider = context.currentProvider + let width = context.menuWidth + guard self.addMiniMaxUsageSummaryMenuItemIfNeeded(to: menu, provider: provider, width: width) else { + return + } + menu.addItem(.separator()) + } +} diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index beadd46c51..c561d4d21a 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -18,6 +18,7 @@ extension StatusItemController { return submenu } if UsageStore.tokenCostRequiresProviderSnapshot(provider), + provider != .minimax, let submenu = self.makeCostHistorySubmenu(provider: provider, width: width) { return submenu diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift index 712fb31371..27a8c2b59d 100644 --- a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -32,4 +32,43 @@ extension StatusItemController { menu.addItem(item) return true } + + func makeZaiUsageDetailsSubmenu(snapshot: UsageSnapshot?) -> NSMenu? { + guard let timeLimit = snapshot?.zaiUsage?.timeLimit else { return nil } + guard !timeLimit.usageDetails.isEmpty else { return nil } + + let submenu = NSMenu() + submenu.delegate = self + let titleItem = NSMenuItem(title: L("MCP details"), action: nil, keyEquivalent: "") + titleItem.isEnabled = false + submenu.addItem(titleItem) + + if let window = timeLimit.windowLabel { + let item = NSMenuItem(title: String(format: L("mcp_window"), window), action: nil, keyEquivalent: "") + item.isEnabled = false + submenu.addItem(item) + } + if let resetTime = timeLimit.nextResetTime { + let reset = self.settings.resetTimeDisplayStyle == .absolute + ? UsageFormatter.resetDescription(from: resetTime) + : UsageFormatter.resetCountdownDescription(from: resetTime) + let item = NSMenuItem(title: String(format: L("mcp_resets"), reset), action: nil, keyEquivalent: "") + item.isEnabled = false + submenu.addItem(item) + } + submenu.addItem(.separator()) + + let sortedDetails = timeLimit.usageDetails.sorted { + $0.modelCode.localizedCaseInsensitiveCompare($1.modelCode) == .orderedAscending + } + for detail in sortedDetails { + let usage = UsageFormatter.tokenCountString(detail.usage) + let item = NSMenuItem( + title: String(format: L("mcp_model_usage"), detail.modelCode, usage), + action: nil, + keyEquivalent: "") + submenu.addItem(item) + } + return submenu + } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 51afbae380..edf69133f3 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -333,6 +333,14 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin supportsAverage: self.settings.menuBarMetricSupportsAverage(for: provider)) } + func menuBarResetTimeWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { + if provider == .minimax { + return MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot) + ?? self.menuBarMetricWindow(for: provider, snapshot: snapshot) + } + return self.menuBarMetricWindow(for: provider, snapshot: snapshot) + } + private func codexMenuBarMetricWindow(snapshot: UsageSnapshot?) -> RateWindow? { guard let snapshot else { return nil } let projection = CodexConsumerProjection.make( diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index a460d791f6..2ac8f52b32 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -19,6 +19,17 @@ extension UsageStore { switch provider { case .codex, .claude: true + case .minimax: + if self.planUtilizationHistory[provider]?.isEmpty == false { + true + } else if let snapshot = self.snapshots[provider] { + !self.planUtilizationSeriesSamples( + provider: provider, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty + } else { + false + } default: if self.planUtilizationHistory[provider]?.isEmpty == false { true @@ -226,9 +237,15 @@ extension UsageStore { private func shouldRecordPlanUtilizationHistory(for provider: UsageProvider) -> Bool { switch provider { case .codex, .claude: - true + return true + case .minimax: + guard let snapshot = self.snapshots[provider] else { return false } + return !self.planUtilizationSeriesSamples( + provider: provider, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty default: - self.settings.historicalTrackingEnabled + return self.settings.historicalTrackingEnabled } } @@ -536,6 +553,9 @@ extension UsageStore { appendWindow(snapshot.primary, name: .session) appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .opus) + case .minimax: + appendWindow(snapshot.primary, name: .session) + appendWindow(snapshot.secondary, name: .weekly) case .antigravity: let namedWeeklyWindows = snapshot.extraRateWindows? .filter { diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 44d2e4e646..e1842339bd 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -70,6 +70,9 @@ extension UsageStore { snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() case .mistral: snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + case .minimax: + snapshot?.minimaxUsage?.usageSummary? + .toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) default: nil } @@ -77,7 +80,7 @@ extension UsageStore { nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { switch provider { - case .mistral, .openai: + case .mistral, .openai, .minimax: true default: false diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index f94ec771ed..32f5c1dab6 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -9,6 +9,7 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { 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" public var displayName: String { @@ -51,6 +52,10 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { 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 diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 52cd975352..17fad92205 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -28,8 +28,8 @@ public enum MiniMaxProviderDescriptor { 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)), @@ -99,6 +99,10 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { region: apiResult.resolvedRegion, environment: context.env, transport: ProviderHTTPClient.shared) + usage = try await MiniMaxUsageFetcher.attachingUsageSummaryIfAvailable( + to: usage, + context: fetchContext, + groupID: cookieOverride.groupID) usage = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( to: usage, context: fetchContext, 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/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift index 4e1c70973b..332b515871 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -285,13 +285,13 @@ extension MiniMaxUsageFetcher { let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID do { - let balance = try await MiniMaxTokenPlanCreditFetcher.fetch( + let credit = try await MiniMaxTokenPlanCreditFetcher.fetch( cookieHeader: context.cookie, groupID: resolvedGroupID, region: context.region, environment: context.environment, transport: context.transport) - return snapshot.withPointsBalanceIfMissing(balance) + return snapshot.withPointsBalanceIfMissing(credit.balance, expiresAt: credit.expiresAt) } catch is CancellationError { throw CancellationError() } catch let error as URLError where error.code == .cancelled { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift index e9c9ce7fef..924995f345 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift @@ -4,12 +4,17 @@ import FoundationNetworking #endif enum MiniMaxTokenPlanCreditFetcher { + struct CreditSnapshot: Equatable { + let balance: Double? + let expiresAt: Date? + } + static func fetch( cookieHeader: String, groupID: String?, region: MiniMaxAPIRegion, environment: [String: String], - transport: any ProviderHTTPTransport) async throws -> Double? + transport: any ProviderHTTPTransport) async throws -> CreditSnapshot { let effectiveRegion = Self.effectiveRegion(for: region, environment: environment) let url = try self.resolveCreditURL(region: region, environment: environment) @@ -37,16 +42,22 @@ enum MiniMaxTokenPlanCreditFetcher { } throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") } - return try self.parseBalance(data: response.data) + 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 self.balance(from: payload) + return CreditSnapshot( + balance: self.balance(from: payload), + expiresAt: self.earliestExpiry(from: payload)) } static func resolveCreditURL(region: MiniMaxAPIRegion, environment: [String: String]) throws -> URL { @@ -105,6 +116,20 @@ enum MiniMaxTokenPlanCreditFetcher { 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 doubleValue(_ value: Any?) -> Double? { if let number = value as? Double { return number } if let number = value as? Int { return Double(number) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift new file mode 100644 index 0000000000..a73bcddc5c --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift @@ -0,0 +1,37 @@ +import Foundation + +extension MiniMaxUsageFetcher { + static func attachingUsageSummaryIfAvailable( + to snapshot: MiniMaxUsageSnapshot, + context: WebFetchContext, + groupID: String?) async throws -> MiniMaxUsageSnapshot + { + guard MiniMaxCookieHeader.normalized(from: context.cookie) != nil else { + return snapshot + } + + let resolvedGroupID = groupID ?? MiniMaxCookieHeader.override(from: context.cookie)?.groupID + do { + let summary = try await MiniMaxUsageSummaryFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + return snapshot.withUsageSummary(summary) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch let error as MiniMaxUsageError { + if case .invalidCredentials = error, context.authorizationToken != nil { + throw error + } + Self.log.debug("MiniMax usage summary unavailable: \(error.localizedDescription)") + return snapshot + } catch { + Self.log.debug("MiniMax usage summary unavailable: \(error.localizedDescription)") + return snapshot + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 73582b3461..a07a93c454 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -442,10 +442,15 @@ public struct MiniMaxUsageFetcher: Sendable { enrichedSnapshot = snapshot } - return try await self.attachingTokenPlanCreditIfAvailable( + let summaryEnrichedSnapshot = try await self.attachingUsageSummaryIfAvailable( to: enrichedSnapshot, context: context, groupID: groupID) + + return try await self.attachingTokenPlanCreditIfAvailable( + to: summaryEnrichedSnapshot, + context: context, + groupID: groupID) } private static func fetchBillingSummary(context: WebFetchContext, now: Date) async throws -> MiniMaxBillingSummary { @@ -1032,12 +1037,17 @@ enum MiniMaxUsageParser { } private static func resetsAt(end: Date?, remains: Int?, now: Date) -> Date? { + if let remains, remains > 0 { + let seconds: TimeInterval = remains > 1_000_000 ? TimeInterval(remains) / 1000 : TimeInterval(remains) + let resetDate = now.addingTimeInterval(seconds) + if resetDate > now { + return resetDate + } + } 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) + return nil } private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { @@ -1485,19 +1495,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)" diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift new file mode 100644 index 0000000000..04b08df563 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift @@ -0,0 +1,153 @@ +import Foundation + +enum MiniMaxUsagePricing { + struct Pricing: Equatable { + let inputCostPerToken: Double + let outputCostPerToken: Double + let cacheReadInputCostPerToken: Double + let cacheCreationInputCostPerToken: Double + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + + init( + inputCostPerToken: Double, + outputCostPerToken: Double, + cacheReadInputCostPerToken: Double, + cacheCreationInputCostPerToken: Double = 0, + thresholdTokens: Int? = nil, + inputCostPerTokenAboveThreshold: Double? = nil, + outputCostPerTokenAboveThreshold: Double? = nil, + cacheReadInputCostPerTokenAboveThreshold: Double? = nil) + { + self.inputCostPerToken = inputCostPerToken + self.outputCostPerToken = outputCostPerToken + self.cacheReadInputCostPerToken = cacheReadInputCostPerToken + self.cacheCreationInputCostPerToken = cacheCreationInputCostPerToken + self.thresholdTokens = thresholdTokens + self.inputCostPerTokenAboveThreshold = inputCostPerTokenAboveThreshold + self.outputCostPerTokenAboveThreshold = outputCostPerTokenAboveThreshold + self.cacheReadInputCostPerTokenAboveThreshold = cacheReadInputCostPerTokenAboveThreshold + } + } + + private static let perMillion = 1_000_000.0 + private static let longContextThreshold = 512_000 + + private static let m27Standard = Pricing( + inputCostPerToken: 0.30 / perMillion, + outputCostPerToken: 1.20 / perMillion, + cacheReadInputCostPerToken: 0.06 / perMillion, + cacheCreationInputCostPerToken: 0.375 / perMillion) + + private static let m27Highspeed = Pricing( + inputCostPerToken: 0.60 / perMillion, + outputCostPerToken: 2.40 / perMillion, + cacheReadInputCostPerToken: 0.06 / perMillion, + cacheCreationInputCostPerToken: 0.375 / perMillion) + + private static let m25Legacy = Pricing( + inputCostPerToken: 0.30 / perMillion, + outputCostPerToken: 1.20 / perMillion, + cacheReadInputCostPerToken: 0.03 / perMillion, + cacheCreationInputCostPerToken: 0.375 / perMillion) + + private static let m3Standard = Pricing( + inputCostPerToken: 0.30 / perMillion, + outputCostPerToken: 1.20 / perMillion, + cacheReadInputCostPerToken: 0.06 / perMillion, + thresholdTokens: longContextThreshold, + inputCostPerTokenAboveThreshold: 0.60 / perMillion, + outputCostPerTokenAboveThreshold: 2.40 / perMillion, + cacheReadInputCostPerTokenAboveThreshold: 0.12 / perMillion) + + private static let includedInPlan = Pricing( + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheReadInputCostPerToken: 0, + cacheCreationInputCostPerToken: 0) + + static func minimaxCostUSD( + model: String, + inputToken: Int, + cacheReadToken: Int, + cacheCreateToken: Int, + outputToken: Int) -> Double? + { + guard let pricing = self.pricing(for: model, inputToken: inputToken) else { return nil } + return self.costUSD( + pricing: pricing, + inputToken: inputToken, + cacheReadToken: cacheReadToken, + cacheCreateToken: cacheCreateToken, + outputToken: outputToken) + } + + static func pricing(for model: String, inputToken: Int) -> Pricing? { + let normalized = self.normalizeModel(model) + if normalized.contains("coding-plan") { + return self.includedInPlan + } + if normalized.contains("m3") { + return self.m3Standard + } + if normalized.contains("m2.7"), normalized.contains("highspeed") { + return self.m27Highspeed + } + if normalized.contains("m2.7") { + return self.m27Standard + } + if normalized.contains("m2.5"), normalized.contains("highspeed") { + return Pricing( + inputCostPerToken: 0.60 / self.perMillion, + outputCostPerToken: 2.40 / self.perMillion, + cacheReadInputCostPerToken: 0.03 / self.perMillion, + cacheCreationInputCostPerToken: 0.375 / self.perMillion) + } + if normalized.contains("m2.5") || normalized.contains("m2.1") || normalized == "m2" { + return self.m25Legacy + } + if normalized.contains("m2") { + return self.m25Legacy + } + return self.m27Standard + } + + static func normalizeModel(_ model: String) -> String { + model + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "-") + } + + private static func costUSD( + pricing: Pricing, + inputToken: Int, + cacheReadToken: Int, + cacheCreateToken: Int, + outputToken: Int) -> Double + { + let input = max(0, inputToken) + let cached = min(max(0, cacheReadToken), input) + let nonCached = max(0, input - cached) + let cacheCreate = max(0, cacheCreateToken) + let output = max(0, outputToken) + + let usesLongContext = pricing.thresholdTokens.map { input > $0 } ?? false + let inputRate = usesLongContext + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let outputRate = usesLongContext + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + let cacheReadRate = usesLongContext + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + + return (Double(nonCached) * inputRate) + + (Double(cached) * cacheReadRate) + + (Double(cacheCreate) * pricing.cacheCreationInputCostPerToken) + + (Double(output) * outputRate) + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift index 133f78517b..7b37b6c1bf 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -17,7 +17,9 @@ extension MiniMaxUsageSnapshot { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, subscriptionRenewsAt: self.subscriptionRenewsAt) } @@ -34,12 +36,33 @@ extension MiniMaxUsageSnapshot { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: metadata.subscriptionExpiresAt ?? self.subscriptionExpiresAt, subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt) } - func withPointsBalanceIfMissing(_ pointsBalance: Double?) -> MiniMaxUsageSnapshot { + func withUsageSummary(_ usageSummary: MiniMaxUsageSummary?) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + usageSummary: usageSummary, + pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) + } + + func withPointsBalanceIfMissing(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot { guard self.pointsBalance == nil, let pointsBalance, pointsBalance >= 0 else { return self } return MiniMaxUsageSnapshot( planName: self.planName, @@ -52,7 +75,9 @@ extension MiniMaxUsageSnapshot { updatedAt: self.updatedAt, services: self.services, billingSummary: self.billingSummary, + usageSummary: self.usageSummary, pointsBalance: pointsBalance, + pointsBalanceExpiresAt: expiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, subscriptionRenewsAt: self.subscriptionRenewsAt) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index fe73270d0d..9a56fae6fc 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -11,7 +11,9 @@ public struct MiniMaxUsageSnapshot: Sendable { public let updatedAt: Date public let services: [MiniMaxServiceUsage]? public let billingSummary: MiniMaxBillingSummary? + public let usageSummary: MiniMaxUsageSummary? public let pointsBalance: Double? + public let pointsBalanceExpiresAt: Date? public let subscriptionExpiresAt: Date? public let subscriptionRenewsAt: Date? @@ -75,7 +77,9 @@ public struct MiniMaxUsageSnapshot: Sendable { updatedAt: Date, services: [MiniMaxServiceUsage]? = nil, billingSummary: MiniMaxBillingSummary? = nil, + usageSummary: MiniMaxUsageSummary? = nil, pointsBalance: Double? = nil, + pointsBalanceExpiresAt: Date? = nil, subscriptionExpiresAt: Date? = nil, subscriptionRenewsAt: Date? = nil) { @@ -89,7 +93,9 @@ public struct MiniMaxUsageSnapshot: Sendable { self.updatedAt = updatedAt self.services = services self.billingSummary = billingSummary + self.usageSummary = usageSummary self.pointsBalance = pointsBalance + self.pointsBalanceExpiresAt = pointsBalanceExpiresAt self.subscriptionExpiresAt = subscriptionExpiresAt self.subscriptionRenewsAt = subscriptionRenewsAt } @@ -106,7 +112,9 @@ public struct MiniMaxUsageSnapshot: Sendable { updatedAt: self.updatedAt, services: self.services, billingSummary: billingSummary, + usageSummary: self.usageSummary, pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, subscriptionRenewsAt: self.subscriptionRenewsAt) } @@ -240,6 +248,7 @@ extension MiniMaxUsageSnapshot { limit: 0, currencyCode: "Points", period: "MiniMax points balance", + resetsAt: self.pointsBalanceExpiresAt, updatedAt: self.updatedAt) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift new file mode 100644 index 0000000000..f502419c75 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift @@ -0,0 +1,133 @@ +import Foundation + +extension MiniMaxUsageSummary { + public var hasProjectableCost: Bool { + self.days.contains { self.projectedCostUSD(for: $0) != nil } + } + + public func projectedCostUSD(for day: MiniMaxUsageSummaryDay) -> Double? { + let breakdown = self.projectedModelBreakdowns(for: day) + guard breakdown.contains(where: { $0.costUSD != nil }) else { return nil } + return breakdown.compactMap(\.costUSD).reduce(0, +) + } + + public func projectedCostUSD(lastDays: Int) -> Double? { + let days = self.trendDays(last: lastDays) + var total = 0.0 + var seen = false + for day in days { + guard let cost = self.projectedCostUSD(for: day) else { continue } + total += cost + seen = true + } + return seen ? total : nil + } + + public func projectedModelBreakdowns( + for day: MiniMaxUsageSummaryDay) -> [CostUsageDailyReport.ModelBreakdown] + { + if !day.models.isEmpty { + return day.models.compactMap { model in + guard let costUSD = MiniMaxUsagePricing.minimaxCostUSD( + model: model.model, + inputToken: model.inputToken, + cacheReadToken: model.cacheReadToken, + cacheCreateToken: model.cacheCreateToken, + outputToken: model.outputToken) + else { + return nil + } + return CostUsageDailyReport.ModelBreakdown( + modelName: model.model, + costUSD: costUSD, + totalTokens: model.totalToken) + } + } + + guard day.totalToken > 0, + let costUSD = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M2.7", + inputToken: day.totalInputToken, + cacheReadToken: day.totalCacheReadToken, + cacheCreateToken: day.totalCacheCreateToken, + outputToken: day.totalOutputToken) + else { + return [] + } + + return [ + CostUsageDailyReport.ModelBreakdown( + modelName: "Day totals", + costUSD: costUSD, + totalTokens: day.totalToken), + ] + } + + public func toCostUsageTokenSnapshot( + historyDays: Int = 30, + now: Date = Date()) -> CostUsageTokenSnapshot? + { + guard self.hasDisplayableData else { return nil } + + let clampedHistoryDays = max(1, min(365, historyDays)) + let selectedDays = Array(self.days.suffix(clampedHistoryDays)) + guard !selectedDays.isEmpty else { return nil } + + var entries: [CostUsageDailyReport.Entry] = [] + entries.reserveCapacity(selectedDays.count) + var windowCost = 0.0 + var windowCostSeen = false + var windowTokens = 0 + + for day in selectedDays { + let breakdown = self.projectedModelBreakdowns(for: day) + let dayCost = breakdown.compactMap(\.costUSD).reduce(0, +) + let hasCost = breakdown.contains { $0.costUSD != nil } + if hasCost { + windowCost += dayCost + windowCostSeen = true + } + windowTokens += day.totalToken + + entries.append(CostUsageDailyReport.Entry( + date: day.date, + inputTokens: day.totalInputToken, + outputTokens: day.totalOutputToken, + cacheReadTokens: day.totalCacheReadToken > 0 ? day.totalCacheReadToken : nil, + cacheCreationTokens: day.totalCacheCreateToken > 0 ? day.totalCacheCreateToken : nil, + totalTokens: day.totalToken, + costUSD: hasCost ? dayCost : nil, + modelsUsed: breakdown.isEmpty ? nil : breakdown.map(\.modelName), + modelBreakdowns: breakdown.isEmpty ? nil : breakdown)) + } + + guard windowCostSeen else { return nil } + if windowCost == 0, self.isPlanIncludedOnly(days: selectedDays) { + return nil + } + + let latestEntry = entries.last + let sessionTokens = self.snapshotDay?.totalToken ?? latestEntry?.totalTokens + let sessionCostUSD = self.snapshotDay.flatMap { day in + entries.first(where: { $0.date == day.date })?.costUSD + } ?? latestEntry?.costUSD + + return CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: windowTokens > 0 ? windowTokens : nil, + last30DaysCostUSD: windowCostSeen ? windowCost : nil, + currencyCode: "USD", + historyDays: clampedHistoryDays, + historyLabel: nil, + daily: entries, + updatedAt: now) + } + + private func isPlanIncludedOnly(days: [MiniMaxUsageSummaryDay]) -> Bool { + let models = days.flatMap(\.models) + return !models.isEmpty && models.allSatisfy { + MiniMaxUsagePricing.normalizeModel($0.model).contains("coding-plan") + } + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift new file mode 100644 index 0000000000..e125567140 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift @@ -0,0 +1,362 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct MiniMaxUsageSummary: Sendable, Equatable { + public let totalDays: Int? + public let totalTokenConsumed: String? + public let usageRankingPercent: Double? + public let activeDays: Int? + public let currentConsecutiveDays: Int? + public let lastUpdateTime: String? + public let dailyTokenUsage: [Int] + public let days: [MiniMaxUsageSummaryDay] + + public var latestDay: MiniMaxUsageSummaryDay? { + self.days.last + } + + public var latestActiveDay: MiniMaxUsageSummaryDay? { + self.days.last { $0.totalToken > 0 } + } + + public var snapshotDateKey: String { + Self.dateKey(fromUpdateTime: self.lastUpdateTime) ?? Self.todayDateKey() + } + + public var snapshotDay: MiniMaxUsageSummaryDay? { + if let day = self.days.first(where: { $0.date == self.snapshotDateKey }) { + return day + } + return self.latestDay + } + + public var latestSnapshotTokens: Int { + if let day = self.snapshotDay, day.totalToken > 0 { + return day.totalToken + } + if let last = self.dailyTokenUsage.last, last > 0 { + return last + } + return self.latestDay?.totalToken ?? 0 + } + + public var last7DaysTokens: Int { + self.tokenTotal(lastDays: 7) + } + + public var last30DaysTokens: Int { + self.tokenTotal(lastDays: 30) + } + + public func trendDays(last count: Int) -> [MiniMaxUsageSummaryDay] { + if !self.days.isEmpty { + return Array(self.days.suffix(count)) + } + guard !self.dailyTokenUsage.isEmpty else { return [] } + let values = Array(self.dailyTokenUsage.suffix(count)) + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return values.enumerated().map { index, tokens in + let daysBack = values.count - 1 - index + let date = calendar.date(byAdding: .day, value: -daysBack, to: today) ?? today + return MiniMaxUsageSummaryDay( + date: formatter.string(from: date), + totalInputToken: 0, + totalCacheReadToken: 0, + totalCacheCreateToken: 0, + totalOutputToken: 0, + totalToken: tokens, + cacheHitPercent: nil, + models: []) + } + } + + public var hasDisplayableData: Bool { + !self.dailyTokenUsage.isEmpty || self.days.contains { $0.totalToken > 0 } + } + + public var latestModelNames: [String] { + (self.latestActiveDay?.models ?? []).map(\.model) + } + + public init( + totalDays: Int?, + totalTokenConsumed: String?, + usageRankingPercent: Double?, + activeDays: Int?, + currentConsecutiveDays: Int?, + lastUpdateTime: String?, + dailyTokenUsage: [Int], + days: [MiniMaxUsageSummaryDay]) + { + self.totalDays = totalDays + self.totalTokenConsumed = totalTokenConsumed + self.usageRankingPercent = usageRankingPercent + self.activeDays = activeDays + self.currentConsecutiveDays = currentConsecutiveDays + self.lastUpdateTime = lastUpdateTime + self.dailyTokenUsage = dailyTokenUsage + self.days = days + } + + private func tokenTotal(lastDays count: Int) -> Int { + if !self.dailyTokenUsage.isEmpty { + let sum = self.dailyTokenUsage.suffix(count).reduce(0, +) + if sum > 0 { + return sum + } + } + return self.days.suffix(count).reduce(0) { $0 + $1.totalToken } + } + + static func dateKey(fromUpdateTime raw: String?) -> String? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + let datePart = raw.split(separator: " ").first.map(String.init) ?? raw + let parts = datePart.split(separator: "-") + guard parts.count == 2, + let month = Int(parts[0]), + let day = Int(parts[1]), + (1...12).contains(month), + (1...31).contains(day) + else { + return nil + } + var components = DateComponents() + components.calendar = Calendar.current + components.timeZone = TimeZone.current + components.year = Calendar.current.component(.year, from: Date()) + components.month = month + components.day = day + guard let date = components.date else { return nil } + return Self.todayDateKey(for: date) + } + + static func todayDateKey(for date: Date = Date()) -> String { + let formatter = DateFormatter() + formatter.calendar = Calendar.current + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } +} + +public struct MiniMaxUsageSummaryDay: Sendable, Equatable { + public let date: String + public let totalInputToken: Int + public let totalCacheReadToken: Int + public let totalCacheCreateToken: Int + public let totalOutputToken: Int + public let totalToken: Int + public let cacheHitPercent: Double? + public let models: [MiniMaxUsageSummaryModel] + + public init( + date: String, + totalInputToken: Int, + totalCacheReadToken: Int, + totalCacheCreateToken: Int, + totalOutputToken: Int, + totalToken: Int, + cacheHitPercent: Double?, + models: [MiniMaxUsageSummaryModel]) + { + self.date = date + self.totalInputToken = totalInputToken + self.totalCacheReadToken = totalCacheReadToken + self.totalCacheCreateToken = totalCacheCreateToken + self.totalOutputToken = totalOutputToken + self.totalToken = totalToken + self.cacheHitPercent = cacheHitPercent + self.models = models + } +} + +public struct MiniMaxUsageSummaryModel: Sendable, Equatable { + public let model: String + public let inputToken: Int + public let cacheReadToken: Int + public let cacheCreateToken: Int + public let outputToken: Int + public let totalToken: Int + public let cacheHitPercent: Double? + + public init( + model: String, + inputToken: Int, + cacheReadToken: Int, + cacheCreateToken: Int, + outputToken: Int, + totalToken: Int, + cacheHitPercent: Double?) + { + self.model = model + self.inputToken = inputToken + self.cacheReadToken = cacheReadToken + self.cacheCreateToken = cacheCreateToken + self.outputToken = outputToken + self.totalToken = totalToken + self.cacheHitPercent = cacheHitPercent + } +} + +enum MiniMaxUsageSummaryFetcher { + static func fetch( + cookieHeader: String, + groupID: String?, + region: MiniMaxAPIRegion, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSummary + { + let effectiveRegion = MiniMaxTokenPlanCreditFetcher.effectiveRegion(for: region, environment: environment) + var request = URLRequest(url: effectiveRegion.tokenPlanUsageSummaryURL) + request.httpMethod = "GET" + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + if let groupID = groupID?.trimmingCharacters(in: .whitespacesAndNewlines), !groupID.isEmpty { + request.setValue(groupID, forHTTPHeaderField: "x-group-id") + } + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "accept") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "x-requested-with") + request.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + forHTTPHeaderField: "user-agent") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "accept-language") + let origin = MiniMaxSubscriptionMetadataFetcher.platformOriginURL(region: effectiveRegion) + request.setValue(origin.absoluteString, forHTTPHeaderField: "origin") + request.setValue(origin.absoluteString + "/", forHTTPHeaderField: "referer") + + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw MiniMaxUsageError.invalidCredentials + } + throw MiniMaxUsageError.apiError("HTTP \(response.statusCode)") + } + return try MiniMaxUsageSummaryParser.parse(data: response.data) + } +} + +private struct MiniMaxUsageSummaryPayload: Decodable { + let totalDays: Int? + let totalTokenConsumed: String? + let usageRankingPercent: Double? + let activeDays: Int? + let currentConsecutiveDays: Int? + let dailyTokenUsage: [Int] + let dateModelUsage: [MiniMaxUsageSummaryDayPayload] + let lastUpdateTime: String? + let baseResp: MiniMaxBaseResponse? + + private enum CodingKeys: String, CodingKey { + case totalDays = "total_days" + case totalTokenConsumed = "total_token_consumed" + case usageRankingPercent = "usage_ranking_percent" + case activeDays = "active_days" + case currentConsecutiveDays = "current_consecutive_days" + case dailyTokenUsage = "daily_token_usage" + case dateModelUsage = "date_model_usage" + case lastUpdateTime = "last_update_time" + case baseResp = "base_resp" + } +} + +private struct MiniMaxUsageSummaryDayPayload: Decodable { + let date: String + let totalInputToken: Int + let totalCacheReadToken: Int + let totalCacheCreateToken: Int + let totalOutputToken: Int + let totalToken: Int + let cacheHitPercent: String? + let models: [MiniMaxUsageSummaryModelPayload] + + private enum CodingKeys: String, CodingKey { + case date + case totalInputToken = "total_input_token" + case totalCacheReadToken = "total_cache_read_token" + case totalCacheCreateToken = "total_cache_create_token" + case totalOutputToken = "total_output_token" + case totalToken = "total_token" + case cacheHitPercent = "cache_hit_percent" + case models + } +} + +private struct MiniMaxUsageSummaryModelPayload: Decodable { + let model: String + let inputToken: Int + let cacheReadToken: Int + let cacheCreateToken: Int + let outputToken: Int + let totalToken: Int + let cacheHitPercent: String? + + private enum CodingKeys: String, CodingKey { + case model + case inputToken = "input_token" + case cacheReadToken = "cache_read_token" + case cacheCreateToken = "cache_create_token" + case outputToken = "output_token" + case totalToken = "total_token" + case cacheHitPercent = "cache_hit_percent" + } +} + +enum MiniMaxUsageSummaryParser { + static func parse(data: Data) throws -> MiniMaxUsageSummary { + let payload = try JSONDecoder().decode(MiniMaxUsageSummaryPayload.self, from: data) + if let status = payload.baseResp?.statusCode, status != 0 { + let message = payload.baseResp?.statusMessage ?? "status_code \(status)" + throw MiniMaxUsageError.apiError(message) + } + return MiniMaxUsageSummary( + totalDays: payload.totalDays, + totalTokenConsumed: payload.totalTokenConsumed, + usageRankingPercent: payload.usageRankingPercent, + activeDays: payload.activeDays, + currentConsecutiveDays: payload.currentConsecutiveDays, + lastUpdateTime: payload.lastUpdateTime, + dailyTokenUsage: payload.dailyTokenUsage, + days: payload.dateModelUsage.map(self.day)) + } + + private static func day(_ payload: MiniMaxUsageSummaryDayPayload) -> MiniMaxUsageSummaryDay { + MiniMaxUsageSummaryDay( + date: payload.date, + totalInputToken: payload.totalInputToken, + totalCacheReadToken: payload.totalCacheReadToken, + totalCacheCreateToken: payload.totalCacheCreateToken, + totalOutputToken: payload.totalOutputToken, + totalToken: payload.totalToken, + cacheHitPercent: self.percent(from: payload.cacheHitPercent), + models: payload.models.map(self.model)) + } + + private static func model(_ payload: MiniMaxUsageSummaryModelPayload) -> MiniMaxUsageSummaryModel { + MiniMaxUsageSummaryModel( + model: payload.model, + inputToken: payload.inputToken, + cacheReadToken: payload.cacheReadToken, + cacheCreateToken: payload.cacheCreateToken, + outputToken: payload.outputToken, + totalToken: payload.totalToken, + cacheHitPercent: self.percent(from: payload.cacheHitPercent)) + } + + private static func percent(from raw: String?) -> Double? { + guard let raw else { return nil } + let trimmed = raw + .replacingOccurrences(of: "%", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return Double(trimmed) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift index 1082c9958d..abe06b7148 100644 --- a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -428,6 +428,8 @@ public struct MiniMaxDiagnosticDetails: Codable, Sendable { 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 +442,8 @@ public struct MiniMaxDiagnosticDetails: Codable, Sendable { self.usedPercent = snapshot.usedPercent self.resetsAt = snapshot.resetsAt self.pointsBalance = snapshot.pointsBalance + self.pointsBalanceExpiresAt = snapshot.pointsBalanceExpiresAt + self.usageSummaryPresent = snapshot.usageSummary != nil self.services = snapshot.services?.map { MiniMaxDiagnosticServiceUsage(from: $0) } self.billingSummaryPresent = snapshot.billingSummary != nil } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 74185447db..74db27f824 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -127,6 +127,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, @@ -234,7 +249,20 @@ public enum UsageFormatter { value.formatted(.currency(code: currencyCode).locale(Locale(identifier: "en_US"))) } + public static func decimalString(_ value: Double, fractionDigits: Int = 2) -> String { + String(format: "%.\(fractionDigits)f", value) + } + + public static func optionalPercentString(_ percent: Double?, fractionDigits: Int = 2) -> String { + guard let percent else { return "—" } + return String(format: "%.\(fractionDigits)f%%", percent) + } + public static func tokenCountString(_ value: Int) -> String { + self.tokenCountString(value, fractionDigits: 1) + } + + public static func tokenCountString(_ value: Int, fractionDigits: Int) -> String { let absValue = abs(value) let sign = value < 0 ? "-" : "" @@ -247,7 +275,9 @@ public enum UsageFormatter { for unit in units where absValue >= unit.threshold { let scaled = Double(absValue) / unit.divisor let formatted: String - if scaled >= 10 { + if fractionDigits == 2 { + formatted = String(format: "%.2f", scaled) + } else if scaled >= 10 { formatted = String(format: "%.0f", scaled) } else { var s = String(format: "%.1f", scaled) @@ -378,7 +408,7 @@ public enum UsageFormatter { nil } - let tokenDetail = totalTokens.map(self.tokenCountString) + let tokenDetail = totalTokens.map { self.tokenCountString($0) } let parts = [costDetail, tokenDetail].compactMap(\.self) guard !parts.isEmpty else { return nil } return parts.joined(separator: " · ") diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 564c7dd4e3..5eb969a208 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -149,6 +149,30 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.remainingPercent == 0) } + @Test + func `nearest reset window picks soonest minimax quota reset`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(3 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + + let window = MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot) + + #expect(window?.resetsAt == sessionReset) + #expect(window?.windowMinutes == 300) + } + @Test func `automatic metric preserves exhausted minimax session lane`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift index 231e3b2e6d..068fc3f1b3 100644 --- a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift +++ b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation import Testing @@ -134,3 +135,56 @@ struct MenuBarResetTimeDisplayTests { #expect(text == "58%") } } + +@Suite(.serialized) +@MainActor +struct MenuBarMiniMaxResetTimeDisplayTests { + @Test + func `reset time mode shows nearest minimax quota reset`() { + let settings = testSettingsStore(suiteName: "MenuBarMiniMaxResetTimeDisplayTests-nearest-reset") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .minimax + settings.menuBarDisplayMode = .resetTime + settings.resetTimesShowAbsolute = true + settings.setMenuBarMetricPreference(.automatic, for: .minimax) + if let metadata = ProviderRegistry.shared.metadata[.minimax] { + settings.setProviderEnabled(provider: .minimax, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(3 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + + let resetWindow = controller.menuBarResetTimeWindow(for: .minimax, snapshot: snapshot) + #expect(resetWindow?.resetsAt == sessionReset) + + let displayText = controller.menuBarDisplayText(for: .minimax, snapshot: snapshot) + + #expect(displayText == "↻ \(UsageFormatter.resetDescription(from: sessionReset, now: now))") + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index fa479423ee..2af884549f 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -636,6 +636,7 @@ struct MiniMaxMenuCardModelTests { resetDescription: "Resets in 6 days"), ], pointsBalance: 14000, + pointsBalanceExpiresAt: Date(timeIntervalSince1970: 1_784_995_199), subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) let snapshot = minimax.toUsageSnapshot() let metadata = try #require(ProviderDefaults.metadata[.minimax]) @@ -673,6 +674,7 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[1].cardStyle == false) #expect(model.providerCost?.title == "Credits") #expect(model.providerCost?.spendLine == "Balance: 14000") + #expect(model.providerCost?.personalSpendLine?.contains("Jul 25, 2026 at 11:59:59") == true) #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 a815d0b12b..62430573ad 100644 --- a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift +++ b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift @@ -58,6 +58,12 @@ struct MiniMaxCurrentTokenPlanResponseTests { 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") @@ -79,6 +85,7 @@ struct MiniMaxCurrentTokenPlanResponseTests { "platform.minimaxi.com", "platform.minimaxi.com", "www.minimaxi.com", + "www.minimaxi.com", ]) } diff --git a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift index 461c7f66ae..defaed616b 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift @@ -4,6 +4,295 @@ import Testing @testable import CodexBar struct MiniMaxMenuCardBillingTests { + @Test + func `minimax usage summary renders cost inline dashboard when pricing is available`() throws { + let now = Date() + let summary = MiniMaxUsageSummary( + totalDays: 77, + totalTokenConsumed: "2.37B", + usageRankingPercent: 3.8, + activeDays: 60, + currentConsecutiveDays: 31, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [201_889, 2_800_317, 4_486_224, 88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-06-29", + totalInputToken: 200_395, + totalCacheReadToken: 172_249, + totalCacheCreateToken: 0, + totalOutputToken: 1494, + totalToken: 201_889, + cacheHitPercent: 85.95, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 200_395, + cacheReadToken: 172_249, + cacheCreateToken: 0, + outputToken: 1494, + totalToken: 374_138, + cacheHitPercent: 85.95), + ]), + MiniMaxUsageSummaryDay( + date: "2026-06-30", + totalInputToken: 2_778_091, + totalCacheReadToken: 1_809_827, + totalCacheCreateToken: 0, + totalOutputToken: 22226, + totalToken: 2_800_317, + cacheHitPercent: 65.15, + models: []), + MiniMaxUsageSummaryDay( + date: "2026-07-01", + totalInputToken: 4_454_050, + totalCacheReadToken: 3_139_433, + totalCacheCreateToken: 0, + totalOutputToken: 32174, + totalToken: 4_486_224, + cacheHitPercent: 70.48, + models: []), + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.6), + ]), + ]) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: nil, + billingSummary: nil, + usageSummary: summary) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis[0].title == "Today") + #expect(model.inlineUsageDashboard?.kpis[0].value == "$0.01") + #expect(model.inlineUsageDashboard?.kpis[1].title == "30d cost") + #expect(model.inlineUsageDashboard?.kpis[2].title == "07-02 20:00 usage") + #expect(model.inlineUsageDashboard?.kpis[2].value == "88.00K") + #expect(model.inlineUsageDashboard?.kpis[3].title == "7d tokens") + #expect(model.inlineUsageDashboard?.kpis[3].value == "7.58M") + #expect(model.inlineUsageDashboard?.kpis[4].title == "Cache hit") + #expect(model.inlineUsageDashboard?.kpis[4].value == "74.63%") + #expect(model.inlineUsageDashboard?.kpis[5].title == "30d tokens") + #expect(model.inlineUsageDashboard?.kpis[5].value == "7.58M") + #expect( + model.inlineUsageDashboard?.detailLines.contains { + $0.contains("Top model") + } == true) + } + + @Test + func `minimax usage summary still renders token inline dashboard without priced models`() throws { + let now = Date() + let summary = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "1", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [1], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 1, + totalCacheReadToken: 0, + totalCacheCreateToken: 0, + totalOutputToken: 0, + totalToken: 1, + cacheHitPercent: 0, + models: [ + MiniMaxUsageSummaryModel( + model: "coding-plan-vlm", + inputToken: 1, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 0, + totalToken: 1, + cacheHitPercent: 0), + ]), + ]) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: nil, + billingSummary: nil, + usageSummary: summary) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(summary.toCostUsageTokenSnapshot() == nil) + #expect(model.inlineUsageDashboard?.kpis[0].title == "07-02 20:00 usage") + #expect(model.inlineUsageDashboard?.kpis[0].value == "1") + } + + @Test + func `minimax usage summary KPIs format tokens to two decimal places`() throws { + let now = Date() + let summary = MiniMaxUsageSummary( + totalDays: 77, + totalTokenConsumed: "1.1B", + usageRankingPercent: 3.8, + activeDays: 60, + currentConsecutiveDays: 31, + lastUpdateTime: "07-02 21:00", + dailyTokenUsage: [88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "coding-plan-vlm", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63), + ]), + ]) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: nil, + billingSummary: nil, + usageSummary: summary) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis[0].value == "88.00K") + #expect(model.inlineUsageDashboard?.kpis[1].value == "88.00K") + #expect(model.inlineUsageDashboard?.kpis[2].value == "74.63%") + #expect(model.inlineUsageDashboard?.kpis[3].value == "88.00K") + } + @Test func `minimax billing history renders inline dashboard`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index dc9245caa8..514c7bf412 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -961,6 +961,12 @@ struct MiniMaxUsageParserTests { 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") @@ -1061,6 +1067,12 @@ 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") + } #expect(url.path == "/backend/account/token_plan_credit") return Self.httpResponse(url: url, body: creditBody, contentType: "application/json") } @@ -1079,7 +1091,7 @@ struct MiniMaxUsageParserTests { #expect(snapshot.pointsBalance == 20000) let billingRequests = requests.filter { $0.url?.path == "/account/amount" } #expect(billingRequests.isEmpty) - #expect(requests.count == 2) + #expect(requests.count == 3) } @Test diff --git a/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift new file mode 100644 index 0000000000..5d0221c67e --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiniMaxResetDescriptionTests { + @Test + func `countdown phrase includes minutes for multi hour windows`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let resetsAt = now.addingTimeInterval((2 * 60 * 60) + (34 * 60)) + + #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((3 * 24 * 60 * 60) + (5 * 60 * 60) + (12 * 60)) + + #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 + ((2 * 60 * 60) + (15 * 60)) * 1000 + 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 + (2 * 60 * 60) * 1000 + 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") + } +} diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift index 144feae58c..32551184b8 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -261,6 +261,12 @@ struct MiniMaxTokenPlanChangeTests { 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") @@ -304,6 +310,12 @@ struct MiniMaxTokenPlanChangeTests { 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") @@ -324,6 +336,7 @@ struct MiniMaxTokenPlanChangeTests { "platform.minimaxi.com", "www.minimaxi.com", "www.minimaxi.com", + "www.minimaxi.com", ]) } @@ -613,6 +626,12 @@ struct MiniMaxTokenPlanChangeTests { 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 index 21850aa4f4..a94a733913 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -7,8 +7,9 @@ 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 balance = try MiniMaxTokenPlanCreditFetcher.parseBalance(data: data) - #expect(balance == 20000) + let snapshot = try MiniMaxTokenPlanCreditFetcher.parseSnapshot(data: data) + #expect(snapshot.balance == 20000) + #expect(snapshot.expiresAt == Date(timeIntervalSince1970: 1_784_995_199.999)) } @Test @@ -86,6 +87,12 @@ struct MiniMaxTokenPlanCreditTests { if url.path.contains("coding_plan/remains") { return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse( + url: url, + body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, + contentType: "application/json") + } #expect(url.host == "www.minimaxi.com") #expect(url.path == "/backend/account/token_plan_credit") return Self.httpResponse(url: url, body: creditJSON, contentType: "application/json") @@ -265,6 +272,54 @@ struct MiniMaxTokenPlanCreditTests { #expect(enriched.pointsBalance == 20000) } + @Test + func `api usage summary enrichment uses resolved china web host after global retry`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let summaryJSON = """ + { + "last_update_time": "07-02 18:00", + "daily_token_usage": [19880, 16060000, 1020000000], + "date_model_usage": [], + "base_resp": { "status_code": 0 } + } + """ + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.host == "api.minimax.io" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + if url.host == "api.minimaxi.com", url.path.contains("remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse(url: url, body: summaryJSON, contentType: "application/json") + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: transport) + let fetchContext = MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=abc", + authorizationToken: nil, + region: apiResult.resolvedRegion, + environment: [:], + transport: transport) + let enriched = try await MiniMaxUsageFetcher.attachingUsageSummaryIfAvailable( + to: apiResult.snapshot, + context: fetchContext, + groupID: nil) + + #expect(apiResult.resolvedRegion == .chinaMainland) + #expect(enriched.usageSummary?.hasDisplayableData == true) + #expect(enriched.usageSummary?.latestSnapshotTokens == 1_020_000_000) + #expect(enriched.usageSummary?.last7DaysTokens == 1_036_079_880) + } + private static func fixtureURL(named name: String) throws -> URL { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -307,7 +362,7 @@ struct MiniMaxTokenPlanCreditTests { let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( apiToken: apiToken, region: MiniMaxAPIRegion(rawValue: minimax.region ?? "") ?? .global) - let balance = try await MiniMaxTokenPlanCreditFetcher.fetch( + let credit = try await MiniMaxTokenPlanCreditFetcher.fetch( cookieHeader: cookieHeader, groupID: cookieOverride?.groupID, region: apiResult.resolvedRegion, @@ -315,8 +370,8 @@ struct MiniMaxTokenPlanCreditTests { transport: ProviderHTTPClient.shared) print("LIVE_MINIMAX_COOKIE_RESOLVED=true") print("LIVE_MINIMAX_RESOLVED_REGION=\(apiResult.resolvedRegion.rawValue)") - print("LIVE_MINIMAX_BALANCE=\(balance.map { String($0) } ?? "nil")") - let balanceValue = try #require(balance) + 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), diff --git a/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift new file mode 100644 index 0000000000..1b6dfd0fb9 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiniMaxUsagePricingTests { + @Test + func `m27 standard pricing bills cache read separately from input`() { + let cost = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M2.7", + inputToken: 1_000_000, + cacheReadToken: 500_000, + cacheCreateToken: 0, + outputToken: 0) + + #expect(cost != nil) + #expect(abs((cost ?? 0) - 0.18) < 0.0001) + } + + @Test + func `m3 long context uses higher rates above threshold`() { + let shortContext = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M3-512k", + inputToken: 400_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 1_000_000) + let longContext = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M3", + inputToken: 600_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 1_000_000) + + #expect(shortContext != nil) + #expect(longContext != nil) + #expect((longContext ?? 0) > (shortContext ?? 0)) + } + + @Test + func `coding plan models are treated as zero cost`() { + let cost = MiniMaxUsagePricing.minimaxCostUSD( + model: "coding-plan-vlm", + inputToken: 1_000_000, + cacheReadToken: 500_000, + cacheCreateToken: 100_000, + outputToken: 250_000) + + #expect(cost == 0) + } + + @Test + func `projected daily cost aggregates model breakdown`() { + let summary = Self.sampleSummary() + guard let day = summary.days.last else { + Issue.record("Expected sample summary day") + return + } + let cost = summary.projectedCostUSD(for: day) + #expect(cost != nil) + #expect((cost ?? 0) > 0) + #expect(summary.projectedCostUSD(lastDays: 30) != nil) + } + + @Test + func `snapshot day with model breakdown projects cost history`() { + let summary = Self.sampleSummary() + let snapshot = summary.toCostUsageTokenSnapshot( + historyDays: 30, + now: Date(timeIntervalSince1970: 1_735_689_600)) + + #expect(snapshot != nil) + #expect(snapshot?.sessionCostUSD != nil) + #expect(snapshot?.last30DaysCostUSD != nil) + #expect(snapshot?.daily.count == 2) + #expect(snapshot?.daily.last?.modelBreakdowns?.count == 1) + #expect(snapshot?.daily.last?.modelBreakdowns?.first?.modelName == "MiniMax-M3-512k") + } + + private static func sampleSummary() -> MiniMaxUsageSummary { + MiniMaxUsageSummary( + totalDays: 77, + totalTokenConsumed: "2.37B", + usageRankingPercent: 3.8, + activeDays: 60, + currentConsecutiveDays: 31, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [201_889, 2_800_317, 4_486_224, 88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-06-29", + totalInputToken: 200_395, + totalCacheReadToken: 172_249, + totalCacheCreateToken: 0, + totalOutputToken: 1494, + totalToken: 201_889, + cacheHitPercent: 85.95, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 200_395, + cacheReadToken: 172_249, + cacheCreateToken: 0, + outputToken: 1494, + totalToken: 374_138, + cacheHitPercent: 85.95), + ]), + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.6), + ]), + ]) + } +} diff --git a/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift b/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift new file mode 100644 index 0000000000..4bf192b9fc --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift @@ -0,0 +1,134 @@ +import CodexBarCore +import Testing +@testable import CodexBar + +struct MiniMaxUsageSummaryChartMenuViewTests { + @Test + @MainActor + func `day detail primary includes projected daily cost`() { + let usage = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "88K", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 21:00", + dailyTokenUsage: [88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.6), + ]), + ]) + let primary = MiniMaxUsageSummaryChartMenuView._dayDetailPrimaryForTesting( + usage: usage, + dateKey: "2026-07-02") + #expect(primary?.contains("74.63%") == true) + #expect(primary?.contains("$") == true) + } + + @Test + @MainActor + func `summary KPI values use two decimal token formatting`() { + let usage = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "88K", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 21:00", + dailyTokenUsage: [88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: []), + ]) + let values = MiniMaxUsageSummaryChartMenuView.summaryKPIValues(usage: usage) + #expect(values == ["88.00K", "88.00K", "88.00K", "74.63%"]) + } + + @Test + @MainActor + func `model breakdown keeps every item behind a bounded scrolling viewport`() { + #expect(MiniMaxUsageSummaryChartMenuView.detailViewportRowCount(itemCount: 6) == 4) + #expect(MiniMaxUsageSummaryChartMenuView.detailRowsNeedScrolling(itemCount: 6)) + #expect(!MiniMaxUsageSummaryChartMenuView.detailRowsNeedScrolling(itemCount: 3)) + } + + @Test + @MainActor + func `usage summary detail height follows visible rows and caps at viewport limit`() { + let singleRowHeight = MiniMaxUsageSummaryChartMenuView._detailViewportHeightForTesting( + modeSubtitlePresence: [true]) + let twoRowHeight = MiniMaxUsageSummaryChartMenuView._detailViewportHeightForTesting( + modeSubtitlePresence: [true, true]) + let fourRowHeight = MiniMaxUsageSummaryChartMenuView._detailViewportHeightForTesting( + modeSubtitlePresence: [true, true, true, true]) + let fiveRowHeight = MiniMaxUsageSummaryChartMenuView._detailViewportHeightForTesting( + modeSubtitlePresence: [true, true, true, true, true]) + + #expect(singleRowHeight < twoRowHeight) + #expect(twoRowHeight < fourRowHeight) + #expect(fiveRowHeight == fourRowHeight) + + let emptyBlockHeight = MiniMaxUsageSummaryChartMenuView._detailBlockHeightForTesting( + modeSubtitlePresence: []) + let populatedBlockHeight = MiniMaxUsageSummaryChartMenuView._detailBlockHeightForTesting( + modeSubtitlePresence: [true]) + #expect(emptyBlockHeight < populatedBlockHeight) + } + + @Test + @MainActor + func `usage summary card height grows with selected day model count`() { + let oneModel = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [true], + hasChart: true) + let threeModels = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [true, true, true], + hasChart: true) + let fourModels = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [true, true, true, true], + hasChart: true) + let fiveModels = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [true, true, true, true, true], + hasChart: true) + + #expect(oneModel < threeModels) + #expect(threeModels < fourModels) + #expect(fiveModels == fourModels) + } + + @Test + @MainActor + func `resolved card height includes chart section when data is present`() { + let withChart = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [], + hasChart: true) + let withoutChart = MiniMaxUsageSummaryChartMenuView._totalCardHeightForTesting( + modeSubtitlePresence: [], + hasChart: false) + #expect(withChart > withoutChart) + #expect(withChart - withoutChart > 130) + } +} diff --git a/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift new file mode 100644 index 0000000000..05e2bce61f --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiniMaxUsageSummaryTests { + @Test + func `parses usage summary with cache hit percentages`() throws { + let body = """ + { + "total_days": 77, + "total_token_consumed": "2.37B", + "active_days": 60, + "current_consecutive_days": 31, + "last_update_time": "07-02 17:00", + "daily_token_usage": [201889, 2800317, 4486224], + "date_model_usage": [ + { + "date": "2026-06-29", + "models": [ + { + "model": "MiniMax-M3-512k", + "input_token": 200395, + "cache_read_token": 172249, + "cache_create_token": 0, + "output_token": 1494, + "total_token": 374138, + "cache_hit_percent": "85.95%" + } + ], + "total_input_token": 200395, + "total_cache_read_token": 172249, + "total_cache_create_token": 0, + "total_output_token": 1494, + "total_token": 201889, + "cache_hit_percent": "85.95%" + }, + { + "date": "2026-06-30", + "models": [], + "total_input_token": 2778091, + "total_cache_read_token": 1809827, + "total_cache_create_token": 0, + "total_output_token": 22226, + "total_token": 2800317, + "cache_hit_percent": "65.15%" + }, + { + "date": "2026-07-01", + "models": [], + "total_input_token": 4454050, + "total_cache_read_token": 3139433, + "total_cache_create_token": 0, + "total_output_token": 32174, + "total_token": 4486224, + "cache_hit_percent": "70.48%" + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + let summary = try MiniMaxUsageSummaryParser.parse(data: Data(body.utf8)) + + #expect(summary.totalTokenConsumed == "2.37B") + #expect(summary.activeDays == 60) + #expect(summary.currentConsecutiveDays == 31) + #expect(summary.lastUpdateTime == "07-02 17:00") + #expect(summary.days.count == 3) + #expect(summary.days[0].cacheHitPercent == 85.95) + #expect(summary.days[0].models[0].cacheHitPercent == 85.95) + #expect(summary.last30DaysTokens == 7_488_430) + #expect(summary.last7DaysTokens == 7_488_430) + #expect(summary.latestSnapshotTokens == 4_486_224) + #expect(summary.hasDisplayableData) + #expect(summary.latestActiveDay?.date == "2026-07-01") + } + + @Test + func `snapshot tokens prefer update day over previous full day`() { + let summary = MiniMaxUsageSummary( + totalDays: 2, + totalTokenConsumed: "1.0M", + usageRankingPercent: nil, + activeDays: 2, + currentConsecutiveDays: 2, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [4_486_224, 88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-01", + totalInputToken: 4_000_000, + totalCacheReadToken: 3_000_000, + totalCacheCreateToken: 0, + totalOutputToken: 32000, + totalToken: 4_486_224, + cacheHitPercent: 70.48, + models: []), + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: []), + ]) + + #expect(summary.latestSnapshotTokens == 88000) + #expect(summary.snapshotDay?.date == "2026-07-02") + } + + @Test + func `synthesizes trend days from daily token usage when model usage is missing`() { + let summary = MiniMaxUsageSummary( + totalDays: 3, + totalTokenConsumed: "1.0M", + usageRankingPercent: nil, + activeDays: 3, + currentConsecutiveDays: 3, + lastUpdateTime: "07-02 18:00", + dailyTokenUsage: [100, 200, 300], + days: []) + + #expect(summary.hasDisplayableData) + #expect(summary.trendDays(last: 3).map(\.totalToken) == [100, 200, 300]) + #expect(summary.last7DaysTokens == 600) + #expect(summary.latestSnapshotTokens == 300) + } +} diff --git a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift index dd86189a39..587a3041ea 100644 --- a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift @@ -85,4 +85,40 @@ struct PlanUtilizationHistoryChartMenuViewTests { #expect(model.visibleSeries == ["weekly:10080"]) #expect(model.selectedSeries == "weekly:10080") } + + @Test + func `minimax snapshot exposes session and weekly chart series`() { + let histories = [ + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 12, + resetsAt: nil), + ]), + PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 4, + resetsAt: nil), + ]), + ] + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 4, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .minimax, + snapshot: snapshot) + + #expect(model.visibleSeries == ["session:300", "weekly:10080"]) + #expect(model.selectedSeries == "session:300") + } } diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index e50877eb34..bcc9971218 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -261,6 +261,21 @@ struct UsageFormatterTests { #expect(UsageFormatter.tokenCountString(-42) == "-42") } + @Test + func `token count string supports fixed two decimal abbreviations`() { + #expect(UsageFormatter.tokenCountString(88000, fractionDigits: 2) == "88.00K") + #expect(UsageFormatter.tokenCountString(2500, fractionDigits: 2) == "2.50K") + #expect(UsageFormatter.tokenCountString(7_576_430, fractionDigits: 2) == "7.58M") + #expect(UsageFormatter.tokenCountString(1_100_000_000, fractionDigits: 2) == "1.10B") + } + + @Test + func `optional percent string keeps two decimal places`() { + #expect(UsageFormatter.optionalPercentString(74.6) == "74.60%") + #expect(UsageFormatter.optionalPercentString(3.8) == "3.80%") + #expect(UsageFormatter.optionalPercentString(nil) == "—") + } + @Test func `clean plan maps O auth to ollama`() { #expect(UsageFormatter.cleanPlanName("oauth") == "Ollama") diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 13bb26e090..7d757631df 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -637,6 +637,55 @@ struct UsageStorePlanUtilizationTests { #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.resetsAt == secondaryReset) } + @MainActor + @Test + func `record plan history persists minimax session and weekly series`() async { + let store = Self.makeStore() + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 12, + limit: 100, + percent: 12, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let snapshot = minimax.toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .minimax) + + await store.recordPlanUtilizationHistorySample( + provider: .minimax, + snapshot: snapshot, + now: now) + + let histories = store.planUtilizationHistory(for: .minimax) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 12) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 4) + #expect(store.supportsPlanUtilizationHistory(for: .minimax)) + #expect(!store.shouldHidePlanUtilizationMenuItem(for: .minimax)) + } + @MainActor @Test func `record plan history skips invalid zero minute windows`() async { diff --git a/docs/screenshots/minimax-usage/menu-card-inline-dashboard.png b/docs/screenshots/minimax-usage/menu-card-inline-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..f221ade8765287f4696ebdaad48e53e685f53aab GIT binary patch literal 67082 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

0Q^w)Q+?Iz=VGFrSPyC(XO7h_3enR0^Sj^`$=y5 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 tIwm<;8YJJ+;j?}5CAd$@DAF@_~dZmR5@pg#2c z^$Q9#cVU)2?=Y*T4v8HB>CT5@JgO-NCSVG>`(;n=A(O6W*ZX(mmVg~I(f&Ay?LItwtSr4Xgj z=q5!tGomFW4^c-iaD@IF_s^f?#D5m$Mh&n~5tx;Fn{W7BQ4o9PEX<`?#VxonX;UXV zWt%YZlT38$Z@ND0&&XA;&ZAX5MT`&s!1`{9b8#A8DYg~0jMhY%0yXcZ=!fH! z0mKb8VhWj?F>RzO_&j&b7LWW!;p8_4%ctLelE)!3nS1sgZpEX-!t| z56Pm9C%}q2WgA^PwWQE>UF5QhXpsoeMZqfdgIe#6sYS=w21`bA1)}YEOWw_vxz@DJ z^QE}^QzNy|sgFC0>{5-TiI3AdMEjm&?s;i8OAM+zN;U=~k{DR-e>H5(^m~}0I)WP8 znv^p;H&^Go$Iuoz;GWzB z!{;r7!87N1YH#-|TG!NvUg;W2DyBe{V_i7Wd1FNLjc$ks6Vr=e{)vhkQ1#XE+c%l? zVNl_LrbcsWeaTz0W0p(uv$T?tMEXccG&B)X6g7P<!kp2?Wy%n8VLXF}a2c`JJ(V_siUSf|zDrlo%{M5@nDdaK_vbH9{*5x2iM zLPnQ>dwcyNfAg&Lv!2PPH~~k}8;E-ytLg#n5~-t;n9f_p<~Vja-ZG6;Zb{eRdz$=7 z`WpVP!WUZ&+cuO^5XsuF_sqEYcRSNwKcEJx>Ndvn4U3T|zgV|v?Jf}BO?Td5{dzqmGZu%vQeljw*uGeG@RSdTVDvW2 zg7B8b%*&<+6+%XJ-LNs&x($ynH{ z#jFY*qju3Gp#R#N<V2m`ot>7|)ux--6AfPJ3sOD3nNU zJ2!R0X_ZJ~al99wuh02V=FoE{T@u_misYE+#s}JGBg@VVJ37c74RfFs+odUq8`;0= z#+e4axA$FC);AZ*IaPUMNpc+CK)=@pZ&E!@*i<9yF#YzzE}a9?e8bmN*c{kz_NLj* zU?NO%Cf_pH z@+2-Yx)-18b@T(i;X$G71l<_U43a{JAR$Mv8&oXWt==Kf#?nK}%x2Na5d0XDor5F4 z<;SdRcE8%q?L};X>PM_`yNx}pD$4Zem*Uy|VNv74r7Y#1OM?%#^S_8B^uVLs9*{($ zEbHQ=VIoU~k4c&rp4p^w>N!xwrEc;RPF)`>eNOgnFi9F&^xFYYdo_q9l^DLq*kQ~N zqM>@-TE0_uWAOqZI%?JUw!Xr{-F9giq*0dcZDaqjnqBH*=%Sj*oUE^k=Z42ohNGiT z{DDbxZ*%ip>Juf6mIpu}j8tDOKywO$7ICuemS{dZb1fot6^#ywv{s473s+XJUE!5j zR~ayD@X(Fd)iSyGvDDS@CN1FlT}Ypqy4@6+oq3>%39qHGjWb)Bsyc*uV$yHYE}J$| zi2CPgzxt7+54!IwaL4od71%K@(HFO7&|CQ`GE7}6P}(Axz6ljHp_ZomDq3m_xf@O5 zvN!|0VRTa>g|r?pm1>&4J25i8jwcfj2;Yh&#$58scaLBT)oIECiFea|lhpMUs&Q?y z_i>`EI)n&|BsDTj}=djc(D*>Y{fPg$S7WM>>>Wo`f z7IbOqfoYSnZ*Zh_#1MD!c1UO?zBPgUp~){m*e?L#XRt0^ZgG{C#R3O&Wt(H@4&USG@!n( zs?RqF9*dwmQfhUVm^B&gzg>lHGXDYq8z7_6b@DBBG}v(sO$({bjZ>uOp{jt&JPk~@ z!eoY+XmsRfy3l{6X8-S=<&wd+ChAU9Rtn!H1F>(oix=_krpIPZWuGVI3*4fI7v;nTL875e&c^8{3R?md-D~^ zzw6G1zI^4UL9F}*dBdjq zpX(lSkojjFFz!b#7-QG#^IVA{lxGNP zho7L*=PuCrl1DbY0U388$%#Zqtd@L>LORg-BY)3e|8C*al6%*~Lwl&%iQQdG*1uay zq~(A4^ZIY|hjof_e*^eivg%}p{ofumhl^P9zx?0mYVC)i(g%OxqxmDzjxq%t2>y(} z@*gQDiL502zwES5z4hP6;fv1yTbY_E38&T}P`)4IPTy5Ir8Z7H;wnT+_&?0x*r2Yb zhPUDSXNBYO4A$rcEpCfm9PSb+!S#^7u#}`!W?9<&Fk|eL-`UnL@?^NHsNlQ9w$w_x zhKHw>^jInf`b9$opaQ)j2Eun`cr0`-jRH7>wuCV!D>LMKx$p8P6)ht9--avMC&iRVR^mziI2J` zZVD**Sd+eNI>VLSEv@)O%@DJNS!&?rY|=&<%sUHKwIHxT%iZ&-i7Jd@9>p}Nm=0!v zdehI2u1o{SZ(LDteScaMhym`2nd81JFwP%U6!ez5GO(OAjMdIKs7DeqhGVOTu?@KL z)+EHZfS!FSXtoYeMDMg8stkZ~<_{+-Z9Hp&yX222YGRUva|&H%-p?S0+m}G6coZo} zV#-rE!k8&aP?b)>sOKP$XVK7===*JDqCskdoylGpU_=W^6i2!j#?xr_`uu;Zc-rzl znw^oFFO!YU^WRxLE9J~p1N7W*FsW{C9X3j`E%%{$&Bm)_RCOU0FZFS)UFGBC(>8JV`WxHe=t1$gGFo}=8>(6~FQgGy!bZk2CDOB;MS)DpcrvefJU z_}l5P(uuj7R%i+?i>_B$79TK}?X2i8g1s}ux^<0G$-3zk@3D)Mb97Z*6+P=1fO~s4 za7DqdP#A==!p^1}(jQ^d6edUwb4+l?m#X+aVS>iKDhM&s725iaJmfXY)2#E1w`ghz zNT#$|xlb>!tzch28n-o~`#_alRlJL7%7Yj8!w&&B)^8-g0N)G)h9CJCZ|&Z`^*~mW z({RaLQ(kHgE9RAOIEkoQ@QU6RzXt)E_ZNrizU;H3`=;o(v4%MzG&WWA8t$86bsNtA z-DLY3#z_`QXJuhs@yZ(8WoQ}0UU=|kI&dQ$<0Z|}887Jym0icaea;udRq%wyTRe@P z_=S4i2qvb=%CG9Wx+wlvRUxjMX|0jnD(UGBM)(D$X01Lp4o$4PaH#1}Liow<{(vOS z%@C_kW@Cp%&kqZiCXl_U7$R*+x^@%*q^?;z-gTmIV#Zdm0qRE*6^C!pKNH^-_t@e4 zj#HKS>gfWLv9K^K=Q$Vw7nUVh{17raDUya5v4p%Rz*Zxyk8sA6NemWq+W<*dydu>J zZ2NfCYr=t8+2lUe5`i8~fK@!W+0oTMzAwI)pd#6ky?GGC=5`U8iK>3cxxB*e8WEJ| zY-!Lcgh}O&c*84#F`JGRFsvgtM6Me`fzsGU7B>5B#$z)GI^SgL)p}(XLHbqXEj8^Y zc`Ee`n_x(8nKt%v<;gH>j^A*zC@sT6y~vv;$8nqT9oY_GKx10V{;Zr?YMQy6+vwX{ z*F9^ve&UXY4jO8l+`MnrhH}8GH<=^7r44hj(+@mrlL^@nvFv*%wb$q{_IseCELD1{ zmh7<6>18ppe)@qpB2b5=B^Lg2(TTr+K?`?lDlr$+*O?ZkcncEffisq48|??gV&v<^ zs&SjhGDPHw_w7!NE*w$ZNF_kdZsH1IW*;#~P#O2)rYPOpk7aghSmEV$ph~fow>Dh^ z;c+PB=)>NSs|;^r%Ej5iFf+yIdvYlHXuJl=8bng-*77uKl19v$d%!yo8e~nBGT8(H z)g5hLJXQJ~;e996|`&RJTzF&Byb>aoq1+$6Sm%6N*vw2hvwG z^!=cz5<0G3Dmyt@(O*84OnPLYuA+(3rkxy|h|nE9lXZBGA#9uWN^hw7;8;UA3o@yv z`GRXWX$I<656fP!&B;>m>q48-aedVz@YQq6t4V&jAueT;S{+vNHL4jDbzs-hIfo5W zt~W7E616bKOxQR0E^zi{zV#QtP$=~POk>|%a^CDA7U&bb%^Ak85FuUU0`v3gNx&ys zU9;Z@&ZVyNv+p2 z?<~MFKLDSd`uorLIv@0p_C>T!t5L6}1-$5pq7GF{N>Is1T!zaqYauHnKhi1SnUkdA zS}u^f_~NUcQo3>i`UuxeEv6U4NhPoB zj5<{cgkz>?=fsIU%uWDI=5{dlu0B+mHkI8pFZ5nVlr%VfJkPYiODPuv2T$XOpVun+ zS+;P}Vwof@6f^@tNaow?wuz|bC9wA$Z}s?lOB&-KrIUR=2%-F2XJBA%942Ys?+RBrVP=p+u4Pfmt0Bp z2NQDhlaMk2{fG5n#q4T2y0fABno5r=^c}l>o|_B(h?jFPr;}}QotS2hd`R=w!g-JT zH7RMIPtLMV12Y!7stPL@%~|Giui+Q&hs$O zsgk)dY5gdR9xc~_Q6|VBvddBtZl1UaoNqqrYv(jKz1`wZ(TCF>ZJlDVW@viEe60cU zRIei^?r!Fvt9)H5xYH;oUo#TA@KuXkse$?hh|}_19N_Xj`bq@6hyA9$qG!5WT`SyB zu#fsLzzWxB!#9n6k5TEQ6mx|aCz>iNX~R3CBiGtqx`L`<#%@PNF12B#>^P1*=sasS z7(&rQiSXb|SMq!l5cNJH$DThinm;pWQDlmlp7=YCMm_mH7EA5%wJ!Kt+p-&iUD;;8 zBs<04r5h4$f|4q9PyOzq#eZx!%KX0e$ap(qpI`V71jjY##QMAxd3vcAm`jFe$c@*S z$+D5JVaG)Sc8Hyq-Pg0od91mhOeyCsL1X+MyWcb5Ex`l#&KI}#pEIt+pYsLQKeU(r zSKDGy!T9q#Em11GNXn7&#O~4dZ)*p}d4<24F+H3}p@bPs?R}5a8hZq0YyAs>oIRct zwZXGLPg-Ppe_3o2K zT|COM27k5ZYSL+~Xm_aoqt&{LFu!I+NyEr@!Tc_^$KLa8MYMySMeIA>7 nH&~8BpaJnSZvAw#BW;Y8N|G@Hl#weWuc*>wl) literal 0 HcmV?d00001 diff --git a/docs/screenshots/minimax-usage/menu-card-usage-notes.png b/docs/screenshots/minimax-usage/menu-card-usage-notes.png new file mode 100644 index 0000000000000000000000000000000000000000..35ee67e05c17ce9ef0b7c5f5a1a5109156a12154 GIT binary patch literal 67702 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 P|6f?~@6t~}f2aQkaGs&m literal 0 HcmV?d00001 diff --git a/docs/screenshots/minimax-usage/subscription-utilization.png b/docs/screenshots/minimax-usage/subscription-utilization.png new file mode 100644 index 0000000000000000000000000000000000000000..04339a01f3b546d1f6915e8c906fc62d3a10b49d GIT binary patch literal 42609 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 QqtKO+U6?Pc(fT#>f3tIW?EnA( literal 0 HcmV?d00001 diff --git a/docs/screenshots/minimax-usage/token-usage-details.png b/docs/screenshots/minimax-usage/token-usage-details.png new file mode 100644 index 0000000000000000000000000000000000000000..d862f5c984e33b148371f3c7bbe1c1b7f29c1ae9 GIT binary patch literal 92733 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

05GUnkL@78t9wbtc{1>5gBw8mil%L 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 literal 0 HcmV?d00001 From 2baec7bf76754689430bf253ec907450d8705e55 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Fri, 3 Jul 2026 00:08:14 +0800 Subject: [PATCH 14/53] Fix MiniMax reset timing, pricing, and CI compile failures. Honor MINIMAX_HOST for usage summaries, bill cache reads separately from input tokens, keep optional summary enrichment from failing API quota fetches, and split reset-time test intervals so macOS CI can type-check the suite. Co-authored-by: Cursor --- .../Providers/MiniMax/MiniMaxAPIRegion.swift | 6 +- .../MiniMax/MiniMaxProviderDescriptor.swift | 2 +- .../MiniMaxUsageFetcher+UsageSummary.swift | 6 +- .../MiniMax/MiniMaxUsageFetcher.swift | 38 ++++-------- .../MiniMaxUsageParser+ResetTime.swift | 61 +++++++++++++++++++ .../MiniMax/MiniMaxUsagePricing.swift | 7 +-- .../MiniMax/MiniMaxUsageSummary.swift | 27 +++++++- .../CodexBarTests/MiniMaxProviderTests.swift | 35 ++++++++++- .../MiniMaxResetDescriptionTests.swift | 36 ++++++++++- .../MiniMaxTokenPlanCreditTests.swift | 39 ++++++++++++ .../MiniMaxUsagePricingTests.swift | 2 +- 11 files changed, 216 insertions(+), 43 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift index 32f5c1dab6..e86d220bdd 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift @@ -11,6 +11,7 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable { 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 { @@ -82,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/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 17fad92205..80d661b288 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -21,7 +21,7 @@ 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, diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift index a73bcddc5c..bbe7aa2dd8 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift @@ -24,9 +24,9 @@ extension MiniMaxUsageFetcher { } catch let error as URLError where error.code == .cancelled { throw CancellationError() } catch let error as MiniMaxUsageError { - if case .invalidCredentials = error, context.authorizationToken != nil { - throw error - } + Self.log.debug("MiniMax usage summary unavailable: \(error.localizedDescription)") + return snapshot + } catch let error as ProviderEndpointOverrideError { Self.log.debug("MiniMax usage summary unavailable: \(error.localizedDescription)") return snapshot } catch { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index a07a93c454..ef65c44d07 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -975,14 +975,16 @@ enum MiniMaxUsageParser { remaining: remaining, remainingPercent: first?.currentIntervalRemainingPercent) - let windowMinutes = self.windowMinutes( - start: self.dateFromEpoch(first?.startTime), - end: self.dateFromEpoch(first?.endTime)) + let startDate = self.dateFromEpoch(first?.startTime) + let endDate = self.dateFromEpoch(first?.endTime) + let windowMinutes = self.windowMinutes(start: startDate, end: endDate) + let intervalWindowType = self.parseWindowInfo(startTime: startDate, endTime: endDate, now: now).windowType let resetsAt = self.resetsAt( - end: self.dateFromEpoch(first?.endTime), + end: endDate, remains: first?.remainsTime, - now: now) + now: now, + windowType: intervalWindowType) let planName = self.parsePlanName(data: payload.data) @@ -1030,26 +1032,6 @@ enum MiniMaxUsageParser { return nil } - private static func windowMinutes(start: Date?, end: Date?) -> Int? { - guard let start, let end else { return nil } - let minutes = Int(end.timeIntervalSince(start) / 60) - return minutes > 0 ? minutes : nil - } - - private static func resetsAt(end: Date?, remains: Int?, now: Date) -> Date? { - if let remains, remains > 0 { - let seconds: TimeInterval = remains > 1_000_000 ? TimeInterval(remains) / 1000 : TimeInterval(remains) - let resetDate = now.addingTimeInterval(seconds) - if resetDate > now { - return resetDate - } - } - if let end, end > now { - return end - } - return nil - } - private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { [ data.currentSubscribeTitle, @@ -1575,7 +1557,11 @@ enum MiniMaxUsageParser { } let isUnlimited = self.isUnlimitedQuotaWindow(input, windowType: windowType) - let resetsAt = isUnlimited ? nil : self.resetsAt(end: endTime, remains: input.remainsTime, now: now) + let resetsAt = isUnlimited ? nil : self.resetsAt( + end: endTime, + remains: input.remainsTime, + now: now, + windowType: windowType) let resetDescription = if isUnlimited { "Unlimited" } else { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift new file mode 100644 index 0000000000..093ed77f9d --- /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 normalized == "5 hours" || normalized == "5h" || normalized.contains("hour") { + return 6 * 3600 + } + if let hours = Int(normalized.split(separator: " ").first ?? ""), normalized.contains("hour") { + return TimeInterval(hours + 1) * 3600 + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift index 04b08df563..a0a2dc55cf 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift @@ -129,8 +129,7 @@ enum MiniMaxUsagePricing { outputToken: Int) -> Double { let input = max(0, inputToken) - let cached = min(max(0, cacheReadToken), input) - let nonCached = max(0, input - cached) + let cacheRead = max(0, cacheReadToken) let cacheCreate = max(0, cacheCreateToken) let output = max(0, outputToken) @@ -145,8 +144,8 @@ enum MiniMaxUsagePricing { ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken : pricing.cacheReadInputCostPerToken - return (Double(nonCached) * inputRate) - + (Double(cached) * cacheReadRate) + return (Double(input) * inputRate) + + (Double(cacheRead) * cacheReadRate) + (Double(cacheCreate) * pricing.cacheCreationInputCostPerToken) + (Double(output) * outputRate) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift index e125567140..0dc9b708b5 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift @@ -208,6 +208,28 @@ public struct MiniMaxUsageSummaryModel: Sendable, Equatable { } enum MiniMaxUsageSummaryFetcher { + private static let usageSummaryPath = "backend/account/token_plan/usage_summary" + + static func resolveUsageSummaryURL( + region: MiniMaxAPIRegion, + environment: [String: String]) throws -> URL? + { + if let rejectedKey = MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: environment) { + throw ProviderEndpointOverrideError.minimax(rejectedKey) + } + if let host = MiniMaxSettingsReader.hostOverride(environment: environment) { + let lowered = host.lowercased() + if !lowered.contains("minimax.io"), !lowered.contains("minimaxi.com"), + let hostURL = MiniMaxUsageFetcher.url(from: host, path: Self.usageSummaryPath) + { + return hostURL + } + } + return MiniMaxTokenPlanCreditFetcher + .effectiveRegion(for: region, environment: environment) + .tokenPlanUsageSummaryURL + } + static func fetch( cookieHeader: String, groupID: String?, @@ -215,8 +237,11 @@ enum MiniMaxUsageSummaryFetcher { environment: [String: String], transport: any ProviderHTTPTransport) async throws -> MiniMaxUsageSummary { + guard let url = try resolveUsageSummaryURL(region: region, environment: environment) else { + throw MiniMaxUsageError.apiError("MiniMax usage summary endpoint unavailable for configured host.") + } let effectiveRegion = MiniMaxTokenPlanCreditFetcher.effectiveRegion(for: region, environment: environment) - var request = URLRequest(url: effectiveRegion.tokenPlanUsageSummaryURL) + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") if let groupID = groupID?.trimmingCharacters(in: .whitespacesAndNewlines), !groupID.isEmpty { diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 514c7bf412..92c546bdba 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -668,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) @@ -1240,6 +1240,15 @@ 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 @@ -1284,6 +1293,30 @@ struct MiniMaxAPIRegionTests { #expect(remains.host == "api.minimaxi.com") } + @Test + func `host override routes usage summary through custom proxy host`() throws { + let env = [MiniMaxSettingsReader.hostKey: "proxy.example.test:8443"] + let resolved = try MiniMaxUsageSummaryFetcher.resolveUsageSummaryURL(region: .global, environment: env) + #expect(resolved?.absoluteString == "https://proxy.example.test:8443/backend/account/token_plan/usage_summary") + } + + @Test + func `host override maps minimax hosts to www usage summary endpoint`() throws { + let chinaEnv = [MiniMaxSettingsReader.hostKey: "platform.minimaxi.com"] + let chinaResolved = try MiniMaxUsageSummaryFetcher.resolveUsageSummaryURL( + region: .global, + environment: chinaEnv) + #expect(chinaResolved?.host == "www.minimaxi.com") + #expect(chinaResolved?.path == "/backend/account/token_plan/usage_summary") + + let globalEnv = [MiniMaxSettingsReader.hostKey: "platform.minimax.io"] + let globalResolved = try MiniMaxUsageSummaryFetcher.resolveUsageSummaryURL( + region: .chinaMainland, + environment: globalEnv) + #expect(globalResolved?.host == "www.minimax.io") + #expect(globalResolved?.path == "/backend/account/token_plan/usage_summary") + } + @Test func `billing history url uses account amount endpoint`() { let url = MiniMaxUsageFetcher.resolveBillingHistoryURL(region: .chinaMainland, environment: [:], page: 2) diff --git a/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift index 5d0221c67e..25b36c1ecc 100644 --- a/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift +++ b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift @@ -3,10 +3,13 @@ import Testing @testable import CodexBarCore struct MiniMaxResetDescriptionTests { + private static let twoHoursThirtyFourMinutes: TimeInterval = (2 * 60 * 60) + (34 * 60) + private static let threeDaysFiveHoursTwelveMinutes: TimeInterval = (3 * 24 * 60 * 60) + (5 * 60 * 60) + (12 * 60) + @Test func `countdown phrase includes minutes for multi hour windows`() { let now = Date(timeIntervalSince1970: 1_700_000_000) - let resetsAt = now.addingTimeInterval((2 * 60 * 60) + (34 * 60)) + let resetsAt = now.addingTimeInterval(Self.twoHoursThirtyFourMinutes) #expect(MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: now) == "2 hours 34 minutes") #expect( @@ -17,7 +20,7 @@ struct MiniMaxResetDescriptionTests { @Test func `countdown phrase includes days hours and minutes for weekly windows`() { let now = Date(timeIntervalSince1970: 1_700_000_000) - let resetsAt = now.addingTimeInterval((3 * 24 * 60 * 60) + (5 * 60 * 60) + (12 * 60)) + let resetsAt = now.addingTimeInterval(Self.threeDaysFiveHoursTwelveMinutes) #expect( MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: now) == "3 days 5 hours 12 minutes") @@ -83,4 +86,33 @@ struct MiniMaxResetDescriptionTests { #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 + (5 * 60 * 60 * 1000) + 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 service = try #require(snapshot.services?.first(where: { $0.windowType == "5 hours" })) + + #expect(service.resetsAt == Date(timeIntervalSince1970: TimeInterval(end) / 1000)) + #expect(service.resetDescription == "Resets in 5 hours") + } } diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index a94a733913..dc4aa71c6c 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -320,6 +320,45 @@ struct MiniMaxTokenPlanCreditTests { #expect(enriched.usageSummary?.last7DaysTokens == 1_036_079_880) } + @Test + func `api usage summary auth failure does not discard valid api quota`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.host == "api.minimax.io" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + if url.host == "api.minimaxi.com", url.path.contains("remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + if url.path == "/backend/account/token_plan/usage_summary" { + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + + let apiResult = try await MiniMaxUsageFetcher.fetchAPITokenUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: transport) + let enriched = try await MiniMaxUsageFetcher.attachingUsageSummaryIfAvailable( + to: apiResult.snapshot, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=abc; bearer=expired", + authorizationToken: "sk-cp-test", + region: apiResult.resolvedRegion, + environment: [:], + transport: transport), + groupID: nil) + + #expect(apiResult.resolvedRegion == .chinaMainland) + #expect(apiResult.snapshot.services?.isEmpty == false) + #expect(enriched.usageSummary == nil) + #expect(enriched.services?.isEmpty == false) + } + private static func fixtureURL(named name: String) throws -> URL { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift index 1b6dfd0fb9..82398f942b 100644 --- a/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift +++ b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift @@ -13,7 +13,7 @@ struct MiniMaxUsagePricingTests { outputToken: 0) #expect(cost != nil) - #expect(abs((cost ?? 0) - 0.18) < 0.0001) + #expect(abs((cost ?? 0) - 0.33) < 0.0001) } @Test From e5c0443bb2370c089a181f0948a71c84dc723a86 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Fri, 3 Jul 2026 00:25:43 +0800 Subject: [PATCH 15/53] Document MiniMax usage dashboard scope in changelog and docs. Align unreleased notes and minimax.md with the expanded PR: usage summary UI, console/usage dashboard link, host routing, reset-time heuristics, and cache-read pricing. Co-authored-by: Cursor --- CHANGELOG.md | 5 +++++ docs/minimax.md | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3877c41aaa..9a04f85c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Qoder: add big-model credit usage from qoder.com and qoder.com.cn browser sessions or manual cookies. Thanks @Yuxin-Qiao! - Quota warnings: add an optional centered on-screen text alert that stays click-through and does not steal focus. Thanks @SAASEmpiree! - MiniMax: fetch Token Plan recharge-credit balance from the console `token_plan_credit` endpoint when a browser session cookie is available. Thanks @Yuxin-Qiao! +- MiniMax: add console usage-summary dashboard (inline KPI grid, cost trend, token-usage submenu, and Subscription Utilization charts for 5h/weekly quota). Thanks @Yuxin-Qiao! ### Fixed - Sakana AI: parse server-rendered quota reset timestamps as UTC instead of device-local time (#1826). Thanks @ss251! @@ -20,6 +21,10 @@ - Linux CLI: keep Claude OAuth usage subprocess-free, skip version probes, and let Auto bypass unsupported web sources. Thanks @derekszen! - Usage display: make Usage widgets follow the used-versus-remaining preference already shared by menus and Overview rows (#1738). Thanks @OlegLustenko and @FrancoLan! - MiniMax: route Token Plan credit lookups to the matching `www.*` host when `MINIMAX_HOST` selects a China or global platform domain. +- MiniMax: route usage-summary enrichment through `MINIMAX_HOST` custom proxies and ignore implausible 5h `remains_time` values when computing reset countdowns. +- MiniMax: open the menu Usage Dashboard action at `platform.*.com/console/usage` instead of the Coding Plan page. +- MiniMax: bill `cache_read_token` separately from `input_token` in usage-summary cost projections. +- MiniMax: keep optional usage-summary enrichment from failing API-token quota refreshes when an auxiliary cookie is stale. - OpenCode Go: keep rolling usage available when the dashboard omits the optional weekly window. Thanks @mohkg1017! - Menu bar: make Show most-used provider rank only providers selected for Overview. Thanks @dstier-git! - Codex: show expiring reset-credit availability even when optional credits and extra usage are hidden, while preserving CLI `--no-credits`. Thanks @simon-ami! diff --git a/docs/minimax.md b/docs/minimax.md index 4d52362455..23bb818de1 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -40,7 +40,7 @@ falls back across the provider's supported web requests when needed. - `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 host (`minimaxi.com` → `www.minimaxi.com`, `minimax.io` → `www.minimax.io`). +- `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`. @@ -53,7 +53,11 @@ falls back across the provider's supported web requests when needed. ## 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 the browser session cookie. API-token remains responses do not include this balance. +- Console usage summary (`GET .../backend/account/token_plan/usage_summary`) enriches web-session refreshes with 30-day token/cost trends, cache-hit stats, and per-model breakdowns when a browser cookie is available. API-token refreshes only attempt summary enrichment when an explicit manual or environment cookie is configured. - API-token refreshes only merge recharge credits when an explicit manual or environment cookie is configured; cached browser cookies are not attached automatically to avoid cross-account balance bleed. +- 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. @@ -65,6 +69,8 @@ quota card and omits the chart instead of treating the whole provider as failed. ## Key files - `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` From c446b1a552df2854b8861944ede0af0715c5eff3 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Fri, 3 Jul 2026 00:51:22 +0800 Subject: [PATCH 16/53] Fix MiniMax reset description test compile timeout in CI. Use precomputed interval literals in a file-scoped enum so release builds avoid Swift type-checker timeouts on arithmetic expressions. Co-authored-by: Cursor --- .../MiniMaxResetDescriptionTests.swift | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift index 25b36c1ecc..41f8d39296 100644 --- a/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift +++ b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift @@ -2,14 +2,19 @@ import Foundation import Testing @testable import CodexBarCore -struct MiniMaxResetDescriptionTests { - private static let twoHoursThirtyFourMinutes: TimeInterval = (2 * 60 * 60) + (34 * 60) - private static let threeDaysFiveHoursTwelveMinutes: TimeInterval = (3 * 24 * 60 * 60) + (5 * 60 * 60) + (12 * 60) +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(Self.twoHoursThirtyFourMinutes) + let resetsAt = now.addingTimeInterval(MiniMaxResetDescriptionTestIntervals.twoHoursThirtyFourMinutes) #expect(MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: now) == "2 hours 34 minutes") #expect( @@ -20,7 +25,7 @@ struct MiniMaxResetDescriptionTests { @Test func `countdown phrase includes days hours and minutes for weekly windows`() { let now = Date(timeIntervalSince1970: 1_700_000_000) - let resetsAt = now.addingTimeInterval(Self.threeDaysFiveHoursTwelveMinutes) + let resetsAt = now.addingTimeInterval(MiniMaxResetDescriptionTestIntervals.threeDaysFiveHoursTwelveMinutes) #expect( MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: now) == "3 days 5 hours 12 minutes") @@ -38,7 +43,7 @@ struct MiniMaxResetDescriptionTests { 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 + ((2 * 60 * 60) + (15 * 60)) * 1000 + let end = start + MiniMaxResetDescriptionTestIntervals.twoHoursFifteenMinutesMillis let json = """ { "base_resp": { "status_code": 0 }, @@ -64,7 +69,7 @@ struct MiniMaxResetDescriptionTests { 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 + (2 * 60 * 60) * 1000 + let end = start + MiniMaxResetDescriptionTestIntervals.twoHoursMillis let json = """ { "base_resp": { "status_code": 0 }, @@ -91,7 +96,7 @@ struct MiniMaxResetDescriptionTests { 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 + (5 * 60 * 60 * 1000) + let end = start + MiniMaxResetDescriptionTestIntervals.fiveHoursMillis let json = """ { "base_resp": { "status_code": 0 }, @@ -110,7 +115,8 @@ struct MiniMaxResetDescriptionTests { """ let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) - let service = try #require(snapshot.services?.first(where: { $0.windowType == "5 hours" })) + 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") From 8c248c1478b33c2a5145ba16867ac7dccee4639f Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Fri, 3 Jul 2026 02:53:05 +0800 Subject: [PATCH 17/53] Improve MiniMax web enrichment for credits and usage summary. Add MiniMax Agent desktop cookie import, browser-cookie gate fixes for user-initiated refresh, and a shared enrichment resolver so API-token users get credits and usage summary without MiniMax Code. Fix timezone-flaky menu card expiry test for CI. Co-authored-by: Cursor --- .../MiniMaxUsageSummaryChartMenuView.swift | 11 +- .../StatusItemController+HostedSubmenus.swift | 7 +- ...emController+MiniMaxUsageSummaryMenu.swift | 6 +- .../BrowserCookieAccessGate.swift | 8 + .../MiniMax/MiniMaxCookieImporter.swift | 2 + .../MiniMaxDesktopCookieImporter.swift | 142 ++++++++++++++++++ .../MiniMax/MiniMaxProviderDescriptor.swift | 50 ++++-- .../MiniMax/MiniMaxSubscriptionMetadata.swift | 2 +- .../MiniMaxTokenPlanCreditFetcher.swift | 8 + .../MiniMax/MiniMaxUsagePricing.swift | 51 +++++-- .../MiniMaxUsageSnapshot+Metadata.swift | 12 +- .../MiniMaxUsageSummary+CostProjection.swift | 20 +-- .../MiniMax/MiniMaxUsageSummary.swift | 14 +- .../MiniMaxWebEnrichmentResolver.swift | 125 +++++++++++++++ .../CodexBarTests/BrowserDetectionTests.swift | 18 ++- Tests/CodexBarTests/MenuCardModelTests.swift | 8 +- .../MiniMaxDesktopCookieImporterTests.swift | 55 +++++++ .../MiniMaxMenuCardBillingTests.swift | 2 +- .../CodexBarTests/MiniMaxProviderTests.swift | 29 ++-- .../MiniMaxTokenPlanCreditTests.swift | 52 +++++-- .../MiniMaxUsagePricingTests.swift | 35 +++++ ...iniMaxUsageSummaryChartMenuViewTests.swift | 36 +++++ .../MiniMaxUsageSummaryTests.swift | 4 +- .../MiniMaxWebEnrichmentResolverTests.swift | 80 ++++++++++ docs/minimax.md | 27 +++- 25 files changed, 705 insertions(+), 99 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift create mode 100644 Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift diff --git a/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift b/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift index 249e1c3708..aded0f1ced 100644 --- a/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift +++ b/Sources/CodexBar/MiniMaxUsageSummaryChartMenuView.swift @@ -523,7 +523,8 @@ struct MiniMaxUsageSummaryChartMenuView: View { usage: MiniMaxUsageSummary) -> [DetailRow] { let breakdownByModel = Dictionary( - uniqueKeysWithValues: usage.projectedModelBreakdowns(for: point.day).map { ($0.modelName, $0) }) + usage.projectedModelBreakdowns(for: point.day).map { ($0.modelName, $0) }, + uniquingKeysWith: { first, _ in first }) if !point.day.models.isEmpty { return point.day.models .sorted { lhs, rhs in @@ -540,7 +541,7 @@ struct MiniMaxUsageSummaryChartMenuView: View { totalTokens: $0.totalTokens) } ?? Self.modelTokenSubtitle(model) return DetailRow( - id: model.model, + id: "\(model.model)#\(index)", title: Self.shortModelName(model.model), subtitle: subtitle, modeSubtitle: @@ -661,6 +662,12 @@ extension MiniMaxUsageSummaryChartMenuView { return Self.dayDetailPrimary(dayLabel: dayLabel, point: point, usage: usage) } + static func _detailRowCountForTesting(usage: MiniMaxUsageSummary, dateKey: String) -> Int { + let view = Self(usage: usage, width: 320) + let model = Self.makeModel(usage: usage, windowDays: 30) + return view.detailContent(selectedDateKey: dateKey, model: model).rows.count + } + static func _detailViewportHeightForTesting(modeSubtitlePresence: [Bool]) -> CGFloat { let rows = modeSubtitlePresence.enumerated().map { index, hasModeSubtitle in DetailRow( diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 99f2fc67a9..8565a921c4 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -277,9 +277,14 @@ extension StatusItemController { else { return "none" } - return usage.days.suffix(30).map { + let days = usage.days.suffix(30).map { "\($0.date):\($0.totalToken):\($0.cacheHitPercent ?? -1):\($0.models.count)" }.joined(separator: "|") + return [ + usage.lastUpdateTime ?? "", + usage.dailyTokenUsage.suffix(30).map(String.init).joined(separator: ","), + days, + ].joined(separator: "|") } private func costHistoryRenderSignature(for provider: UsageProvider) -> String { diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift index fc9603870f..e508256db8 100644 --- a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummaryMenu.swift @@ -14,7 +14,8 @@ extension StatusItemController { } func makeMiniMaxUsageSummarySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { - guard provider == .minimax, + guard self.settings.showOptionalCreditsAndExtraUsage, + provider == .minimax, let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, usage.hasDisplayableData else { @@ -45,7 +46,8 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { - guard provider == .minimax, + guard self.settings.showOptionalCreditsAndExtraUsage, + provider == .minimax, let usage = self.store.snapshot(for: provider)?.minimaxUsage?.usageSummary, usage.hasDisplayableData else { diff --git a/Sources/CodexBarCore/BrowserCookieAccessGate.swift b/Sources/CodexBarCore/BrowserCookieAccessGate.swift index 0265079706..ea80c6b6dd 100644 --- a/Sources/CodexBarCore/BrowserCookieAccessGate.swift +++ b/Sources/CodexBarCore/BrowserCookieAccessGate.swift @@ -47,6 +47,14 @@ public enum BrowserCookieAccessGate { public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { guard browser.usesKeychainForCookieDecryption else { return true } guard !KeychainAccessGate.isDisabled else { return false } + + if ProviderInteractionContext.current == .userInitiated { + self.log.debug( + "Cookie access allowed for user-initiated refresh", + metadata: ["browser": browser.displayName]) + return true + } + let shouldCheckKeychain = self.lock.withLock { state in self.loadIfNeeded(&state) if let blockedUntil = state.deniedUntilByBrowser[browser.rawValue] { diff --git a/Sources/CodexBarCore/Providers/MiniMax/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..52397a40bb --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift @@ -0,0 +1,142 @@ +import Foundation +#if os(macOS) +import SQLite3 + +enum MiniMaxDesktopCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.minimaxCookie) + private static let sourceLabel = "MiniMax Agent" + private static let domainPatterns = ["minimaxi.com", "minimax.io"] + + static func cookiesDatabaseURL(fileManager: FileManager = .default) -> URL { + fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/MiniMax/Cookies") + } + + static func importSession( + databaseURL: URL? = nil, + fileManager: FileManager = .default) -> 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) + 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) 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 + 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), + let value = self.columnText(stmt, index: 2), + !value.isEmpty, + self.matchesMiniMaxDomain(domain) + 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() + return self.domainPatterns.contains { pattern in + normalized == pattern || normalized.hasSuffix(".\(pattern)") + } + } + + 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) + } +} + +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 80d661b288..6a3670bdc6 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -90,9 +90,12 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { apiToken: apiToken, region: preferredRegion) var usage = apiResult.snapshot - if let cookieOverride = Self.resolveExplicitCookieOverride(context: context), - let cookie = MiniMaxCookieHeader.normalized(from: cookieOverride.cookieHeader) + for candidate in context.includeOptionalUsage + ? MiniMaxWebEnrichmentResolver.candidates(context: context) + : [] { + let cookieOverride = candidate.override + guard let cookie = MiniMaxCookieHeader.normalized(from: cookieOverride.cookieHeader) else { continue } let fetchContext = MiniMaxUsageFetcher.WebFetchContext( cookie: cookie, authorizationToken: cookieOverride.authorizationToken, @@ -107,24 +110,16 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { to: usage, context: fetchContext, groupID: cookieOverride.groupID) + if usage.usageSummary != nil || usage.pointsBalance != nil { + MiniMaxWebEnrichmentResolver.cacheValidated(candidate) + break + } } return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "api") } - private static func resolveExplicitCookieOverride(context: ProviderFetchContext) -> MiniMaxCookieOverride? { - 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 - } - return MiniMaxCookieHeader.override(from: raw) - } - func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { guard let minimaxError = error as? MiniMaxUsageError else { return false } switch minimaxError { @@ -148,6 +143,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 { @@ -185,6 +183,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 { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift index 332b515871..3e3510140a 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSubscriptionMetadata.swift @@ -277,7 +277,7 @@ extension MiniMaxUsageFetcher { context: WebFetchContext, groupID: String?) async throws -> MiniMaxUsageSnapshot { - guard snapshot.pointsBalance == nil, + guard snapshot.pointsBalance == nil || snapshot.pointsBalanceExpiresAt == nil, MiniMaxCookieHeader.normalized(from: context.cookie) != nil else { return snapshot diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift index 924995f345..b4e3a4302d 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift @@ -67,6 +67,14 @@ enum MiniMaxTokenPlanCreditFetcher { 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 } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift index a0a2dc55cf..47e2daad12 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift @@ -1,6 +1,13 @@ import Foundation enum MiniMaxUsagePricing { + private struct TokenCounts { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let output: Int + } + struct Pricing: Equatable { let inputCostPerToken: Double let outputCostPerToken: Double @@ -78,10 +85,30 @@ enum MiniMaxUsagePricing { guard let pricing = self.pricing(for: model, inputToken: inputToken) else { return nil } return self.costUSD( pricing: pricing, - inputToken: inputToken, - cacheReadToken: cacheReadToken, - cacheCreateToken: cacheCreateToken, - outputToken: outputToken) + tokens: TokenCounts( + input: inputToken, + cacheRead: cacheReadToken, + cacheCreate: cacheCreateToken, + output: outputToken), + applyLongContextThreshold: true) + } + + static func minimaxAggregateCostUSD( + model: String, + inputToken: Int, + cacheReadToken: Int, + cacheCreateToken: Int, + outputToken: Int) -> Double? + { + guard let pricing = self.pricing(for: model, inputToken: inputToken) else { return nil } + return self.costUSD( + pricing: pricing, + tokens: TokenCounts( + input: inputToken, + cacheRead: cacheReadToken, + cacheCreate: cacheCreateToken, + output: outputToken), + applyLongContextThreshold: false) } static func pricing(for model: String, inputToken: Int) -> Pricing? { @@ -123,17 +150,15 @@ enum MiniMaxUsagePricing { private static func costUSD( pricing: Pricing, - inputToken: Int, - cacheReadToken: Int, - cacheCreateToken: Int, - outputToken: Int) -> Double + tokens: TokenCounts, + applyLongContextThreshold: Bool) -> Double { - let input = max(0, inputToken) - let cacheRead = max(0, cacheReadToken) - let cacheCreate = max(0, cacheCreateToken) - let output = max(0, outputToken) + let input = max(0, tokens.input) + let cacheRead = max(0, tokens.cacheRead) + let cacheCreate = max(0, tokens.cacheCreate) + let output = max(0, tokens.output) - let usesLongContext = pricing.thresholdTokens.map { input > $0 } ?? false + let usesLongContext = applyLongContextThreshold && (pricing.thresholdTokens.map { input > $0 } ?? false) let inputRate = usesLongContext ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken : pricing.inputCostPerToken diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift index 7b37b6c1bf..98388c01f9 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -63,7 +63,13 @@ extension MiniMaxUsageSnapshot { } func withPointsBalanceIfMissing(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot { - guard self.pointsBalance == nil, let pointsBalance, pointsBalance >= 0 else { return self } + let resolvedBalance = self.pointsBalance ?? pointsBalance + let resolvedExpiry = self.pointsBalanceExpiresAt ?? expiresAt + guard let resolvedBalance, resolvedBalance >= 0, + resolvedBalance != self.pointsBalance || resolvedExpiry != self.pointsBalanceExpiresAt + else { + return self + } return MiniMaxUsageSnapshot( planName: self.planName, availablePrompts: self.availablePrompts, @@ -76,8 +82,8 @@ extension MiniMaxUsageSnapshot { services: self.services, billingSummary: self.billingSummary, usageSummary: self.usageSummary, - pointsBalance: pointsBalance, - pointsBalanceExpiresAt: expiresAt, + pointsBalance: resolvedBalance, + pointsBalanceExpiresAt: resolvedExpiry, subscriptionExpiresAt: self.subscriptionExpiresAt, subscriptionRenewsAt: self.subscriptionRenewsAt) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift index f502419c75..7e0ef98a60 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary+CostProjection.swift @@ -28,7 +28,7 @@ extension MiniMaxUsageSummary { { if !day.models.isEmpty { return day.models.compactMap { model in - guard let costUSD = MiniMaxUsagePricing.minimaxCostUSD( + guard let costUSD = MiniMaxUsagePricing.minimaxAggregateCostUSD( model: model.model, inputToken: model.inputToken, cacheReadToken: model.cacheReadToken, @@ -44,23 +44,7 @@ extension MiniMaxUsageSummary { } } - guard day.totalToken > 0, - let costUSD = MiniMaxUsagePricing.minimaxCostUSD( - model: "MiniMax-M2.7", - inputToken: day.totalInputToken, - cacheReadToken: day.totalCacheReadToken, - cacheCreateToken: day.totalCacheCreateToken, - outputToken: day.totalOutputToken) - else { - return [] - } - - return [ - CostUsageDailyReport.ModelBreakdown( - modelName: "Day totals", - costUSD: costUSD, - totalTokens: day.totalToken), - ] + return [] } public func toCostUsageTokenSnapshot( diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift index 0dc9b708b5..17fb4d0260 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift @@ -57,14 +57,15 @@ public struct MiniMaxUsageSummary: Sendable, Equatable { guard !self.dailyTokenUsage.isEmpty else { return [] } let values = Array(self.dailyTokenUsage.suffix(count)) let calendar = Calendar.current - let today = calendar.startOfDay(for: Date()) + let snapshotDate = Self.date(fromDateKey: self.snapshotDateKey) ?? Date() + let snapshotDay = calendar.startOfDay(for: snapshotDate) let formatter = DateFormatter() formatter.calendar = calendar formatter.timeZone = TimeZone.current formatter.dateFormat = "yyyy-MM-dd" return values.enumerated().map { index, tokens in let daysBack = values.count - 1 - index - let date = calendar.date(byAdding: .day, value: -daysBack, to: today) ?? today + let date = calendar.date(byAdding: .day, value: -daysBack, to: snapshotDay) ?? snapshotDay return MiniMaxUsageSummaryDay( date: formatter.string(from: date), totalInputToken: 0, @@ -139,6 +140,15 @@ public struct MiniMaxUsageSummary: Sendable, Equatable { return Self.todayDateKey(for: date) } + private static func date(fromDateKey key: String) -> Date? { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: key) + } + static func todayDateKey(for date: Date = Date()) -> String { let formatter = DateFormatter() formatter.calendar = Calendar.current diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift new file mode 100644 index 0000000000..9b46eebb43 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift @@ -0,0 +1,125 @@ +import Foundation + +enum MiniMaxWebEnrichmentResolver { + struct Candidate { + let override: MiniMaxCookieOverride + let sourceLabel: String + let shouldCache: Bool + } + + static func candidates(context: ProviderFetchContext) -> [Candidate] { + var candidates: [Candidate] = [] + if let settings = context.settings?.minimax, + let header = settings.manualCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), + !header.isEmpty, + let override = MiniMaxCookieHeader.override(from: header) + { + candidates.append(Candidate(override: override, sourceLabel: "settings", shouldCache: false)) + } + if let raw = ProviderTokenResolver.minimaxCookie(environment: context.env), + let override = MiniMaxCookieHeader.override(from: raw) + { + candidates.append(Candidate(override: override, sourceLabel: "environment", shouldCache: false)) + } + + #if os(macOS) + if let session = MiniMaxDesktopCookieImporter.importSession(), + let override = MiniMaxCookieHeader.override(from: session.cookieHeader) + { + candidates.append(Candidate( + override: self.enrichWithBrowserTokens( + override, + sourceLabel: session.sourceLabel, + browserDetection: context.browserDetection), + sourceLabel: session.sourceLabel, + shouldCache: true)) + } + 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)) + } + 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)) + } + } + #endif + return self.deduplicated(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 || context.includeOptionalUsage) + } + + 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/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index eeda8a0fe0..bd2a67f1bc 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -213,7 +213,7 @@ struct BrowserDetectionTests { var preflightCount = 0 KeychainAccessGate.withTaskOverrideForTesting(false) { - ProviderInteractionContext.$current.withValue(.userInitiated) { + ProviderInteractionContext.$current.withValue(.background) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 return .interactionRequired @@ -281,6 +281,22 @@ struct BrowserDetectionTests { #expect(preflightCount == 1) } + @Test + func `user initiated cookie import allows chromium keychain sources requiring interaction`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + .interactionRequired + } operation: { + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) + } + } + } + } + @Test func `dia requires profile data`() throws { let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 1e65bdc5b0..2d8545bf2f 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -647,6 +647,7 @@ struct MiniMaxMenuCardModelTests { @Test func `minimax token plan model shows weekly quota and points balance`() throws { let now = Date() + let pointsBalanceExpiresAt = Date(timeIntervalSince1970: 1_784_995_199) let minimax = MiniMaxUsageSnapshot( planName: "Token Plan · TokenPlanPlus-年度会员", availablePrompts: nil, @@ -677,7 +678,7 @@ struct MiniMaxMenuCardModelTests { resetDescription: "Resets in 6 days"), ], pointsBalance: 14000, - pointsBalanceExpiresAt: Date(timeIntervalSince1970: 1_784_995_199), + pointsBalanceExpiresAt: pointsBalanceExpiresAt, subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) let snapshot = minimax.toUsageSnapshot() let metadata = try #require(ProviderDefaults.metadata[.minimax]) @@ -715,7 +716,10 @@ struct MiniMaxMenuCardModelTests { #expect(model.metrics[1].cardStyle == false) #expect(model.providerCost?.title == "Credits") #expect(model.providerCost?.spendLine == "Balance: 14000") - #expect(model.providerCost?.personalSpendLine?.contains("Jul 25, 2026 at 11:59:59") == true) + 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/MiniMaxDesktopCookieImporterTests.swift b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift new file mode 100644 index 0000000000..049c194fe5 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift @@ -0,0 +1,55 @@ +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"), + ("agent.minimaxi.com", "_token", "desktop-token-value"), + ]) + 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) + } + + private func makeCookiesDatabase(records: [(String, String, String)]) 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 + ); + """ + guard sqlite3_exec(db, createSQL, nil, nil, nil) == SQLITE_OK else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("create failed") + } + + for (host, name, value) in records { + let insertSQL = "INSERT INTO cookies(host_key, name, value) VALUES ('\(host)','\(name)','\(value)');" + guard sqlite3_exec(db, insertSQL, nil, nil, nil) == SQLITE_OK else { + throw MiniMaxDesktopCookieImportError.sqliteFailed("insert failed") + } + } + + return databaseURL + } +} +#endif diff --git a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift index defaed616b..130503a794 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift @@ -116,7 +116,7 @@ struct MiniMaxMenuCardBillingTests { now: now)) #expect(model.inlineUsageDashboard?.kpis[0].title == "Today") - #expect(model.inlineUsageDashboard?.kpis[0].value == "$0.01") + #expect(model.inlineUsageDashboard?.kpis[0].value == "$0.03") #expect(model.inlineUsageDashboard?.kpis[1].title == "30d cost") #expect(model.inlineUsageDashboard?.kpis[2].title == "07-02 20:00 usage") #expect(model.inlineUsageDashboard?.kpis[2].value == "88.00K") diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 92c546bdba..75a3103d75 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1051,14 +1051,8 @@ struct MiniMaxUsageParserTests { } @Test - func `web usage fetch skips billing history when optional usage is disabled`() async throws { + func `web usage fetch skips console enrichment when optional usage is disabled`() async throws { let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) - let creditBody = """ - { - "remaining_credits": 20000, - "base_resp": { "status_code": 0 } - } - """ let transport = ProviderHTTPTransportStub { request in let url = try #require(request.url) if url.path.contains("coding-plan") { @@ -1067,14 +1061,8 @@ 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") - } - #expect(url.path == "/backend/account/token_plan_credit") - return Self.httpResponse(url: url, body: creditBody, contentType: "application/json") + Issue.record("Unexpected optional enrichment request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") } let snapshot = try await MiniMaxUsageFetcher.fetchUsage( @@ -1088,10 +1076,10 @@ struct MiniMaxUsageParserTests { let requests = await transport.requests() #expect(snapshot.currentPrompts == 2) #expect(snapshot.billingSummary == nil) - #expect(snapshot.pointsBalance == 20000) + #expect(snapshot.pointsBalance == nil) let billingRequests = requests.filter { $0.url?.path == "/account/amount" } #expect(billingRequests.isEmpty) - #expect(requests.count == 3) + #expect(requests.count == 1) } @Test @@ -1284,6 +1272,13 @@ struct MiniMaxAPIRegionTests { #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/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index dc4aa71c6c..559cefbe8f 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -73,9 +73,8 @@ struct MiniMaxTokenPlanCreditTests { } @Test - func `web usage fetch merges token plan credit balance`() async throws { + func `web usage fetch skips optional console enrichment when disabled`() 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.contains("coding-plan") { @@ -87,15 +86,8 @@ struct MiniMaxTokenPlanCreditTests { if url.path.contains("coding_plan/remains") { return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") } - if url.path == "/backend/account/token_plan/usage_summary" { - return Self.httpResponse( - url: url, - body: #"{"daily_token_usage":[],"date_model_usage":[],"base_resp":{"status_code":0}}"#, - contentType: "application/json") - } - #expect(url.host == "www.minimaxi.com") - #expect(url.path == "/backend/account/token_plan_credit") - return Self.httpResponse(url: url, body: creditJSON, contentType: "application/json") + Issue.record("Unexpected optional enrichment request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") } let snapshot = try await MiniMaxUsageFetcher.fetchUsage( @@ -106,10 +98,10 @@ struct MiniMaxTokenPlanCreditTests { session: transport, now: now) - #expect(snapshot.pointsBalance == 20000) - #expect(snapshot.toUsageSnapshot().providerCost?.used == 20000) + #expect(snapshot.pointsBalance == nil) let requests = await transport.requests() - #expect(requests.contains { $0.url?.path == "/backend/account/token_plan_credit" }) + #expect(!requests.contains { $0.url?.path == "/backend/account/token_plan_credit" }) + #expect(!requests.contains { $0.url?.path == "/backend/account/token_plan/usage_summary" }) } @Test @@ -215,6 +207,38 @@ struct MiniMaxTokenPlanCreditTests { #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) diff --git a/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift index 82398f942b..fa5ce7a68b 100644 --- a/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift +++ b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift @@ -61,6 +61,41 @@ struct MiniMaxUsagePricingTests { #expect(summary.projectedCostUSD(lastDays: 30) != nil) } + @Test + func `daily aggregate model rows do not use per request long context pricing`() throws { + let shortRate = try #require(MiniMaxUsagePricing.minimaxAggregateCostUSD( + model: "MiniMax-M3", + inputToken: 600_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 1_000_000)) + let longRate = try #require(MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M3", + inputToken: 600_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 1_000_000)) + + #expect(shortRate < longRate) + } + + @Test + func `days without model attribution remain unpriced`() { + let summary = Self.sampleSummary() + let unattributed = MiniMaxUsageSummaryDay( + date: "2026-07-03", + totalInputToken: 1_000_000, + totalCacheReadToken: 500_000, + totalCacheCreateToken: 0, + totalOutputToken: 100_000, + totalToken: 1_100_000, + cacheHitPercent: 50, + models: []) + + #expect(summary.projectedCostUSD(for: unattributed) == nil) + #expect(summary.projectedModelBreakdowns(for: unattributed).isEmpty) + } + @Test func `snapshot day with model breakdown projects cost history`() { let summary = Self.sampleSummary() diff --git a/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift b/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift index 4bf192b9fc..92c1d10a61 100644 --- a/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift +++ b/Tests/CodexBarTests/MiniMaxUsageSummaryChartMenuViewTests.swift @@ -75,6 +75,42 @@ struct MiniMaxUsageSummaryChartMenuViewTests { #expect(!MiniMaxUsageSummaryChartMenuView.detailRowsNeedScrolling(itemCount: 3)) } + @Test + @MainActor + func `duplicate model names do not trap detail rendering`() { + let duplicate = MiniMaxUsageSummaryModel( + model: "MiniMax-M3", + inputToken: 100, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 10, + totalToken: 110, + cacheHitPercent: 0) + let usage = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "220", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 21:00", + dailyTokenUsage: [220], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 200, + totalCacheReadToken: 0, + totalCacheCreateToken: 0, + totalOutputToken: 20, + totalToken: 220, + cacheHitPercent: 0, + models: [duplicate, duplicate]), + ]) + + #expect(MiniMaxUsageSummaryChartMenuView._detailRowCountForTesting( + usage: usage, + dateKey: "2026-07-02") == 2) + } + @Test @MainActor func `usage summary detail height follows visible rows and caps at viewport limit`() { diff --git a/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift index 05e2bce61f..d8e52b10eb 100644 --- a/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift +++ b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift @@ -123,7 +123,9 @@ struct MiniMaxUsageSummaryTests { days: []) #expect(summary.hasDisplayableData) - #expect(summary.trendDays(last: 3).map(\.totalToken) == [100, 200, 300]) + let trend = summary.trendDays(last: 3) + #expect(trend.map(\.date) == ["2026-06-30", "2026-07-01", "2026-07-02"]) + #expect(trend.map(\.totalToken) == [100, 200, 300]) #expect(summary.last7DaysTokens == 600) #expect(summary.latestSnapshotTokens == 300) } diff --git a/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift new file mode 100644 index 0000000000..e1cf15dedb --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift @@ -0,0 +1,80 @@ +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 `browser import gate allows optional usage in app background refresh`() { + let context = self.makeContext(runtime: .app, includeOptionalUsage: true) + + ProviderInteractionContext.$current.withValue(.background) { + #expect(MiniMaxWebEnrichmentResolver.allowsBrowserCookieImport(context: context)) + } + } + + @Test + func `browser import gate requires user initiated refresh when optional usage is disabled`() { + let context = self.makeContext(runtime: .app, includeOptionalUsage: false) + + ProviderInteractionContext.$current.withValue(.background) { + #expect(!MiniMaxWebEnrichmentResolver.allowsBrowserCookieImport(context: context)) + } + + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(MiniMaxWebEnrichmentResolver.allowsBrowserCookieImport(context: context)) + } + } + + 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/docs/minimax.md b/docs/minimax.md index 23bb818de1..f33a3ea2d9 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -21,14 +21,21 @@ 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 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 when present. + +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`. @@ -45,16 +52,20 @@ falls back across the provider's supported web requests when needed. - 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 the browser session cookie. API-token remains responses do not include this balance. -- Console usage summary (`GET .../backend/account/token_plan/usage_summary`) enriches web-session refreshes with 30-day token/cost trends, cache-hit stats, and per-model breakdowns when a browser cookie is available. API-token refreshes only attempt summary enrichment when an explicit manual or environment cookie is configured. -- API-token refreshes only merge recharge credits when an explicit manual or environment cookie is configured; cached browser cookies are not attached automatically to avoid cross-account balance bleed. +- 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 (`_token` from MiniMax Agent or the browser). API-token remains responses do not include this balance. +- Console usage summary (`GET .../backend/account/token_plan/usage_summary`) enriches API-token and web-session refreshes when a valid web cookie is available from MiniMax Agent, browser import, cache, manual settings, or `MINIMAX_COOKIE`. +- API-token refreshes merge recharge credits and usage-summary enrichment through `MiniMaxWebEnrichmentResolver`, 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. - 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. @@ -67,6 +78,8 @@ 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` From bc0b9601e4f38ef7867a213928cdea11125e022a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 19:18:19 +0000 Subject: [PATCH 18/53] Fix PR 1821 CI: update MiniMax test and complete tr/it/id/pl locales - Align web-session enrichment test with credit/usage-summary always-on behavior - Add 21 MiniMax usage dashboard strings to tr, it, id, pl catalogs - Fix Indonesian Keychain prompt key quoting to match English catalog Co-authored-by: Yuxin Qiao --- .../Resources/id.lproj/Localizable.strings | 23 ++++++++++++++++++- .../Resources/it.lproj/Localizable.strings | 21 +++++++++++++++++ .../Resources/pl.lproj/Localizable.strings | 21 +++++++++++++++++ .../Resources/tr.lproj/Localizable.strings | 21 +++++++++++++++++ .../MiniMaxTokenPlanCreditTests.swift | 22 ++++++++++++++---- 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index fd9b5e6528..c730ae7607 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -54,6 +54,26 @@ "CLI paths" = "Path CLI"; "CLI sessions" = "Sesi CLI"; "Caches" = "Cache"; +"Cache hit: %@" = "Hit cache: %@"; +"Cache hit" = "Hit cache"; +"%@ usage" = "Penggunaan %@"; +"Latest usage" = "Penggunaan terbaru"; +"7d tokens" = "Token 7 hari"; +"Last 7 days: %@ tokens" = "7 hari terakhir: %@ token"; +"Last 7 days" = "7 hari terakhir"; +"Usage trend" = "Tren penggunaan"; +"MiniMax 7 day token usage trend" = "Tren penggunaan token MiniMax 7 hari"; +"Language models only; data may be delayed" = "Hanya model bahasa; data mungkin tertunda"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · hit cache %@"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · hit cache %@"; +"Total consumed" = "Total dikonsumsi"; +"Daily peak" = "Puncak harian"; +"Active days" = "Hari aktif"; +"Active days: %@" = "Hari aktif: %@"; +"Streak: %@ days" = "Beruntun: %@ hari"; +"Usage rank: top %@%%" = "Peringkat penggunaan: %@%% teratas"; +"Token usage details" = "Detail penggunaan token"; +"Day totals" = "Total harian"; "Cancel" = "Batal"; "Check for Updates…" = "Periksa Pembaruan…"; "Check for updates automatically" = "Periksa pembaruan secara otomatis"; @@ -125,6 +145,7 @@ "Enabled" = "Aktif"; "Error" = "Error"; "Error simulation" = "Simulasi error"; +"Expires: %@" = "Berlaku hingga: %@"; "Expose troubleshooting tools in the Debug tab." = "Tampilkan alat pemecahan masalah di tab Debug."; "Failed" = "Gagal"; "False" = "Salah"; @@ -982,7 +1003,7 @@ "CodexBar could not save the current system account before switching." = "CodexBar tidak dapat menyimpan akun sistem saat ini sebelum beralih."; "CodexBar could not update managed account storage." = "CodexBar tidak dapat memperbarui penyimpanan akun terkelola."; "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar menemukan akun terkelola lain yang sudah menggunakan akun sistem saat ini. Selesaikan akun duplikat sebelum beralih."; -"CodexBar will ask macOS Keychain for \U201c%@\U201d so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk \U201c%@\U201d agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk “%@” agar dapat mendekripsi cookie browser dan mengautentikasi akun Anda. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk token OAuth Claude Code agar dapat mengambil penggunaan Claude Anda. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Amp Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar akan meminta Keychain macOS untuk header cookie Augment Anda agar dapat mengambil penggunaan. Klik OK untuk melanjutkan."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 2d533c1aa7..f64ef7cefe 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -54,6 +54,26 @@ "CLI paths" = "Percorsi CLI"; "CLI sessions" = "Sessioni CLI"; "Caches" = "Cache"; +"Cache hit: %@" = "Cache hit: %@"; +"Cache hit" = "Cache hit"; +"%@ usage" = "Utilizzo %@"; +"Latest usage" = "Ultimo utilizzo"; +"7d tokens" = "Token 7g"; +"Last 7 days: %@ tokens" = "Ultimi 7 giorni: %@ token"; +"Last 7 days" = "Ultimi 7 giorni"; +"Usage trend" = "Andamento utilizzo"; +"MiniMax 7 day token usage trend" = "Trend utilizzo token MiniMax 7 giorni"; +"Language models only; data may be delayed" = "Solo modelli linguistici; i dati possono essere in ritardo"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ cache hit"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ cache hit"; +"Total consumed" = "Totale consumato"; +"Daily peak" = "Picco giornaliero"; +"Active days" = "Giorni attivi"; +"Active days: %@" = "Giorni attivi: %@"; +"Streak: %@ days" = "Serie: %@ giorni"; +"Usage rank: top %@%%" = "Classifica utilizzo: top %@%%"; +"Token usage details" = "Dettagli utilizzo token"; +"Day totals" = "Totali giornalieri"; "Cancel" = "Annulla"; "Check for Updates…" = "Controlla aggiornamenti…"; "Check for updates automatically" = "Controlla automaticamente gli aggiornamenti"; @@ -125,6 +145,7 @@ "Enabled" = "Attivo"; "Error" = "Errore"; "Error simulation" = "Simulazione errore"; +"Expires: %@" = "Scade: %@"; "Expose troubleshooting tools in the Debug tab." = "Espone gli strumenti di risoluzione problemi nella scheda Debug."; "Failed" = "Fallito"; "False" = "Falso"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index f2b339463e..b42fadcc8c 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -54,6 +54,26 @@ "CLI paths" = "Ścieżki CLI"; "CLI sessions" = "Sesje CLI"; "Caches" = "Pamięci podręczne"; +"Cache hit: %@" = "Trafienia cache: %@"; +"Cache hit" = "Trafienia cache"; +"%@ usage" = "Użycie %@"; +"Latest usage" = "Ostatnie użycie"; +"7d tokens" = "Tokeny 7d"; +"Last 7 days: %@ tokens" = "Ostatnie 7 dni: %@ tokenów"; +"Last 7 days" = "Ostatnie 7 dni"; +"Usage trend" = "Trend użycia"; +"MiniMax 7 day token usage trend" = "Trend użycia tokenów MiniMax (7 dni)"; +"Language models only; data may be delayed" = "Tylko modele językowe; dane mogą być opóźnione"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ wejście · %@ wyjście · %@ trafień cache"; +"%@ input · %@ output · %@ cache hit" = "%@ wejście · %@ wyjście · %@ trafień cache"; +"Total consumed" = "Łącznie zużyto"; +"Daily peak" = "Dzienny szczyt"; +"Active days" = "Aktywne dni"; +"Active days: %@" = "Aktywne dni: %@"; +"Streak: %@ days" = "Seria: %@ dni"; +"Usage rank: top %@%%" = "Ranking użycia: top %@%%"; +"Token usage details" = "Szczegóły użycia tokenów"; +"Day totals" = "Sumy dzienne"; "Cancel" = "Anuluj"; "Check for Updates…" = "Sprawdź aktualizacje…"; "Check for updates automatically" = "Sprawdzaj aktualizacje automatycznie"; @@ -125,6 +145,7 @@ "Enabled" = "Włączone"; "Error" = "Błąd"; "Error simulation" = "Symulacja błędu"; +"Expires: %@" = "Wygasa: %@"; "Expose troubleshooting tools in the Debug tab." = "Udostępnia narzędzia diagnostyczne na karcie Debug."; "Failed" = "Niepowodzenie"; "False" = "Fałsz"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 0e81bfc018..ee82ec01e4 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -54,6 +54,26 @@ "CLI paths" = "CLI yolları"; "CLI sessions" = "CLI oturumları"; "Caches" = "Önbellekler"; +"Cache hit: %@" = "Önbellek isabeti: %@"; +"Cache hit" = "Önbellek isabeti"; +"%@ usage" = "%@ kullanımı"; +"Latest usage" = "Son kullanım"; +"7d tokens" = "7g token"; +"Last 7 days: %@ tokens" = "Son 7 gün: %@ token"; +"Last 7 days" = "Son 7 gün"; +"Usage trend" = "Kullanım trendi"; +"MiniMax 7 day token usage trend" = "MiniMax 7 günlük token kullanım trendi"; +"Language models only; data may be delayed" = "Yalnızca dil modelleri; veriler gecikebilir"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ girdi · %@ çıktı · %@ önbellek isabeti"; +"%@ input · %@ output · %@ cache hit" = "%@ girdi · %@ çıktı · %@ önbellek isabeti"; +"Total consumed" = "Toplam tüketim"; +"Daily peak" = "Günlük zirve"; +"Active days" = "Aktif günler"; +"Active days: %@" = "Aktif günler: %@"; +"Streak: %@ days" = "Seri: %@ gün"; +"Usage rank: top %@%%" = "Kullanım sırası: ilk %@%%"; +"Token usage details" = "Token kullanım detayları"; +"Day totals" = "Günlük toplamlar"; "Cancel" = "İptal"; "Check for Updates…" = "Güncellemeleri Denetle…"; "Check for updates automatically" = "Güncellemeleri otomatik denetle"; @@ -125,6 +145,7 @@ "Enabled" = "Etkin"; "Error" = "Hata"; "Error simulation" = "Hata simülasyonu"; +"Expires: %@" = "Sona erer: %@"; "Expose troubleshooting tools in the Debug tab." = "Sorun giderme araçlarını Hata Ayıklama sekmesinde göster."; "Failed" = "Başarısız"; "False" = "Yanlış"; diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index 559cefbe8f..4dadbe9e6d 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -73,7 +73,7 @@ struct MiniMaxTokenPlanCreditTests { } @Test - func `web usage fetch skips optional console enrichment when disabled`() async throws { + func `web usage fetch still enriches credit and usage summary when billing history disabled`() async throws { let now = Date(timeIntervalSince1970: 1_780_282_340) let transport = ProviderHTTPTransportStub { request in let url = try #require(request.url) @@ -86,7 +86,20 @@ struct MiniMaxTokenPlanCreditTests { if url.path.contains("coding_plan/remains") { return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") } - Issue.record("Unexpected optional enrichment request: \(url.absoluteString)") + 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") } @@ -100,8 +113,9 @@ struct MiniMaxTokenPlanCreditTests { #expect(snapshot.pointsBalance == nil) let requests = await transport.requests() - #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.contains("account/amount") == true }) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan_credit" }) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan/usage_summary" }) } @Test From 4be8e947eabfbc4a43ae9fcbed5bbed67c3f9755 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 19:37:32 +0000 Subject: [PATCH 19/53] Translate Italian MiniMax cache-hit strings for localization catalog test Co-authored-by: Yuxin Qiao --- Sources/CodexBar/Resources/it.lproj/Localizable.strings | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index f64ef7cefe..0067cff80b 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -54,8 +54,8 @@ "CLI paths" = "Percorsi CLI"; "CLI sessions" = "Sessioni CLI"; "Caches" = "Cache"; -"Cache hit: %@" = "Cache hit: %@"; -"Cache hit" = "Cache hit"; +"Cache hit: %@" = "Hit della cache: %@"; +"Cache hit" = "Hit della cache"; "%@ usage" = "Utilizzo %@"; "Latest usage" = "Ultimo utilizzo"; "7d tokens" = "Token 7g"; @@ -64,8 +64,8 @@ "Usage trend" = "Andamento utilizzo"; "MiniMax 7 day token usage trend" = "Trend utilizzo token MiniMax 7 giorni"; "Language models only; data may be delayed" = "Solo modelli linguistici; i dati possono essere in ritardo"; -"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ cache hit"; -"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ cache hit"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ hit cache"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ hit cache"; "Total consumed" = "Totale consumato"; "Daily peak" = "Picco giornaliero"; "Active days" = "Giorni attivi"; From c8541d919a32ed380abdfe27740b63464a8573f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 20:07:18 +0000 Subject: [PATCH 20/53] Update MiniMaxProviderTests for always-on credit and usage-summary enrichment Co-authored-by: Yuxin Qiao --- .../CodexBarTests/MiniMaxProviderTests.swift | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 75a3103d75..ceb6b6ef88 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1051,7 +1051,7 @@ struct MiniMaxUsageParserTests { } @Test - func `web usage fetch skips console enrichment 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) @@ -1061,7 +1061,20 @@ struct MiniMaxUsageParserTests { body: Self.codingPlanJSON, contentType: "application/json") } - Issue.record("Unexpected optional enrichment request: \(url.absoluteString)") + 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") } @@ -1079,7 +1092,8 @@ struct MiniMaxUsageParserTests { #expect(snapshot.pointsBalance == nil) let billingRequests = requests.filter { $0.url?.path == "/account/amount" } #expect(billingRequests.isEmpty) - #expect(requests.count == 1) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan_credit" }) + #expect(requests.contains { $0.url?.path == "/backend/account/token_plan/usage_summary" }) } @Test From 1b14fa19c7acd1386082750353c1d55742e96286 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 21:07:47 +0000 Subject: [PATCH 21/53] Address ClawSweeper P2 review findings for MiniMax enrichment - Restrict API-token credit/summary enrichment to explicit cookies only - Extract desktop cookie group IDs for remains fetches - Scope desktop cookie import to platform/www web hosts - Ignore expired resets in nearest-reset window selection - Gate MiniMax projected-cost dashboard on cost-summary toggle - Leave unrecognized MiniMax models unpriced - Include per-model fields in usage-summary submenu signature - Label MiniMax utilization history lanes by window type Co-authored-by: Yuxin Qiao --- .../InlineUsageDashboardContent.swift | 1 + .../MenuBarMetricWindowResolver.swift | 4 +- .../StatusItemController+HostedSubmenus.swift | 7 ++- .../CodexBar/UsageStore+PlanUtilization.swift | 49 +++++++++++++++- .../MiniMaxDesktopCookieImporter.swift | 11 ++-- .../MiniMax/MiniMaxProviderDescriptor.swift | 3 +- .../MiniMax/MiniMaxUsagePricing.swift | 2 +- .../MiniMaxWebEnrichmentResolver.swift | 32 +++++++---- .../MenuBarMetricWindowResolverTests.swift | 26 ++++++++- .../MiniMaxDesktopCookieImporterTests.swift | 15 ++++- .../MiniMaxUsagePricingTests.swift | 12 ++++ .../MiniMaxWebEnrichmentResolverTests.swift | 16 ++++++ .../UsageStorePlanUtilizationTests.swift | 57 +++++++++++++++++++ 13 files changed, 209 insertions(+), 26 deletions(-) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 092ae09287..8a606f95d9 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -171,6 +171,7 @@ extension UsageMenuCardView.Model { private static func resolveInlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { if input.provider == .minimax, input.showOptionalCreditsAndExtraUsage, + input.tokenCostInlineDashboardEnabled, let usageSummary = input.snapshot?.minimaxUsage?.usageSummary, usageSummary.hasDisplayableData, let costSnapshot = primaryCostHistorySnapshot(input: input), diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 07bf415e1a..2b189f8b97 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -228,12 +228,12 @@ enum MenuBarMetricWindowResolver { } /// The quota window whose reset is soonest among the primary, secondary, and tertiary lanes. - static func nearestResetWindow(snapshot: UsageSnapshot?) -> RateWindow? { + static func nearestResetWindow(snapshot: UsageSnapshot?, now: Date = Date()) -> RateWindow? { guard let snapshot else { return nil } let datedWindows = [snapshot.primary, snapshot.secondary, snapshot.tertiary] .compactMap(\.self) .compactMap { window -> (RateWindow, Date)? in - guard let resetsAt = window.resetsAt else { return nil } + guard let resetsAt = window.resetsAt, resetsAt > now else { return nil } return (window, resetsAt) } guard !datedWindows.isEmpty else { return nil } diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 8565a921c4..d2a1e29dea 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -277,8 +277,11 @@ extension StatusItemController { else { return "none" } - let days = usage.days.suffix(30).map { - "\($0.date):\($0.totalToken):\($0.cacheHitPercent ?? -1):\($0.models.count)" + let days = usage.days.suffix(30).map { day in + let models = day.models.map { + "\($0.model):\($0.inputToken):\($0.outputToken):\($0.cacheReadToken):\($0.totalToken)" + }.joined(separator: ";") + return "\(day.date):\(day.totalToken):\(day.cacheHitPercent ?? -1):\(models)" }.joined(separator: "|") return [ usage.lastUpdateTime ?? "", diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 2ac8f52b32..b946431fad 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -554,8 +554,9 @@ extension UsageStore { appendWindow(snapshot.secondary, name: .weekly) appendWindow(snapshot.tertiary, name: .opus) case .minimax: - appendWindow(snapshot.primary, name: .session) - appendWindow(snapshot.secondary, name: .weekly) + self.appendMiniMaxPlanUtilizationSamples( + snapshot: snapshot, + appendWindow: appendWindow) case .antigravity: let namedWeeklyWindows = snapshot.extraRateWindows? .filter { @@ -593,6 +594,50 @@ extension UsageStore { } } + private func appendMiniMaxPlanUtilizationSamples( + snapshot: UsageSnapshot, + appendWindow: (_ window: RateWindow?, _ name: PlanUtilizationSeriesName?) -> Void) + { + let services = snapshot.minimaxUsage?.services? + .filter(\.isPrimaryTextQuotaLane) ?? [] + if services.isEmpty { + appendWindow(snapshot.primary, name: .session) + appendWindow(snapshot.secondary, name: .weekly) + return + } + + if let weeklyService = services.first(where: { + $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" + }) { + appendWindow(self.rateWindow(for: weeklyService), name: .weekly) + } + + let sessionService = services + .filter { + let normalized = $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized != "weekly" && normalized != "today" && normalized != "今日" + } + .min { + self.windowMinutes(for: $0) < self.windowMinutes(for: $1) + } + if let sessionService { + appendWindow(self.rateWindow(for: sessionService), name: .session) + } + } + + private func rateWindow(for service: MiniMaxServiceUsage) -> RateWindow { + RateWindow( + usedPercent: max(0, min(100, service.percent)), + windowMinutes: self.windowMinutes(for: service), + resetsAt: service.resetsAt, + resetDescription: service.resetDescription) + } + + private func windowMinutes(for service: MiniMaxServiceUsage) -> Int { + let parsed = MiniMaxServiceUsage.parseWindowType(service.windowType).windowMinutes + return parsed ?? 300 + } + private nonisolated static func planUtilizationHourBucket(for date: Date) -> Int64 { Int64(floor(date.timeIntervalSince1970 / self.planUtilizationMinSampleIntervalSeconds)) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift index 52397a40bb..036d5c9d55 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift @@ -5,7 +5,12 @@ import SQLite3 enum MiniMaxDesktopCookieImporter { private static let log = CodexBarLog.logger(LogCategories.minimaxCookie) private static let sourceLabel = "MiniMax Agent" - private static let domainPatterns = ["minimaxi.com", "minimax.io"] + private static let webCookieHosts: Set = [ + "www.minimaxi.com", + "www.minimax.io", + "platform.minimaxi.com", + "platform.minimax.io", + ] static func cookiesDatabaseURL(fileManager: FileManager = .default) -> URL { fileManager.homeDirectoryForCurrentUser @@ -94,9 +99,7 @@ enum MiniMaxDesktopCookieImporter { let normalized = domain.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: ".")) .lowercased() - return self.domainPatterns.contains { pattern in - normalized == pattern || normalized.hasSuffix(".\(pattern)") - } + return self.webCookieHosts.contains(normalized) } private static func makeHTTPCookies(from records: [Record]) -> [HTTPCookie] { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 6a3670bdc6..65919e5e4a 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -91,7 +91,7 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { region: preferredRegion) var usage = apiResult.snapshot for candidate in context.includeOptionalUsage - ? MiniMaxWebEnrichmentResolver.candidates(context: context) + ? MiniMaxWebEnrichmentResolver.explicitCandidates(context: context) : [] { let cookieOverride = candidate.override @@ -362,6 +362,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/MiniMaxUsagePricing.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift index 47e2daad12..b26e4e7033 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsagePricing.swift @@ -138,7 +138,7 @@ enum MiniMaxUsagePricing { if normalized.contains("m2") { return self.m25Legacy } - return self.m27Standard + return nil } static func normalizeModel(_ model: String) -> String { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift index 9b46eebb43..d4de0c6230 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift @@ -9,18 +9,7 @@ enum MiniMaxWebEnrichmentResolver { static func candidates(context: ProviderFetchContext) -> [Candidate] { var candidates: [Candidate] = [] - if let settings = context.settings?.minimax, - let header = settings.manualCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), - !header.isEmpty, - let override = MiniMaxCookieHeader.override(from: header) - { - candidates.append(Candidate(override: override, sourceLabel: "settings", shouldCache: false)) - } - if let raw = ProviderTokenResolver.minimaxCookie(environment: context.env), - let override = MiniMaxCookieHeader.override(from: raw) - { - candidates.append(Candidate(override: override, sourceLabel: "environment", shouldCache: false)) - } + candidates.append(contentsOf: self.explicitCandidates(context: context)) #if os(macOS) if let session = MiniMaxDesktopCookieImporter.importSession(), @@ -63,6 +52,25 @@ enum MiniMaxWebEnrichmentResolver { return self.deduplicated(candidates) } + /// Cookies from explicit user configuration only. API-token enrichment must not attach + /// cached or imported browser/desktop sessions that may belong to a different MiniMax account. + static func explicitCandidates(context: ProviderFetchContext) -> [Candidate] { + var candidates: [Candidate] = [] + if let settings = context.settings?.minimax, + let header = settings.manualCookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), + !header.isEmpty, + let override = MiniMaxCookieHeader.override(from: header) + { + candidates.append(Candidate(override: override, sourceLabel: "settings", shouldCache: false)) + } + if let raw = ProviderTokenResolver.minimaxCookie(environment: context.env), + let override = MiniMaxCookieHeader.override(from: raw) + { + candidates.append(Candidate(override: override, sourceLabel: "environment", shouldCache: false)) + } + return self.deduplicated(candidates) + } + static func cacheValidated(_ candidate: Candidate) { guard candidate.shouldCache else { return } CookieHeaderCache.store( diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 5eb969a208..45c5eb3013 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -167,12 +167,36 @@ struct MenuBarMetricWindowResolverTests { resetDescription: nil), updatedAt: now) - let window = MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot) + let window = MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot, now: now) #expect(window?.resetsAt == sessionReset) #expect(window?.windowMinutes == 300) } + @Test + func `nearest reset window ignores expired minimax session reset`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expiredSessionReset = now.addingTimeInterval(-3600) + let weeklyReset = now.addingTimeInterval(3 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 300, + resetsAt: expiredSessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + + let window = MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot, now: now) + + #expect(window?.resetsAt == weeklyReset) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + @Test func `automatic metric preserves exhausted minimax session lane`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift index 049c194fe5..7cead6bf7c 100644 --- a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift +++ b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift @@ -10,13 +10,26 @@ struct MiniMaxDesktopCookieImporterTests { let databaseURL = try self.makeCookiesDatabase( records: [ (".www.minimaxi.com", "_token", "desktop-token-value"), - ("agent.minimaxi.com", "_token", "desktop-token-value"), + ("agent.minimaxi.com", "_token", "agent-token-value"), ]) 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"), + ]) + defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } + + let session = MiniMaxDesktopCookieImporter.importSession(databaseURL: databaseURL) + #expect(session?.cookieHeader.contains("_token=platform-token-value") == true) } private func makeCookiesDatabase(records: [(String, String, String)]) throws -> URL { diff --git a/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift index fa5ce7a68b..6ed24aeb95 100644 --- a/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift +++ b/Tests/CodexBarTests/MiniMaxUsagePricingTests.swift @@ -48,6 +48,18 @@ struct MiniMaxUsagePricingTests { #expect(cost == 0) } + @Test + func `unknown minimax models remain unpriced`() { + let cost = MiniMaxUsagePricing.minimaxCostUSD( + model: "MiniMax-M9-preview", + inputToken: 1_000_000, + cacheReadToken: 0, + cacheCreateToken: 0, + outputToken: 250_000) + + #expect(cost == nil) + } + @Test func `projected daily cost aggregates model breakdown`() { let summary = Self.sampleSummary() diff --git a/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift index e1cf15dedb..3c362fe0c9 100644 --- a/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift +++ b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift @@ -57,6 +57,22 @@ struct MiniMaxWebEnrichmentResolverTests { } } + @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 all = MiniMaxWebEnrichmentResolver.candidates(context: context) + + #expect(!explicit.contains { $0.sourceLabel == "Chrome" }) + #expect(all.contains { $0.sourceLabel == "Chrome" }) + } + private func makeContext( runtime: ProviderRuntime, includeOptionalUsage: Bool = true, diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 7d757631df..fef2596cda 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -686,6 +686,63 @@ struct UsageStorePlanUtilizationTests { #expect(!store.shouldHidePlanUtilizationMenuItem(for: .minimax)) } + @MainActor + @Test + func `record plan history labels minimax lanes by window type`() async { + let store = Self.makeStore() + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 12, + limit: 100, + percent: 12, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Today", + timeRange: "00:00-23:59(UTC+8)", + usage: 30, + limit: 100, + percent: 30, + resetsAt: now.addingTimeInterval(12 * 3600), + resetDescription: "Resets tonight"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let snapshot = minimax.toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .minimax) + + await store.recordPlanUtilizationHistorySample( + provider: .minimax, + snapshot: snapshot, + now: now) + + let histories = store.planUtilizationHistory(for: .minimax) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 12) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 4) + #expect(findSeries(histories, name: .weekly, windowMinutes: 1440) == nil) + } + @MainActor @Test func `record plan history skips invalid zero minute windows`() async { From b7470c93629b035aa1e13efea708e7ecb48e1f38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 21:35:24 +0000 Subject: [PATCH 22/53] Fix MiniMax plan utilization helper closure call labels Co-authored-by: Yuxin Qiao --- Sources/CodexBar/UsageStore+PlanUtilization.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index b946431fad..1714f09002 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -601,15 +601,15 @@ extension UsageStore { let services = snapshot.minimaxUsage?.services? .filter(\.isPrimaryTextQuotaLane) ?? [] if services.isEmpty { - appendWindow(snapshot.primary, name: .session) - appendWindow(snapshot.secondary, name: .weekly) + appendWindow(snapshot.primary, .session) + appendWindow(snapshot.secondary, .weekly) return } if let weeklyService = services.first(where: { $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" }) { - appendWindow(self.rateWindow(for: weeklyService), name: .weekly) + appendWindow(self.rateWindow(for: weeklyService), .weekly) } let sessionService = services @@ -621,7 +621,7 @@ extension UsageStore { self.windowMinutes(for: $0) < self.windowMinutes(for: $1) } if let sessionService { - appendWindow(self.rateWindow(for: sessionService), name: .session) + appendWindow(self.rateWindow(for: sessionService), .session) } } From b447e2076fa96e1811f22de07d0c1f84365a9fb8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 22:16:31 +0000 Subject: [PATCH 23/53] Map MiniMax weekly window type to 10080 minutes in plan history Co-authored-by: Yuxin Qiao --- Sources/CodexBar/UsageStore+PlanUtilization.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 1714f09002..5a41bc042a 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -634,8 +634,17 @@ extension UsageStore { } private func windowMinutes(for service: MiniMaxServiceUsage) -> Int { - let parsed = MiniMaxServiceUsage.parseWindowType(service.windowType).windowMinutes - return parsed ?? 300 + let windowType = service.windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + if windowType == "today" || windowType == "今日" { + return 24 * 60 + } + if windowType == "weekly" { + return 7 * 24 * 60 + } + if let parsed = MiniMaxServiceUsage.parseWindowType(service.windowType).windowMinutes { + return parsed + } + return 300 } private nonisolated static func planUtilizationHourBucket(for date: Date) -> Int64 { From 98d35cdd673000c9a50e1e8c3ba011cdab8b4e33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Jul 2026 23:02:42 +0000 Subject: [PATCH 24/53] Align MiniMax cost dashboard tests with cost-summary toggle Co-authored-by: Yuxin Qiao --- .../MiniMaxMenuCardBillingTests.swift | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift index 130503a794..0a428d751c 100644 --- a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift +++ b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift @@ -110,7 +110,7 @@ struct MiniMaxMenuCardBillingTests { lastError: nil, usageBarsShowUsed: true, resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, + tokenCostUsageEnabled: true, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) @@ -132,6 +132,86 @@ struct MiniMaxMenuCardBillingTests { } == true) } + @Test + func `minimax usage summary hides cost inline dashboard when cost summary is disabled`() throws { + let now = Date() + let summary = MiniMaxUsageSummary( + totalDays: 1, + totalTokenConsumed: "88K", + usageRankingPercent: nil, + activeDays: 1, + currentConsecutiveDays: 1, + lastUpdateTime: "07-02 20:00", + dailyTokenUsage: [88000], + days: [ + MiniMaxUsageSummaryDay( + date: "2026-07-02", + totalInputToken: 86000, + totalCacheReadToken: 64000, + totalCacheCreateToken: 0, + totalOutputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.63, + models: [ + MiniMaxUsageSummaryModel( + model: "MiniMax-M3-512k", + inputToken: 86000, + cacheReadToken: 64000, + cacheCreateToken: 0, + outputToken: 2500, + totalToken: 88000, + cacheHitPercent: 74.6), + ]), + ]) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: nil, + billingSummary: nil, + usageSummary: summary) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis.contains { $0.title == "Today" } == false) + #expect(model.inlineUsageDashboard?.kpis.contains { $0.title == "30d cost" } == false) + #expect(model.inlineUsageDashboard?.kpis.first?.title == "07-02 20:00 usage") + } + @Test func `minimax usage summary still renders token inline dashboard without priced models`() throws { let now = Date() From ee3547c68535e4e99a6bac07da5372856db1407d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 00:03:57 +0000 Subject: [PATCH 25/53] Gate MiniMax generic cost-history dashboard on cost-summary toggle Co-authored-by: Yuxin Qiao --- Sources/CodexBar/InlineUsageDashboardContent.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 8a606f95d9..ec109a4dea 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -182,6 +182,7 @@ extension UsageMenuCardView.Model { usage: usageSummary) } if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider), + input.provider != .minimax || input.tokenCostInlineDashboardEnabled, let tokenSnapshot = primaryCostHistorySnapshot(input: input), !tokenSnapshot.daily.isEmpty { From 0a20638b86f508dd593b36653f0d1c460216de76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 00:55:52 +0000 Subject: [PATCH 26/53] Align MiniMax docs with explicit-cookie-only API enrichment Co-authored-by: Yuxin Qiao --- docs/minimax.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/minimax.md b/docs/minimax.md index f33a3ea2d9..f8aba0b515 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -21,10 +21,11 @@ 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) **MiniMax Agent desktop session** (automatic when installed) +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 when present. + - Does not require Chrome Keychain access; used first on web-session refreshes when present. + - Not used for API-token optional enrichment (see Snapshot mapping). 3) **Cached/imported browser session** (automatic web path) - Uses CodexBar's standard cookie cache and browser import flow. @@ -63,9 +64,10 @@ falls back across the provider's supported web requests when needed. ## 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 (`_token` from MiniMax Agent or the browser). API-token remains responses do not include this balance. -- Console usage summary (`GET .../backend/account/token_plan/usage_summary`) enriches API-token and web-session refreshes when a valid web cookie is available from MiniMax Agent, browser import, cache, manual settings, or `MINIMAX_COOKIE`. -- API-token refreshes merge recharge credits and usage-summary enrichment through `MiniMaxWebEnrichmentResolver`, 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. +- 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** only attach optional recharge credits and usage-summary enrichment when an **explicit** web cookie is configured (manual settings cookie header or `MINIMAX_COOKIE` / `MINIMAX_COOKIE_HEADER`). The API path calls `MiniMaxWebEnrichmentResolver.explicitCandidates` and does not attach cached, browser-imported, or MiniMax Agent desktop cookies, so a different account's browser session cannot be merged onto an API-key quota snapshot. +- 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. From 0728e285b4fa1250698cf47667763f2c26a650ec Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Fri, 3 Jul 2026 11:51:05 +0800 Subject: [PATCH 27/53] Add MiniMax web session expiry detection and re-import UX. Mark cached browser cookies invalid on 401/403, surface recovery actions in the menu, and only read browser cookies on user-initiated refresh. Co-authored-by: Cursor --- Scripts/package_app.sh | 12 ++- .../Resources/en.lproj/Localizable.strings | 8 ++ .../zh-Hans.lproj/Localizable.strings | 8 ++ .../StatusItemController+Actions.swift | 15 ++++ .../CodexBar/StatusItemController+Menu.swift | 10 ++- ...StatusItemController+MenuSmartUpdate.swift | 2 +- ...ontroller+MergedSwitcherContentCache.swift | 77 ++++++++++++++++ ...ontroller+MiniMaxUsageSummarySection.swift | 67 +++++++++++++- Sources/CodexBar/StatusItemController.swift | 1 + .../MiniMax/MiniMaxProviderDescriptor.swift | 47 +++++++--- .../MiniMaxTokenPlanCreditFetcher.swift | 18 +++- .../MiniMaxUsageFetcher+UsageSummary.swift | 70 +++++++++++++++ .../MiniMax/MiniMaxUsageFetcher.swift | 2 + .../MiniMaxUsageSnapshot+Metadata.swift | 12 ++- .../MiniMax/MiniMaxUsageSnapshot.swift | 42 ++++++++- .../MiniMaxWebEnrichmentResolver.swift | 89 +++++++++++-------- .../CodexBarTests/MiniMaxProviderTests.swift | 81 +++++++++++++++++ .../MiniMaxTokenPlanCreditTests.swift | 1 + .../MiniMaxWebEnrichmentResolverTests.swift | 15 +--- .../SakanaUsageFetcherTests.swift | 2 +- .../StatusMenuInstantOpenTests.swift | 41 +++++++++ .../project.pbxproj | 4 +- 22 files changed, 550 insertions(+), 74 deletions(-) diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 73f85f0f4b..5c9667b1ad 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -183,8 +183,9 @@ for ARCH in "${ARCH_LIST[@]}"; do stage_build_products "$ARCH" done -APP_FINAL="$ROOT/CodexBar.app" -APP_STAGE="$ROOT/.build/package/CodexBar.app" +APP_FINAL="${CODEXBAR_PACKAGE_OUTPUT:-$ROOT/CodexBar.app}" +APP_STAGE="${CODEXBAR_PACKAGE_STAGE:-$ROOT/.build/package/CodexBar.app}" +mkdir -p "$(dirname "$APP_FINAL")" "$(dirname "$APP_STAGE")" rm -rf "$APP_STAGE" APP="$APP_STAGE" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" @@ -453,6 +454,9 @@ chmod -R a+rX "$APP/Contents/Frameworks/Sparkle.framework" install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP/Contents/MacOS/CodexBar" # Re-sign Sparkle and all nested components with the selected package identity. SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" +# Finder metadata and quarantine attributes copied from downloaded artifacts make +# codesign reject nested Sparkle apps even though their file contents are valid. +xattr -cr "$SPARKLE" if [[ "$SIGNING_MODE" == "adhoc" ]]; then CODESIGN_ID="-" CODESIGN_ARGS=(--force --sign "$CODESIGN_ID") @@ -526,6 +530,10 @@ if [[ -d "${APP}/Contents/PlugIns/CodexBarWidget.appex" ]]; then fi # Finally sign the app bundle itself +# Signing nested bundles can reattach provenance metadata on some local volumes. +# Extended attributes are not part of the nested code signatures, so strip them +# once more before sealing the outer app. +xattr -cr "$APP" codesign "${CODESIGN_ARGS[@]}" \ --entitlements "$APP_ENTITLEMENTS" \ "$APP" diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 4463e54bd9..de504876b3 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -781,6 +781,14 @@ "Top method" = "Top method"; "30d cash" = "30d cash"; "30d billing history from MiniMax web session" = "30d billing history from MiniMax web session"; +"MiniMax web session expired" = "MiniMax web session expired"; +"MiniMax web session account does not match" = "MiniMax web session account does not match"; +"No MiniMax browser session found" = "No MiniMax browser session found"; +"MiniMax web data endpoints are unavailable" = "MiniMax web data endpoints are unavailable"; +"MiniMax web session unavailable" = "MiniMax web session unavailable"; +"Re-import MiniMax session from browser" = "Re-import MiniMax session from browser"; +"Open MiniMax login page" = "Open MiniMax login page"; +"MiniMax web session: %@" = "MiniMax web session: %@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer billing can lag."; "Rate limit: %d / %@" = "Rate limit: %d / %@"; "Key remaining" = "Key remaining"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 178e2f9ad9..1db7759416 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -753,6 +753,14 @@ "Top method" = "主要方法"; "30d cash" = "近 30 天费用"; "30d billing history from MiniMax web session" = "来自 MiniMax Web 会话的近 30 天账单历史"; +"MiniMax web session expired" = "MiniMax 网页会话已过期"; +"MiniMax web session account does not match" = "MiniMax 网页会话与 API 账号不匹配"; +"No MiniMax browser session found" = "未找到 MiniMax 浏览器会话"; +"MiniMax web data endpoints are unavailable" = "MiniMax 网页数据接口不可用"; +"MiniMax web session unavailable" = "MiniMax 网页会话不可用"; +"Re-import MiniMax session from browser" = "从浏览器重新导入 MiniMax 会话"; +"Open MiniMax login page" = "打开 MiniMax 登录页"; +"MiniMax web session: %@" = "MiniMax 网页会话:%@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 账单数据可能延迟。"; "Rate limit: %d / %@" = "速率限制:%d / %@"; "Key remaining" = "密钥剩余额度"; diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index 7cb1a807a9..f49b609142 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -241,6 +241,21 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { NSWorkspace.shared.open(url) } + @objc func reimportMiniMaxWebSession() { + CookieHeaderCache.clear(provider: .minimax) + Task { + await self.performStoreRefresh( + for: .minimax, + refreshOpenMenusWhenComplete: true, + interaction: .userInitiated) + } + } + + @objc func openMiniMaxLoginPage() { + guard let url = self.dashboardURL(for: .minimax) else { return } + NSWorkspace.shared.open(url) + } + func dashboardURL( for provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index a7b4fd579a..b9553c7896 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -145,6 +145,7 @@ extension StatusItemController { menuWasFreshBeforeOpen: menuWasFreshBeforeOpen) } self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + self.scheduleMiniMaxMergedMenuPrewarmIfNeeded(menu) // Only schedule refresh after menu is registered as open - refreshNow is called async self.scheduleOpenMenuRefresh(for: menu) } @@ -167,6 +168,8 @@ extension StatusItemController { } self.clearMergedSwitcherContentCache(for: menu) + self.miniMaxMergedMenuPrewarmTask?.cancel() + self.miniMaxMergedMenuPrewarmTask = nil let wasTracked = self.openMenus.removeValue(forKey: key) != nil let menuTrackingEnded = wasTracked && self.openMenus.isEmpty if self.openMenus.isEmpty { @@ -219,6 +222,11 @@ extension StatusItemController { breadcrumb: "populateMenu:\(provider?.rawValue ?? "merged")") defer { self.endMenuOperationTrace(trace, menu: menu, provider: provider) } defer { self.refreshMenuCardHeights(in: menu) } + defer { + if self.openMenus[ObjectIdentifier(menu)] != nil { + self.scheduleMiniMaxMergedMenuPrewarmIfNeeded(menu) + } + } let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) @@ -456,7 +464,7 @@ extension StatusItemController { } } - private func openAIWebContext( + func openAIWebContext( currentProvider: UsageProvider, showAllAccounts: Bool) -> OpenAIWebContext { diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift index 13e6fd34ae..ab63d06e56 100644 --- a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -102,7 +102,7 @@ extension StatusItemController { /// Adds everything below the provider switcher (account switchers, card content, and /// actionable sections) to `target`, which may be a detached scratch menu; interaction /// closures always capture `captureMenu`, the live menu the rows will serve. - private func addSwitcherScopedMenuContent( + func addSwitcherScopedMenuContent( into target: NSMenu, captureMenu: NSMenu, context: MenuUpdateContext) diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift index f060a53edf..2a1058e7cc 100644 --- a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -32,6 +32,8 @@ struct MergedSwitcherContentCacheContext { } extension StatusItemController { + private static let miniMaxMergedMenuPrewarmDelay: Duration = .milliseconds(120) + func preservingMergedSwitcherContentCachesDuringInvalidation(_ body: () -> Void) { let previous = self.preservesMergedSwitcherContentCachesDuringInvalidation self.preservesMergedSwitcherContentCachesDuringInvalidation = true @@ -139,4 +141,79 @@ extension StatusItemController { self.updatePersistentRefreshItemsEnabled() return true } + + /// Builds MiniMax's richer card while the user is reading the currently selected provider. + /// The first switch can then use the same detached-item cache as subsequent switch-backs. + func scheduleMiniMaxMergedMenuPrewarmIfNeeded(_ menu: NSMenu) { + guard self.shouldMergeIcons, + self.store.enabledProvidersForDisplay().contains(.minimax), + self.lastMergedSwitcherSelection != .provider(.minimax) + else { return } + + self.miniMaxMergedMenuPrewarmTask?.cancel() + self.miniMaxMergedMenuPrewarmTask = Task { @MainActor [weak self, weak menu] in + try? await Task.sleep(for: Self.miniMaxMergedMenuPrewarmDelay) + guard !Task.isCancelled, let self, let menu else { return } + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + self.prewarmMiniMaxMergedMenuContent(in: menu) + self.miniMaxMergedMenuPrewarmTask = nil + } + } + + func prewarmMiniMaxMergedMenuContent(in menu: NSMenu) { + let enabledProviders = self.store.enabledProvidersForDisplay() + guard self.shouldMergeIcons, enabledProviders.contains(.minimax) else { return } + + let tokenAccountDisplay = self.tokenAccountMenuDisplay(for: .minimax) + let codexAccountDisplay: CodexAccountMenuDisplay? = nil + let descriptor = self.makeMenuDescriptor(provider: .minimax, includeContextualActions: true) + let menuWidth = self.menuCardWidth( + for: enabledProviders, + selectedProvider: .minimax, + descriptor: descriptor) + if self.reusableMergedSwitcherContent( + for: .provider(.minimax), + in: menu, + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay) != nil + { + return + } + + let previousCodexDisplay = self.lastCodexAccountMenuDisplay + let previousTokenDisplay = self.lastTokenAccountMenuDisplay + defer { + self.lastCodexAccountMenuDisplay = previousCodexDisplay + self.lastTokenAccountMenuDisplay = previousTokenDisplay + } + + let scratch = NSMenu() + scratch.autoenablesItems = false + self.addSwitcherScopedMenuContent( + into: scratch, + captureMenu: menu, + context: MenuUpdateContext( + provider: .minimax, + currentProvider: .minimax, + switcherSelection: .provider(.minimax), + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: self.openAIWebContext( + currentProvider: .minimax, + showAllAccounts: tokenAccountDisplay?.showAll ?? false), + descriptor: descriptor)) + let items = scratch.items + scratch.removeAllItems() + self.cacheMergedSwitcherContent( + items, + in: menu, + selection: .provider(.minimax), + context: MergedSwitcherContentCacheContext( + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + contentVersion: self.menuSession.contentVersion)) + } } diff --git a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift index 7bea490d7d..dd7e9a3831 100644 --- a/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift +++ b/Sources/CodexBar/StatusItemController+MiniMaxUsageSummarySection.swift @@ -5,9 +5,70 @@ extension StatusItemController { func addMiniMaxUsageSummarySectionIfNeeded(to menu: NSMenu, context: MenuCardContext) { let provider = context.currentProvider let width = context.menuWidth - guard self.addMiniMaxUsageSummaryMenuItemIfNeeded(to: menu, provider: provider, width: width) else { - return - } + let addedSummary = self.addMiniMaxUsageSummaryMenuItemIfNeeded(to: menu, provider: provider, width: width) + let addedRecovery = self.addMiniMaxWebSessionRecoveryItemsIfNeeded(to: menu, provider: provider) + let addedSource = self.addMiniMaxWebSessionSourceIfNeeded(to: menu, provider: provider) + guard addedSummary || addedRecovery || addedSource else { return } menu.addItem(.separator()) } + + @discardableResult + private func addMiniMaxWebSessionSourceIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard provider == .minimax, + self.settings.showOptionalCreditsAndExtraUsage, + let usage = self.store.snapshot(for: provider)?.minimaxUsage, + case let .valid(sourceLabel) = usage.webSessionState + else { return false } + let item = NSMenuItem( + title: L("MiniMax web session: %@", sourceLabel), + action: nil, + keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + return true + } + + @discardableResult + private func addMiniMaxWebSessionRecoveryItemsIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard provider == .minimax, + self.settings.showOptionalCreditsAndExtraUsage, + let usage = self.store.snapshot(for: provider)?.minimaxUsage, + usage.usageSummary == nil, + usage.pointsBalance == nil, + usage.webSessionState != .notChecked + else { return false } + + let statusTitle = switch usage.webSessionState { + case .expired: + L("MiniMax web session expired") + case .accountMismatch: + L("MiniMax web session account does not match") + case .unavailable(reason: .noBrowserSession): + L("No MiniMax browser session found") + case .unavailable(reason: .keychainAccessDisabled): + L("Keychain access is disabled in Advanced, so browser cookie import is unavailable.") + case .unavailable(reason: .endpointsUnavailable): + L("MiniMax web data endpoints are unavailable") + case .notChecked, .valid: + L("MiniMax web session unavailable") + } + let status = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + + let reimport = NSMenuItem( + title: L("Re-import MiniMax session from browser"), + action: #selector(self.reimportMiniMaxWebSession), + keyEquivalent: "") + reimport.target = self + menu.addItem(reimport) + + let login = NSMenuItem( + title: L("Open MiniMax login page"), + action: #selector(self.openMiniMaxLoginPage), + keyEquivalent: "") + login.target = self + menu.addItem(login) + return true + } } diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 30dffd7b31..2a0e67a946 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -129,6 +129,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin /// Persistent Refresh rows are weakly tracked so their enabled state can change during menu tracking. let persistentRefreshItems = NSHashTable.weakObjects() var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] + var miniMaxMergedMenuPrewarmTask: Task? var measuredStandardMenuWidthCache: [String: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" var lastMenuAdjunctReadinessBaselineVersion = 0 diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 65919e5e4a..6142563eca 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -90,10 +90,12 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { apiToken: apiToken, region: preferredRegion) var usage = apiResult.snapshot - for candidate in context.includeOptionalUsage - ? MiniMaxWebEnrichmentResolver.explicitCandidates(context: context) + 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( @@ -102,18 +104,43 @@ struct MiniMaxAPIFetchStrategy: ProviderFetchStrategy { region: apiResult.resolvedRegion, environment: context.env, transport: ProviderHTTPClient.shared) - usage = try await MiniMaxUsageFetcher.attachingUsageSummaryIfAvailable( - to: usage, - context: fetchContext, - groupID: cookieOverride.groupID) - usage = try await MiniMaxUsageFetcher.attachingTokenPlanCreditIfAvailable( - to: usage, + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: usage, context: fetchContext, groupID: cookieOverride.groupID) - if usage.usageSummary != nil || usage.pointsBalance != nil { + 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(), diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift index b4e3a4302d..aa67346a39 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxTokenPlanCreditFetcher.swift @@ -7,6 +7,7 @@ enum MiniMaxTokenPlanCreditFetcher { struct CreditSnapshot: Equatable { let balance: Double? let expiresAt: Date? + let groupIDs: Set } static func fetch( @@ -57,7 +58,8 @@ enum MiniMaxTokenPlanCreditFetcher { try self.validateBaseResponse(in: payload) return CreditSnapshot( balance: self.balance(from: payload), - expiresAt: self.earliestExpiry(from: payload)) + expiresAt: self.earliestExpiry(from: payload), + groupIDs: self.groupIDs(from: payload)) } static func resolveCreditURL(region: MiniMaxAPIRegion, environment: [String: String]) throws -> URL { @@ -138,6 +140,20 @@ enum MiniMaxTokenPlanCreditFetcher { }.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) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift index bbe7aa2dd8..d3e5b8d640 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift @@ -1,6 +1,76 @@ 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 summary = try await MiniMaxUsageSummaryFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + enriched = enriched.withUsageSummary(summary) + receivedWebData = 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 usage summary unavailable: \(error.localizedDescription)") + } + + do { + let credit = try await MiniMaxTokenPlanCreditFetcher.fetch( + cookieHeader: context.cookie, + groupID: resolvedGroupID, + region: context.region, + environment: context.environment, + transport: context.transport) + if let resolvedGroupID, + !credit.groupIDs.isEmpty, + !credit.groupIDs.contains(resolvedGroupID) + { + accountMismatch = true + } else { + enriched = enriched.withPointsBalanceIfMissing(credit.balance, expiresAt: credit.expiresAt) + receivedWebData = true + } + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch MiniMaxUsageError.invalidCredentials { + rejectedCredentials = true + } catch { + Self.log.debug("MiniMax token plan credit unavailable: \(error.localizedDescription)") + } + + return WebEnrichmentAttempt( + snapshot: accountMismatch ? snapshot : enriched, + rejectedCredentials: rejectedCredentials, + receivedWebData: accountMismatch ? false : receivedWebData, + accountMismatch: accountMismatch) + } + static func attachingUsageSummaryIfAvailable( to snapshot: MiniMaxUsageSnapshot, context: WebFetchContext, diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index ef65c44d07..d04a5f4b0a 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -442,6 +442,8 @@ public struct MiniMaxUsageFetcher: Sendable { enrichedSnapshot = snapshot } + guard includeBillingHistory else { return enrichedSnapshot } + let summaryEnrichedSnapshot = try await self.attachingUsageSummaryIfAvailable( to: enrichedSnapshot, context: context, diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift index 98388c01f9..1503b34587 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -21,7 +21,8 @@ extension MiniMaxUsageSnapshot { pointsBalance: self.pointsBalance, pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, - subscriptionRenewsAt: self.subscriptionRenewsAt) + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) } func withSubscriptionMetadata(_ metadata: MiniMaxSubscriptionMetadata) -> MiniMaxUsageSnapshot { @@ -40,7 +41,8 @@ extension MiniMaxUsageSnapshot { pointsBalance: self.pointsBalance, pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: metadata.subscriptionExpiresAt ?? self.subscriptionExpiresAt, - subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt) + subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt, + webSessionState: self.webSessionState) } func withUsageSummary(_ usageSummary: MiniMaxUsageSummary?) -> MiniMaxUsageSnapshot { @@ -59,7 +61,8 @@ extension MiniMaxUsageSnapshot { pointsBalance: self.pointsBalance, pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, - subscriptionRenewsAt: self.subscriptionRenewsAt) + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) } func withPointsBalanceIfMissing(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot { @@ -85,7 +88,8 @@ extension MiniMaxUsageSnapshot { pointsBalance: resolvedBalance, pointsBalanceExpiresAt: resolvedExpiry, subscriptionExpiresAt: self.subscriptionExpiresAt, - subscriptionRenewsAt: self.subscriptionRenewsAt) + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 9a56fae6fc..943d2a6a32 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -1,5 +1,19 @@ import Foundation +public enum MiniMaxWebSessionState: Sendable, Equatable { + case notChecked + case unavailable(reason: MiniMaxWebSessionUnavailableReason) + case expired + case accountMismatch + case valid(sourceLabel: String) +} + +public enum MiniMaxWebSessionUnavailableReason: Sendable, Equatable { + case noBrowserSession + case keychainAccessDisabled + case endpointsUnavailable +} + public struct MiniMaxUsageSnapshot: Sendable { public let planName: String? public let availablePrompts: Int? @@ -16,6 +30,7 @@ public struct MiniMaxUsageSnapshot: Sendable { public let pointsBalanceExpiresAt: Date? public let subscriptionExpiresAt: Date? public let subscriptionRenewsAt: Date? + public let webSessionState: MiniMaxWebSessionState public var primaryService: MiniMaxServiceUsage? { self.orderedQuotaServices.first @@ -81,7 +96,8 @@ public struct MiniMaxUsageSnapshot: Sendable { pointsBalance: Double? = nil, pointsBalanceExpiresAt: Date? = nil, subscriptionExpiresAt: Date? = nil, - subscriptionRenewsAt: Date? = nil) + subscriptionRenewsAt: Date? = nil, + webSessionState: MiniMaxWebSessionState = .notChecked) { self.planName = planName self.availablePrompts = availablePrompts @@ -98,6 +114,7 @@ public struct MiniMaxUsageSnapshot: Sendable { self.pointsBalanceExpiresAt = pointsBalanceExpiresAt self.subscriptionExpiresAt = subscriptionExpiresAt self.subscriptionRenewsAt = subscriptionRenewsAt + self.webSessionState = webSessionState } public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { @@ -116,7 +133,28 @@ public struct MiniMaxUsageSnapshot: Sendable { pointsBalance: self.pointsBalance, pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, - subscriptionRenewsAt: self.subscriptionRenewsAt) + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) + } + + func withWebSessionState(_ state: MiniMaxWebSessionState) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + usageSummary: self.usageSummary, + pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: self.pointsBalanceExpiresAt, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: state) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift index d4de0c6230..1c1aaa66e5 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift @@ -5,11 +5,12 @@ enum MiniMaxWebEnrichmentResolver { 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: [Candidate] = [] - candidates.append(contentsOf: self.explicitCandidates(context: context)) + var candidates = self.explicitCandidates(context: context) #if os(macOS) if let session = MiniMaxDesktopCookieImporter.importSession(), @@ -21,39 +22,24 @@ enum MiniMaxWebEnrichmentResolver { sourceLabel: session.sourceLabel, browserDetection: context.browserDetection), sourceLabel: session.sourceLabel, - shouldCache: true)) - } - 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)) - } - 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)) - } + shouldCache: true, + isCached: false)) } + candidates.append(contentsOf: self.cachedAndBrowserCandidates(context: context)) + #endif + return self.deduplicated(candidates) + } + + /// API-token enrichment: explicit cookies, validated cache, and user-initiated browser import only. + static func apiEnrichmentCandidates(context: ProviderFetchContext) -> [Candidate] { + var candidates = self.explicitCandidates(context: context) + #if os(macOS) + candidates.append(contentsOf: self.cachedAndBrowserCandidates(context: context)) #endif return self.deduplicated(candidates) } - /// Cookies from explicit user configuration only. API-token enrichment must not attach - /// cached or imported browser/desktop sessions that may belong to a different MiniMax account. + /// Cookies from explicit user configuration only. static func explicitCandidates(context: ProviderFetchContext) -> [Candidate] { var candidates: [Candidate] = [] if let settings = context.settings?.minimax, @@ -61,14 +47,16 @@ enum MiniMaxWebEnrichmentResolver { !header.isEmpty, let override = MiniMaxCookieHeader.override(from: header) { - candidates.append(Candidate(override: override, sourceLabel: "settings", shouldCache: false)) + 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)) + candidates.append(Candidate( + override: override, sourceLabel: "environment", shouldCache: false, isCached: false)) } - return self.deduplicated(candidates) + return candidates } static func cacheValidated(_ candidate: Candidate) { @@ -86,8 +74,39 @@ enum MiniMaxWebEnrichmentResolver { #if os(macOS) static func allowsBrowserCookieImport(context: ProviderFetchContext) -> Bool { - context.runtime == .app && - (ProviderInteractionContext.current == .userInitiated || context.includeOptionalUsage) + context.runtime == .app && ProviderInteractionContext.current == .userInitiated + } + + 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( diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index ceb6b6ef88..526d83b58e 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1096,6 +1096,87 @@ struct MiniMaxUsageParserTests { #expect(requests.contains { $0.url?.path == "/backend/account/token_plan/usage_summary" }) } + @Test + func `web enrichment reports expired credentials without discarding API quota`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + let quota = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + let context = MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=expired", + authorizationToken: nil, + region: .global, + environment: [:], + transport: transport) + + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: quota, + context: context, + groupID: nil) + + #expect(attempt.rejectedCredentials) + #expect(!attempt.receivedWebData) + #expect(!attempt.accountMismatch) + #expect(attempt.snapshot.currentPrompts == 25) + #expect(attempt.snapshot.usageSummary == nil) + #expect(attempt.snapshot.pointsBalance == nil) + #expect(await transport.requests().count == 2) + } + + @Test + func `web enrichment rejects credit response for a different group`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("usage_summary") { + return Self.httpResponse(url: url, body: "{}", statusCode: 503, contentType: "application/json") + } + let body = """ + { + "remaining_credits": 20000, + "credit_packages_details": [{"group_id":"different-group"}], + "base_resp":{"status_code":0} + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + let quota = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + let context = MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=valid", + authorizationToken: nil, + region: .global, + environment: [:], + transport: transport) + + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: quota, + context: context, + groupID: "expected-group") + + #expect(attempt.accountMismatch) + #expect(!attempt.receivedWebData) + #expect(attempt.snapshot.pointsBalance == nil) + #expect(attempt.snapshot.currentPrompts == 25) + } + + } + @Test func `web usage fetch keeps quota when billing history is forbidden`() async throws { let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index 4dadbe9e6d..6b1073804a 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -10,6 +10,7 @@ struct MiniMaxTokenPlanCreditTests { 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 diff --git a/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift index 3c362fe0c9..a77e6cf663 100644 --- a/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift +++ b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift @@ -36,18 +36,9 @@ struct MiniMaxWebEnrichmentResolverTests { } @Test - func `browser import gate allows optional usage in app background refresh`() { + 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)) - } - } - - @Test - func `browser import gate requires user initiated refresh when optional usage is disabled`() { - let context = self.makeContext(runtime: .app, includeOptionalUsage: false) - ProviderInteractionContext.$current.withValue(.background) { #expect(!MiniMaxWebEnrichmentResolver.allowsBrowserCookieImport(context: context)) } @@ -67,10 +58,10 @@ struct MiniMaxWebEnrichmentResolverTests { let context = self.makeContext(runtime: .app) let explicit = MiniMaxWebEnrichmentResolver.explicitCandidates(context: context) - let all = MiniMaxWebEnrichmentResolver.candidates(context: context) + let api = MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates(context: context) #expect(!explicit.contains { $0.sourceLabel == "Chrome" }) - #expect(all.contains { $0.sourceLabel == "Chrome" }) + #expect(api.contains { $0.isCached && $0.sourceLabel == "Chrome" }) } private func makeContext( diff --git a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift index 25d76a1364..761fbc39a3 100644 --- a/Tests/CodexBarTests/SakanaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/SakanaUsageFetcherTests.swift @@ -194,7 +194,7 @@ struct SakanaUsageFetcherTests { } private actor SakanaScriptedTransport: ProviderHTTPTransport { - struct CapturedRequest: Sendable { + struct CapturedRequest { let url: String? let method: String? let cookie: String? diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift index 9a4afe5b7b..1884083dcc 100644 --- a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -5,6 +5,47 @@ import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `merged menu prewarms MiniMax content before first switch`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableProvidersForInstantOpenTesting([.codex, .minimax], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + .toUsageSnapshot(), + provider: .minimax) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.prewarmMiniMaxMergedMenuContent(in: menu) + + let cached = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.minimax)] + #expect(cached?.items.contains { $0.representedObject as? String == "menuCard" } == true) + } + @Test func `opening fresh menu does not schedule deferred refresh`() async { self.disableMenuCardsForTesting() diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj index 819a09961c..19796b3502 100644 --- a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ /* Begin PBXFileReference section */ 02F15F392AF9D502F0503F8F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 1AFF48072AC06EBE060E3AFE /* CombinedBurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedBurnDownWidgetViews.swift; sourceTree = ""; }; + 44CDB462FCE6D1105E6594FE /* CodexBar-minimax-runtime */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "CodexBar-minimax-runtime"; path = ..; sourceTree = SOURCE_ROOT; }; 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetBundle.swift; sourceTree = ""; }; 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetProvider.swift; sourceTree = ""; }; 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetViews.swift; sourceTree = ""; }; @@ -28,7 +29,6 @@ B99BBE05D3817388F9649AD0 /* BurnDownWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetProvider.swift; sourceTree = ""; }; E430B27E4F28973A5E77EA3F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexBarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - EFBE36CB6481E7133E2A5CF3 /* CodexBar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CodexBar; path = ..; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -48,7 +48,7 @@ 4FAD1E2FCD6C4AC65D308ABC /* Packages */ = { isa = PBXGroup; children = ( - EFBE36CB6481E7133E2A5CF3 /* CodexBar */, + 44CDB462FCE6D1105E6594FE /* CodexBar-minimax-runtime */, ); name = Packages; sourceTree = ""; From 21857510e4fa45663d6f0fc7ddcfcbeee79d15a6 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao Date: Fri, 3 Jul 2026 13:55:49 +0800 Subject: [PATCH 28/53] Harden MiniMax web session recovery and menu switch prewarm. Rebase onto latest PR fixes, add expiry UX with validated cache reuse on API enrichment, and prebuild MiniMax merged-menu content after open to cut first-switch latency. Co-authored-by: Cursor --- .../Resources/ar.lproj/Localizable.strings | 8 +++ .../Resources/fa.lproj/Localizable.strings | 8 +++ .../Resources/th.lproj/Localizable.strings | 8 +++ .../CodexBarTests/MiniMaxProviderTests.swift | 2 - .../StatusMenuInstantOpenTests.swift | 55 ------------------- .../StatusMenuMiniMaxPrewarmTests.swift | 48 ++++++++++++++++ Tests/CodexBarTests/StatusMenuTests.swift | 14 +++++ docs/minimax.md | 2 +- 8 files changed, 87 insertions(+), 58 deletions(-) create mode 100644 Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index b8c95c6164..bd1bb4b4bb 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -781,6 +781,14 @@ "Top method" = "الطريقة العليا"; "30d cash" = "30d نقدا"; "30d billing history from MiniMax web session" = "30d تاريخ الفوترة من جلسة الويب MiniMax"; +"MiniMax web session expired" = "انتهت صلاحية جلسة الويب MiniMax"; +"MiniMax web session account does not match" = "جلسة الويب MiniMax لا تطابق حساب API"; +"No MiniMax browser session found" = "لم يتم العثور على جلسة متصفح MiniMax"; +"MiniMax web data endpoints are unavailable" = "نقاط نهاية بيانات الويب MiniMax غير متاحة"; +"MiniMax web session unavailable" = "جلسة الويب MiniMax غير متاحة"; +"Re-import MiniMax session from browser" = "إعادة استيراد جلسة MiniMax من المتصفح"; +"Open MiniMax login page" = "فتح صفحة تسجيل الدخول MiniMax"; +"MiniMax web session: %@" = "جلسة الويب MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS قد تتأخر فوترة Cost Explorer."; "Rate limit: %d / %@" = "الحد الأقصى للسعر: %d / %@"; "Key remaining" = "المفتاح المتبقي"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index cead0c96ce..0fee135e39 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -781,6 +781,14 @@ "Top method" = "روش تاپ"; "30d cash" = "30d پول نقد"; "30d billing history from MiniMax web session" = "تاریخچه صورتحساب 30d از جلسه وب MiniMax"; +"MiniMax web session expired" = "جلسه وب MiniMax منقضی شده است"; +"MiniMax web session account does not match" = "جلسه وب MiniMax با حساب API مطابقت ندارد"; +"No MiniMax browser session found" = "جلسه مرورگر MiniMax یافت نشد"; +"MiniMax web data endpoints are unavailable" = "نقاط پایانی داده وب MiniMax در دسترس نیستند"; +"MiniMax web session unavailable" = "جلسه وب MiniMax در دسترس نیست"; +"Re-import MiniMax session from browser" = "وارد کردن مجدد جلسه MiniMax از مرورگر"; +"Open MiniMax login page" = "باز کردن صفحه ورود MiniMax"; +"MiniMax web session: %@" = "جلسه وب MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS صورتحساب اکسپلورر هزینه ممکن است کند شود."; "Rate limit: %d / %@" = "محدودیت نرخ: %d / %@"; "Key remaining" = "کلید باقی مانده"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index c2363b1e19..5914f4f076 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -781,6 +781,14 @@ "Top method" = "วิธียอดนิยม"; "30d cash" = "30d เงินสด"; "30d billing history from MiniMax web session" = "30d ประวัติการเรียกเก็บเงินจากเซสชันเว็บ MiniMax"; +"MiniMax web session expired" = "เซสชันเว็บ MiniMax หมดอายุแล้ว"; +"MiniMax web session account does not match" = "เซสชันเว็บ MiniMax ไม่ตรงกับบัญชี API"; +"No MiniMax browser session found" = "ไม่พบเซสชันเบราว์เซอร์ MiniMax"; +"MiniMax web data endpoints are unavailable" = "เอ็นด์พอยต์ข้อมูลเว็บ MiniMax ไม่พร้อมใช้งาน"; +"MiniMax web session unavailable" = "เซสชันเว็บ MiniMax ไม่พร้อมใช้งาน"; +"Re-import MiniMax session from browser" = "นำเข้าเซสชัน MiniMax จากเบราว์เซอร์อีกครั้ง"; +"Open MiniMax login page" = "เปิดหน้าเข้าสู่ระบบ MiniMax"; +"MiniMax web session: %@" = "เซสชันเว็บ MiniMax: %@"; "AWS Cost Explorer billing can lag." = "AWS การเรียกเก็บเงิน Cost Explorer อาจล่าช้า"; "Rate limit: %d / %@" = "จํากัดอัตรา: %d / %@"; "Key remaining" = "กุญแจที่เหลืออยู่"; diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 526d83b58e..769ae95f60 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1175,8 +1175,6 @@ struct MiniMaxUsageParserTests { #expect(attempt.snapshot.currentPrompts == 25) } - } - @Test func `web usage fetch keeps quota when billing history is forbidden`() async throws { let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift index 1884083dcc..f1f47d9456 100644 --- a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -5,47 +5,6 @@ import Testing @testable import CodexBar extension StatusMenuTests { - @Test - func `merged menu prewarms MiniMax content before first switch`() { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true - self.enableProvidersForInstantOpenTesting([.codex, .minimax], settings: settings) - - let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) - store._setSnapshotForTesting( - MiniMaxUsageSnapshot( - planName: "Plus", - availablePrompts: 100, - currentPrompts: 25, - remainingPrompts: 75, - windowMinutes: 300, - usedPercent: 25, - resetsAt: nil, - updatedAt: Date()) - .toUsageSnapshot(), - provider: .minimax) - let controller = StatusItemController( - store: store, - settings: settings, - account: UsageFetcher().loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - defer { controller.releaseStatusItemsForTesting() } - - let menu = controller.makeMenu() - controller.mergedMenu = menu - controller.statusItem.menu = menu - controller.populateMenu(menu, provider: nil) - controller.prewarmMiniMaxMergedMenuContent(in: menu) - - let cached = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.minimax)] - #expect(cached?.items.contains { $0.representedObject as? String == "menuCard" } == true) - } - @Test func `opening fresh menu does not schedule deferred refresh`() async { self.disableMenuCardsForTesting() @@ -1550,20 +1509,6 @@ extension StatusMenuTests { accountOrganization: nil, loginMethod: "ChatGPT")) } - - private func enableProvidersForInstantOpenTesting( - _ enabledProviders: Set, - settings: SettingsStore) - { - let registry = ProviderRegistry.shared - for provider in UsageProvider.allCases { - guard let metadata = registry.metadata[provider] else { continue } - settings.setProviderEnabled( - provider: provider, - metadata: metadata, - enabled: enabledProviders.contains(provider)) - } - } } private struct InstantOpenProviderFetchStrategy: ProviderFetchStrategy { diff --git a/Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift b/Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift new file mode 100644 index 0000000000..a45ad1fd16 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuMiniMaxPrewarmTests.swift @@ -0,0 +1,48 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `merged menu prewarms MiniMax content before first switch`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableProvidersForInstantOpenTesting([.codex, .minimax], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + .toUsageSnapshot(), + provider: .minimax) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.prewarmMiniMaxMergedMenuContent(in: menu) + + let cached = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.minimax)] + #expect(cached?.items.contains { $0.representedObject as? String == "menuCard" } == true) + } +} diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3fa28f8e03..8799bd8d1c 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -69,6 +69,20 @@ struct StatusMenuTests { return store } + func enableProvidersForInstantOpenTesting( + _ enabledProviders: Set, + settings: SettingsStore) + { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) + } + } + private func switcherButtons(in menu: NSMenu) -> [NSButton] { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } return switcherView.subviews diff --git a/docs/minimax.md b/docs/minimax.md index f8aba0b515..0f0da12e93 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -66,7 +66,7 @@ falls back across the provider's supported web requests when needed. - 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** only attach optional recharge credits and usage-summary enrichment when an **explicit** web cookie is configured (manual settings cookie header or `MINIMAX_COOKIE` / `MINIMAX_COOKIE_HEADER`). The API path calls `MiniMaxWebEnrichmentResolver.explicitCandidates` and does not attach cached, browser-imported, or MiniMax Agent desktop cookies, so a different account's browser session cannot be merged onto an API-key quota snapshot. +- **API-token refreshes** attach optional recharge credits and usage-summary enrichment from explicit cookies (manual settings cookie header or `MINIMAX_COOKIE` / `MINIMAX_COOKIE_HEADER`), a previously validated browser-session cache, or a user-initiated browser re-import. The API path calls `MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates` and does not attach MiniMax Agent desktop cookies or 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. From 669f6fd050a51174a894ab995f58b72c12155f59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 12:39:31 +0000 Subject: [PATCH 29/53] Fix PR 1821 CI: add MiniMax web session locale keys Complete it/tr/id/pl catalogs after the session recovery strings landed in en, and mirror the same keys in zh-Hant. Co-authored-by: Yuxin Qiao --- Sources/CodexBar/Resources/id.lproj/Localizable.strings | 8 ++++++++ Sources/CodexBar/Resources/it.lproj/Localizable.strings | 8 ++++++++ Sources/CodexBar/Resources/pl.lproj/Localizable.strings | 8 ++++++++ Sources/CodexBar/Resources/tr.lproj/Localizable.strings | 8 ++++++++ .../CodexBar/Resources/zh-Hant.lproj/Localizable.strings | 8 ++++++++ 5 files changed, 40 insertions(+) diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index c730ae7607..bb4c764ce9 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -783,6 +783,14 @@ "Top method" = "Metode teratas"; "30d cash" = "Kas 30 hari"; "30d billing history from MiniMax web session" = "Riwayat tagihan 30 hari dari sesi web MiniMax"; +"MiniMax web session expired" = "Sesi web MiniMax kedaluwarsa"; +"MiniMax web session account does not match" = "Sesi web MiniMax tidak cocok dengan akun API"; +"No MiniMax browser session found" = "Tidak ada sesi browser MiniMax ditemukan"; +"MiniMax web data endpoints are unavailable" = "Endpoint data web MiniMax tidak tersedia"; +"MiniMax web session unavailable" = "Sesi web MiniMax tidak tersedia"; +"Re-import MiniMax session from browser" = "Impor ulang sesi MiniMax dari browser"; +"Open MiniMax login page" = "Buka halaman login MiniMax"; +"MiniMax web session: %@" = "Sesi web MiniMax: %@"; "AWS Cost Explorer billing can lag." = "Tagihan AWS Cost Explorer dapat tertunda."; "Rate limit: %d / %@" = "Batas rate: %d / %@"; "Key remaining" = "Sisa kunci"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 0067cff80b..0ab664f8bf 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -783,6 +783,14 @@ "Top method" = "Metodo principale"; "30d cash" = "Spesa 30 g"; "30d billing history from MiniMax web session" = "Cronologia di fatturazione degli ultimi 30 giorni dalla sessione web MiniMax"; +"MiniMax web session expired" = "Sessione web MiniMax scaduta"; +"MiniMax web session account does not match" = "La sessione web MiniMax non corrisponde all'account API"; +"No MiniMax browser session found" = "Nessuna sessione browser MiniMax trovata"; +"MiniMax web data endpoints are unavailable" = "Gli endpoint dati web MiniMax non sono disponibili"; +"MiniMax web session unavailable" = "Sessione web MiniMax non disponibile"; +"Re-import MiniMax session from browser" = "Reimporta la sessione MiniMax dal browser"; +"Open MiniMax login page" = "Apri la pagina di accesso MiniMax"; +"MiniMax web session: %@" = "Sessione web MiniMax: %@"; "AWS Cost Explorer billing can lag." = "La fatturazione di AWS Cost Explorer può avere ritardi."; "Rate limit: %d / %@" = "Limite di richiesta: %d / %@"; "Key remaining" = "Residuo chiave"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index b42fadcc8c..f4d9b2ef93 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -783,6 +783,14 @@ "Top method" = "Najlepsza metoda"; "30d cash" = "Gotówka 30 dni"; "30d billing history from MiniMax web session" = "30-dniowa historia rozliczeń z sesji webowej MiniMax"; +"MiniMax web session expired" = "Sesja webowa MiniMax wygasła"; +"MiniMax web session account does not match" = "Sesja webowa MiniMax nie pasuje do konta API"; +"No MiniMax browser session found" = "Nie znaleziono sesji przeglądarki MiniMax"; +"MiniMax web data endpoints are unavailable" = "Punkty końcowe danych webowych MiniMax są niedostępne"; +"MiniMax web session unavailable" = "Sesja webowa MiniMax jest niedostępna"; +"Re-import MiniMax session from browser" = "Ponownie zaimportuj sesję MiniMax z przeglądarki"; +"Open MiniMax login page" = "Otwórz stronę logowania MiniMax"; +"MiniMax web session: %@" = "Sesja webowa MiniMax: %@"; "AWS Cost Explorer billing can lag." = "Dane rozliczeń AWS Cost Explorer mogą być opóźnione."; "Rate limit: %d / %@" = "Limit żądań: %d / %@"; "Key remaining" = "Pozostało klucza"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index ee82ec01e4..3ccb5945a5 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -779,6 +779,14 @@ "Top method" = "En çok kullanılan yöntem"; "30d cash" = "30 günlük nakit"; "30d billing history from MiniMax web session" = "MiniMax web oturumundan 30 günlük faturalandırma geçmişi"; +"MiniMax web session expired" = "MiniMax web oturumu süresi doldu"; +"MiniMax web session account does not match" = "MiniMax web oturumu API hesabıyla eşleşmiyor"; +"No MiniMax browser session found" = "MiniMax tarayıcı oturumu bulunamadı"; +"MiniMax web data endpoints are unavailable" = "MiniMax web veri uç noktaları kullanılamıyor"; +"MiniMax web session unavailable" = "MiniMax web oturumu kullanılamıyor"; +"Re-import MiniMax session from browser" = "MiniMax oturumunu tarayıcıdan yeniden içe aktar"; +"Open MiniMax login page" = "MiniMax giriş sayfasını aç"; +"MiniMax web session: %@" = "MiniMax web oturumu: %@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer faturalandırması gecikmeli olabilir."; "Rate limit: %d / %@" = "Hız limiti: %d / %@"; "Key remaining" = "Anahtar kalan"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index e8cf7bc177..67f5da8806 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -791,6 +791,14 @@ "Top method" = "主要方法"; "30d cash" = "近 30 天費用"; "30d billing history from MiniMax web session" = "來自 MiniMax 網頁工作階段的 30 天帳單記錄"; +"MiniMax web session expired" = "MiniMax 網頁工作階段已過期"; +"MiniMax web session account does not match" = "MiniMax 網頁工作階段與 API 帳號不相符"; +"No MiniMax browser session found" = "找不到 MiniMax 瀏覽器工作階段"; +"MiniMax web data endpoints are unavailable" = "MiniMax 網頁資料端點無法使用"; +"MiniMax web session unavailable" = "MiniMax 網頁工作階段無法使用"; +"Re-import MiniMax session from browser" = "從瀏覽器重新匯入 MiniMax 工作階段"; +"Open MiniMax login page" = "開啟 MiniMax 登入頁面"; +"MiniMax web session: %@" = "MiniMax 網頁工作階段:%@"; "AWS Cost Explorer billing can lag." = "AWS Cost Explorer 帳單資料可能延遲。"; "Rate limit: %d / %@" = "速率限制:%d / %@"; "Key remaining" = "金鑰剩餘額度"; From 143ac2e1931122d0a45fcbaaee0f6af00623d640 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:42:14 +0800 Subject: [PATCH 30/53] Optimize MiniMax menu switching --- .../StatusItemController+MemoryPressure.swift | 3 +- .../CodexBar/StatusItemController+Menu.swift | 4 +- ...StatusItemController+MenuSmartUpdate.swift | 58 ++++++++++++++++++- .../StatusItemController+MenuWidthCache.swift | 21 ++++++- ...ontroller+MergedSwitcherContentCache.swift | 18 +++--- ...tatusItemController+ProviderSwitcher.swift | 2 + Sources/CodexBar/StatusItemController.swift | 3 +- .../MemoryPressureCacheTrimTests.swift | 4 +- .../StatusMenuInstantOpenTests.swift | 41 +++++++++++++ 9 files changed, 140 insertions(+), 14 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+MemoryPressure.swift b/Sources/CodexBar/StatusItemController+MemoryPressure.swift index c4224b1091..530084746a 100644 --- a/Sources/CodexBar/StatusItemController+MemoryPressure.swift +++ b/Sources/CodexBar/StatusItemController+MemoryPressure.swift @@ -8,12 +8,13 @@ extension StatusItemController { } let summary = MemoryPressureCacheTrimSummary( menuCardHeights: self.menuCardHeightCache.count, - menuWidths: self.measuredStandardMenuWidthCache.count, + menuWidths: self.measuredStandardMenuWidthCache.count + self.mergedMenuWidthCache.count, mergedSwitcherSelections: mergedSwitcherSelectionCount, recycledMenuCardViews: self.menuCardViewRecyclePool.count) self.menuCardHeightCache.removeAll(keepingCapacity: false) self.measuredStandardMenuWidthCache.removeAll(keepingCapacity: false) + self.mergedMenuWidthCache.removeAll(keepingCapacity: false) self.mergedSwitcherContentCaches.removeAll(keepingCapacity: false) self.menuCardViewRecyclePool.removeAll(keepingCapacity: false) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index dcd939e973..45c6e8c0ec 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -168,8 +168,6 @@ extension StatusItemController { } self.clearMergedSwitcherContentCache(for: menu) - self.miniMaxMergedMenuPrewarmTask?.cancel() - self.miniMaxMergedMenuPrewarmTask = nil let wasTracked = self.openMenus.removeValue(forKey: key) != nil let menuTrackingEnded = wasTracked && self.openMenus.isEmpty if self.openMenus.isEmpty { @@ -225,6 +223,8 @@ extension StatusItemController { defer { if self.openMenus[ObjectIdentifier(menu)] != nil { self.scheduleMiniMaxMergedMenuPrewarmIfNeeded(menu) + } else { + self.prewarmMiniMaxMergedMenuContentIfNeeded(in: menu) } } diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift index ab63d06e56..59e784be3a 100644 --- a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -1,5 +1,6 @@ import AppKit import CodexBarCore +import QuartzCore import SwiftUI extension StatusItemController { @@ -20,6 +21,9 @@ extension StatusItemController { _ menu: NSMenu, context: MenuUpdateContext) { + let updateStartedAt = CACurrentMediaTime() + let requestedAt = self.providerSwitcherRebuildRequestedAt + self.providerSwitcherRebuildRequestedAt = nil self.performMenuMutationWithoutAnimation { let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) if let switcherView = menu.items.first?.view as? ProviderSwitcherView { @@ -45,13 +49,14 @@ extension StatusItemController { let outgoingCodexAccountDisplay = self.lastCodexAccountMenuDisplay let outgoingTokenAccountDisplay = self.lastTokenAccountMenuDisplay self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let replacementStartedAt = CACurrentMediaTime() let displacedItems = self.replaceMenuContentKeepingRowsVisible( menu, fromIndex: contentStartIndex, with: cachedItems) + let replacementEndedAt = CACurrentMediaTime() // Cached items may have changed refresh state while detached from a menu. self.updatePersistentRefreshItemsEnabled() - self.refreshMenuCardHeights(in: menu) self.cacheMergedSwitcherContent( displacedItems, in: menu, @@ -69,6 +74,14 @@ extension StatusItemController { contentStartIndex: contentStartIndex, menuWidth: context.menuWidth, contentVersion: self.menuSession.contentVersion) + self.logProviderSwitcherPerformanceIfSlow( + provider: context.provider, + cacheHit: true, + requestedAt: requestedAt, + updateStartedAt: updateStartedAt, + buildDuration: 0, + replacementDuration: replacementEndedAt - replacementStartedAt, + layoutDuration: 0) return } @@ -85,20 +98,63 @@ extension StatusItemController { preserveHighlightedItem: true) defer { self.clearMenuCardViewRecyclePool() } self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) + let buildStartedAt = CACurrentMediaTime() let scratch = NSMenu() scratch.autoenablesItems = false self.addSwitcherScopedMenuContent(into: scratch, captureMenu: menu, context: context) + let buildEndedAt = CACurrentMediaTime() + let replacementStartedAt = CACurrentMediaTime() self.reconcileMenuContent(menu, fromIndex: contentStartIndex, shapes: shapes, with: scratch) + let replacementEndedAt = CACurrentMediaTime() + let layoutStartedAt = CACurrentMediaTime() self.refreshMenuCardHeights(in: menu) + let layoutEndedAt = CACurrentMediaTime() self.cacheVisibleMergedSwitcherContent( in: menu, selection: context.switcherSelection, contentStartIndex: contentStartIndex, menuWidth: context.menuWidth, contentVersion: self.menuSession.contentVersion) + self.logProviderSwitcherPerformanceIfSlow( + provider: context.provider, + cacheHit: false, + requestedAt: requestedAt, + updateStartedAt: updateStartedAt, + buildDuration: buildEndedAt - buildStartedAt, + replacementDuration: replacementEndedAt - replacementStartedAt, + layoutDuration: layoutEndedAt - layoutStartedAt) } } + private func logProviderSwitcherPerformanceIfSlow( + provider: UsageProvider?, + cacheHit: Bool, + requestedAt: CFTimeInterval?, + updateStartedAt: CFTimeInterval, + buildDuration: CFTimeInterval, + replacementDuration: CFTimeInterval, + layoutDuration: CFTimeInterval) + { + let endedAt = CACurrentMediaTime() + let totalDuration = requestedAt.map { endedAt - $0 } ?? (endedAt - updateStartedAt) + guard totalDuration >= 0.008 else { return } + self.menuLogger.debug( + "provider switch performance", + metadata: [ + "provider": provider?.rawValue ?? "overview", + "cacheHit": cacheHit ? "1" : "0", + "queueMs": Self.formatMenuPerformanceDuration(updateStartedAt - (requestedAt ?? updateStartedAt)), + "buildMs": Self.formatMenuPerformanceDuration(buildDuration), + "replaceMs": Self.formatMenuPerformanceDuration(replacementDuration), + "layoutMs": Self.formatMenuPerformanceDuration(layoutDuration), + "totalMs": Self.formatMenuPerformanceDuration(totalDuration), + ]) + } + + private static func formatMenuPerformanceDuration(_ duration: CFTimeInterval) -> String { + String(format: "%.1f", duration * 1000) + } + /// Adds everything below the provider switcher (account switchers, card content, and /// actionable sections) to `target`, which may be a detached scratch menu; interaction /// closures always capture `captureMenu`, the live menu the rows will serve. diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index 8f18e5266a..141420edf2 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -9,6 +9,19 @@ extension StatusItemController { selectedProvider: UsageProvider?, descriptor: MenuDescriptor) -> CGFloat { + let mergedCacheKey: String? = if self.shouldMergeIcons, providers.count > 1 { + [ + "version=\(self.menuSession.contentVersion)", + "providers=\(providers.map(\.rawValue).joined(separator: ","))", + "locale=\(self.menuLocalizationSignature())", + ].joined(separator: "|") + } else { + nil + } + if let mergedCacheKey, let cached = self.mergedMenuWidthCache[mergedCacheKey] { + return cached + } + let sectionSets: [[MenuDescriptor.Section]] = if self.shouldMergeIcons, providers.count > 1 { providers.map { provider in if provider == selectedProvider { @@ -21,7 +34,13 @@ extension StatusItemController { } else { [descriptor.sections] } - return self.measuredMenuCardWidth(for: sectionSets) + let width = self.measuredMenuCardWidth(for: sectionSets) + if let mergedCacheKey { + // Only one content version is useful. Keeping stale versions would retain widths + // indefinitely while provider snapshots refresh in the background. + self.mergedMenuWidthCache = [mergedCacheKey: width] + } + return width } func measuredMenuCardWidth(for sectionSets: [[MenuDescriptor.Section]]) -> CGFloat { diff --git a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift index 2a1058e7cc..04c29624d3 100644 --- a/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift +++ b/Sources/CodexBar/StatusItemController+MergedSwitcherContentCache.swift @@ -32,8 +32,6 @@ struct MergedSwitcherContentCacheContext { } extension StatusItemController { - private static let miniMaxMergedMenuPrewarmDelay: Duration = .milliseconds(120) - func preservingMergedSwitcherContentCachesDuringInvalidation(_ body: () -> Void) { let previous = self.preservesMergedSwitcherContentCachesDuringInvalidation self.preservesMergedSwitcherContentCachesDuringInvalidation = true @@ -150,13 +148,14 @@ extension StatusItemController { self.lastMergedSwitcherSelection != .provider(.minimax) else { return } - self.miniMaxMergedMenuPrewarmTask?.cancel() - self.miniMaxMergedMenuPrewarmTask = Task { @MainActor [weak self, weak menu] in - try? await Task.sleep(for: Self.miniMaxMergedMenuPrewarmDelay) - guard !Task.isCancelled, let self, let menu else { return } + // An open NSMenu runs the main thread in event-tracking mode. A MainActor Task + // continuation can therefore starve until the menu closes, defeating the prewarm. + // Queue directly into that run-loop mode so the detached content is ready before + // the user's first switch to MiniMax. + ProviderSwitcherTrackingRunLoopScheduler.schedule { [weak self, weak menu] in + guard let self, let menu else { return } guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } self.prewarmMiniMaxMergedMenuContent(in: menu) - self.miniMaxMergedMenuPrewarmTask = nil } } @@ -216,4 +215,9 @@ extension StatusItemController { tokenAccountDisplay: tokenAccountDisplay, contentVersion: self.menuSession.contentVersion)) } + + func prewarmMiniMaxMergedMenuContentIfNeeded(in menu: NSMenu) { + guard self.lastMergedSwitcherSelection != .provider(.minimax) else { return } + self.prewarmMiniMaxMergedMenuContent(in: menu) + } } diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift index 24019b4bb0..05d588c6cb 100644 --- a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -1,5 +1,6 @@ import AppKit import CodexBarCore +import QuartzCore struct PendingProviderSwitcherRebuild { let menu: NSMenu @@ -324,6 +325,7 @@ extension StatusItemController { } func requestProviderSwitcherMenuRebuild(_ menu: NSMenu, provider: UsageProvider?) { + self.providerSwitcherRebuildRequestedAt = CACurrentMediaTime() guard self.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu) else { self.deferSwitcherMenuRebuildIfStillVisible(menu, provider: provider) return diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 2a0e67a946..51f5b5ff9b 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -129,8 +129,8 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin /// Persistent Refresh rows are weakly tracked so their enabled state can change during menu tracking. let persistentRefreshItems = NSHashTable.weakObjects() var menuCardHeightCache: [MenuCardHeightCacheKey: CGFloat] = [:] - var miniMaxMergedMenuPrewarmTask: Task? var measuredStandardMenuWidthCache: [String: CGFloat] = [:] + var mergedMenuWidthCache: [String: CGFloat] = [:] var lastMenuAdjunctReadinessSignature = "" var lastMenuAdjunctReadinessBaselineVersion = 0 var rootOpenHandledMenuObservationSignature: String? @@ -161,6 +161,7 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin var providerSwitcherShortcutMenuID: ObjectIdentifier? var providerSwitcherPointerInteractionMenuID: ObjectIdentifier? var pendingProviderSwitcherPointerRebuild: PendingProviderSwitcherRebuild? + var providerSwitcherRebuildRequestedAt: CFTimeInterval? var overviewScrollAccumulatedDelta: CGFloat = 0 var overviewScrollNavigationHandlerForTesting: ((OverviewScrollStep) -> Void)? var hasPreparedForAppShutdown = false diff --git a/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift index 1d908e6942..4e261f0fd4 100644 --- a/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift +++ b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift @@ -113,6 +113,7 @@ struct MemoryPressureCacheTrimTests { controller.menuCardHeightCache[key] = 42 controller.measuredStandardMenuWidthCache["width"] = 300 + controller.mergedMenuWidthCache["merged"] = 320 controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ .overview: entry, .provider(.codex): entry, @@ -122,11 +123,12 @@ struct MemoryPressureCacheTrimTests { let summary = controller.trimRebuildableCachesForMemoryPressure() #expect(summary.menuCardHeights == 1) - #expect(summary.menuWidths == 1) + #expect(summary.menuWidths == 2) #expect(summary.mergedSwitcherSelections == 2) #expect(summary.recycledMenuCardViews == 1) #expect(controller.menuCardHeightCache.isEmpty) #expect(controller.measuredStandardMenuWidthCache.isEmpty) + #expect(controller.mergedMenuWidthCache.isEmpty) #expect(controller.mergedSwitcherContentCaches.isEmpty) #expect(controller.menuCardViewRecyclePool.isEmpty) } diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift index f1f47d9456..ab28b38b86 100644 --- a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -5,6 +5,47 @@ import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `merged menu prewarms MiniMax content before first switch`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableProvidersForInstantOpenTesting([.codex, .minimax], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + .toUsageSnapshot(), + provider: .minimax) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + + let cached = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.minimax)] + #expect(cached?.items.contains { $0.representedObject as? String == "menuCard" } == true) + #expect(controller.mergedMenuWidthCache.count == 1) + } + @Test func `opening fresh menu does not schedule deferred refresh`() async { self.disableMenuCardsForTesting() From e25a28ff73f0d51b3e8b58af783a35016bac8410 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 17:18:46 +0000 Subject: [PATCH 31/53] Fix PR 1821 CI lint and duplicate MiniMax prewarm test - Remove duplicate merged-menu prewarm test from StatusMenuInstantOpenTests - Bundle provider-switcher perf timings into a struct for SwiftLint - Extract menuUpdateContext helper to shorten populateMenu body Co-authored-by: Yuxin Qiao --- .../CodexBar/StatusItemController+Menu.swift | 12 ++-- ...StatusItemController+MenuSmartUpdate.swift | 64 +++++++++++++------ .../StatusMenuInstantOpenTests.swift | 41 ------------ 3 files changed, 50 insertions(+), 67 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 45c6e8c0ec..77bf87cd1b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -311,10 +311,10 @@ extension StatusItemController { if canSmartUpdate { self.updateMenuContentPreservingSwitcher( menu, - context: MenuUpdateContext( - provider: selectedProvider, + context: self.menuUpdateContext( + selectedProvider: selectedProvider, currentProvider: currentProvider, - switcherSelection: switcherSelection ?? .provider(currentProvider), + switcherSelection: switcherSelection, menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, @@ -344,10 +344,10 @@ extension StatusItemController { if canPreserveProviderSwitcher { self.updateMenuContentPreservingSwitcher( menu, - context: MenuUpdateContext( - provider: selectedProvider, + context: self.menuUpdateContext( + selectedProvider: selectedProvider, currentProvider: currentProvider, - switcherSelection: switcherSelection ?? .provider(currentProvider), + switcherSelection: switcherSelection, menuWidth: menuWidth, codexAccountDisplay: codexAccountDisplay, tokenAccountDisplay: tokenAccountDisplay, diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift index 59e784be3a..4657b2b0f0 100644 --- a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -16,6 +16,27 @@ extension StatusItemController { let descriptor: MenuDescriptor } + func menuUpdateContext( + selectedProvider: UsageProvider?, + currentProvider: UsageProvider, + switcherSelection: ProviderSwitcherSelection?, + menuWidth: CGFloat, + codexAccountDisplay: CodexAccountMenuDisplay?, + tokenAccountDisplay: TokenAccountMenuDisplay?, + openAIContext: OpenAIWebContext, + descriptor: MenuDescriptor) -> MenuUpdateContext + { + MenuUpdateContext( + provider: selectedProvider, + currentProvider: currentProvider, + switcherSelection: switcherSelection ?? .provider(currentProvider), + menuWidth: menuWidth, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: openAIContext, + descriptor: descriptor) + } + /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. func updateMenuContentPreservingSwitcher( _ menu: NSMenu, @@ -74,14 +95,14 @@ extension StatusItemController { contentStartIndex: contentStartIndex, menuWidth: context.menuWidth, contentVersion: self.menuSession.contentVersion) - self.logProviderSwitcherPerformanceIfSlow( + self.logProviderSwitcherPerformanceIfSlow(ProviderSwitcherPerformanceSample( provider: context.provider, cacheHit: true, requestedAt: requestedAt, updateStartedAt: updateStartedAt, buildDuration: 0, replacementDuration: replacementEndedAt - replacementStartedAt, - layoutDuration: 0) + layoutDuration: 0)) return } @@ -115,38 +136,41 @@ extension StatusItemController { contentStartIndex: contentStartIndex, menuWidth: context.menuWidth, contentVersion: self.menuSession.contentVersion) - self.logProviderSwitcherPerformanceIfSlow( + self.logProviderSwitcherPerformanceIfSlow(ProviderSwitcherPerformanceSample( provider: context.provider, cacheHit: false, requestedAt: requestedAt, updateStartedAt: updateStartedAt, buildDuration: buildEndedAt - buildStartedAt, replacementDuration: replacementEndedAt - replacementStartedAt, - layoutDuration: layoutEndedAt - layoutStartedAt) + layoutDuration: layoutEndedAt - layoutStartedAt)) } } - private func logProviderSwitcherPerformanceIfSlow( - provider: UsageProvider?, - cacheHit: Bool, - requestedAt: CFTimeInterval?, - updateStartedAt: CFTimeInterval, - buildDuration: CFTimeInterval, - replacementDuration: CFTimeInterval, - layoutDuration: CFTimeInterval) - { + private struct ProviderSwitcherPerformanceSample { + let provider: UsageProvider? + let cacheHit: Bool + let requestedAt: CFTimeInterval? + let updateStartedAt: CFTimeInterval + let buildDuration: CFTimeInterval + let replacementDuration: CFTimeInterval + let layoutDuration: CFTimeInterval + } + + private func logProviderSwitcherPerformanceIfSlow(_ sample: ProviderSwitcherPerformanceSample) { let endedAt = CACurrentMediaTime() - let totalDuration = requestedAt.map { endedAt - $0 } ?? (endedAt - updateStartedAt) + let totalDuration = sample.requestedAt.map { endedAt - $0 } ?? (endedAt - sample.updateStartedAt) guard totalDuration >= 0.008 else { return } self.menuLogger.debug( "provider switch performance", metadata: [ - "provider": provider?.rawValue ?? "overview", - "cacheHit": cacheHit ? "1" : "0", - "queueMs": Self.formatMenuPerformanceDuration(updateStartedAt - (requestedAt ?? updateStartedAt)), - "buildMs": Self.formatMenuPerformanceDuration(buildDuration), - "replaceMs": Self.formatMenuPerformanceDuration(replacementDuration), - "layoutMs": Self.formatMenuPerformanceDuration(layoutDuration), + "provider": sample.provider?.rawValue ?? "overview", + "cacheHit": sample.cacheHit ? "1" : "0", + "queueMs": Self.formatMenuPerformanceDuration( + sample.updateStartedAt - (sample.requestedAt ?? sample.updateStartedAt)), + "buildMs": Self.formatMenuPerformanceDuration(sample.buildDuration), + "replaceMs": Self.formatMenuPerformanceDuration(sample.replacementDuration), + "layoutMs": Self.formatMenuPerformanceDuration(sample.layoutDuration), "totalMs": Self.formatMenuPerformanceDuration(totalDuration), ]) } diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift index ab28b38b86..f1f47d9456 100644 --- a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -5,47 +5,6 @@ import Testing @testable import CodexBar extension StatusMenuTests { - @Test - func `merged menu prewarms MiniMax content before first switch`() { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true - self.enableProvidersForInstantOpenTesting([.codex, .minimax], settings: settings) - - let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) - store._setSnapshotForTesting( - MiniMaxUsageSnapshot( - planName: "Plus", - availablePrompts: 100, - currentPrompts: 25, - remainingPrompts: 75, - windowMinutes: 300, - usedPercent: 25, - resetsAt: nil, - updatedAt: Date()) - .toUsageSnapshot(), - provider: .minimax) - let controller = StatusItemController( - store: store, - settings: settings, - account: UsageFetcher().loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - defer { controller.releaseStatusItemsForTesting() } - - let menu = controller.makeMenu() - controller.mergedMenu = menu - controller.statusItem.menu = menu - controller.populateMenu(menu, provider: nil) - - let cached = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.minimax)] - #expect(cached?.items.contains { $0.representedObject as? String == "menuCard" } == true) - #expect(controller.mergedMenuWidthCache.count == 1) - } - @Test func `opening fresh menu does not schedule deferred refresh`() async { self.disableMenuCardsForTesting() From 8119ca13e71b259e40447f5a7e58a2ece1e1e30d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 17:22:45 +0000 Subject: [PATCH 32/53] Extract menu populate snapshot helpers for SwiftLint limits Co-authored-by: Yuxin Qiao --- .../CodexBar/StatusItemController+Menu.swift | 163 +++++++++--------- .../StatusItemController+MenuContexts.swift | 47 +++++ ...StatusItemController+MenuSmartUpdate.swift | 21 --- 3 files changed, 126 insertions(+), 105 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 77bf87cd1b..a8c43df7f1 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -228,6 +228,51 @@ extension StatusItemController { } } + let snapshot = self.makeMenuPopulateSnapshot(menu: menu, provider: provider) + let gate = self.mergedMenuSmartUpdateGate(menu: menu, snapshot: snapshot) + + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil { + self.menuLogger.debug( + "populateMenu(open): provider=\(String(describing: provider)) " + + "display=\(snapshot.enabledProviders.map(\.rawValue)) " + + "available=\(self.store.enabledProviders().map(\.rawValue)) " + + "selection=\(String(describing: snapshot.switcherSelection)) " + + "last=\(String(describing: self.lastMergedSwitcherSelection)) " + + "smart=\(gate.canSmartUpdate)") + } + #endif + + if gate.canSmartUpdate { + self.updateMenuContentPreservingSwitcher(menu, context: snapshot.menuUpdateContext) + return + } + + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil { + self.menuLogger.debug( + "populateMenu(open): preserveSwitcher=\(gate.canPreserveProviderSwitcher) " + + "widthMatch=\(gate.providerSwitcherWidthMatches)") + } + #endif + + if gate.canPreserveProviderSwitcher { + self.updateMenuContentPreservingSwitcher(menu, context: snapshot.menuUpdateContext) + return + } + + #if DEBUG + if self.openMenus[ObjectIdentifier(menu)] != nil, menu.items.first?.view is ProviderSwitcherView { + self.menuLogger.debug("populateMenu(open): rebuilding whole menu and replacing provider switcher") + } + #endif + self.rebuildMenuContent(menu, context: snapshot.menuRebuildContext) + } + + private func makeMenuPopulateSnapshot( + menu: NSMenu, + provider: UsageProvider?) -> MenuPopulateSnapshot + { let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -261,30 +306,46 @@ extension StatusItemController { for: enabledProviders, selectedProvider: selectedProvider, descriptor: descriptor) + return MenuPopulateSnapshot( + enabledProviders: enabledProviders, + includesOverview: includesOverview, + switcherSelection: switcherSelection, + selectedProvider: selectedProvider, + currentProvider: currentProvider, + codexAccountDisplay: codexAccountDisplay, + tokenAccountDisplay: tokenAccountDisplay, + openAIContext: openAIContext, + descriptor: descriptor, + menuWidth: menuWidth) + } + private func mergedMenuSmartUpdateGate( + menu: NSMenu, + snapshot: MenuPopulateSnapshot) -> MergedMenuSmartUpdateGate + { let hasTokenSwitcher = menu.items.contains { $0.view is TokenAccountSwitcherView } let hasCodexSwitcher = menu.items.contains { $0.view is CodexAccountSwitcherView } - let switcherProvidersMatch = enabledProviders == self.lastSwitcherProviders + let switcherProvidersMatch = snapshot.enabledProviders == self.lastSwitcherProviders let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed - let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection - let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview + let switcherSelectionMatches = snapshot.switcherSelection == self.lastMergedSwitcherSelection + let switcherOverviewAvailabilityMatches = snapshot.includesOverview == self.lastSwitcherIncludesOverview let menuLocalizationMatches = self.menuLocalizationSignature() == self.lastMenuLocalizationSignature - let tokenSwitcherCompatible = tokenAccountDisplay == self.lastTokenAccountMenuDisplay && - ((tokenAccountDisplay?.showSwitcher == true && hasTokenSwitcher) || - (tokenAccountDisplay?.showSwitcher != true && !hasTokenSwitcher)) - let codexSwitcherCompatible = codexAccountDisplay == self.lastCodexAccountMenuDisplay && - ((codexAccountDisplay?.showSwitcher == true && hasCodexSwitcher) || - (codexAccountDisplay?.showSwitcher != true && !hasCodexSwitcher)) + let tokenSwitcherCompatible = snapshot.tokenAccountDisplay == self.lastTokenAccountMenuDisplay && + ((snapshot.tokenAccountDisplay?.showSwitcher == true && hasTokenSwitcher) || + (snapshot.tokenAccountDisplay?.showSwitcher != true && !hasTokenSwitcher)) + let codexSwitcherCompatible = snapshot.codexAccountDisplay == self.lastCodexAccountMenuDisplay && + ((snapshot.codexAccountDisplay?.showSwitcher == true && hasCodexSwitcher) || + (snapshot.codexAccountDisplay?.showSwitcher != true && !hasCodexSwitcher)) let reusableRowWidthsMatch = self.reusableFixedWidthRows(in: menu).allSatisfy { item in guard let view = item.view else { return false } - return abs(view.frame.width - menuWidth) <= 0.5 + return abs(view.frame.width - snapshot.menuWidth) <= 0.5 } let providerSwitcherWidthMatches = (menu.items.first?.view as? ProviderSwitcherView).map { view in - abs(view.frame.width - menuWidth) <= 0.5 + abs(view.frame.width - snapshot.menuWidth) <= 0.5 } ?? false let canSmartUpdate = self.shouldMergeIcons && - enabledProviders.count > 1 && - !isOverviewSelected && + snapshot.enabledProviders.count > 1 && + !snapshot.isOverviewSelected && switcherProvidersMatch && switcherUsageBarsShowUsedMatch && switcherSelectionMatches && @@ -295,36 +356,8 @@ extension StatusItemController { reusableRowWidthsMatch && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView - - #if DEBUG - if self.openMenus[ObjectIdentifier(menu)] != nil { - self.menuLogger.debug( - "populateMenu(open): provider=\(String(describing: provider)) " + - "display=\(enabledProviders.map(\.rawValue)) " + - "available=\(self.store.enabledProviders().map(\.rawValue)) " + - "selection=\(String(describing: switcherSelection)) " + - "last=\(String(describing: self.lastMergedSwitcherSelection)) " + - "smart=\(canSmartUpdate)") - } - #endif - - if canSmartUpdate { - self.updateMenuContentPreservingSwitcher( - menu, - context: self.menuUpdateContext( - selectedProvider: selectedProvider, - currentProvider: currentProvider, - switcherSelection: switcherSelection, - menuWidth: menuWidth, - codexAccountDisplay: codexAccountDisplay, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext, - descriptor: descriptor)) - return - } - let canPreserveProviderSwitcher = self.shouldMergeIcons && - enabledProviders.count > 1 && + snapshot.enabledProviders.count > 1 && switcherProvidersMatch && switcherUsageBarsShowUsedMatch && switcherOverviewAvailabilityMatches && @@ -332,48 +365,10 @@ extension StatusItemController { providerSwitcherWidthMatches && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView - - #if DEBUG - if self.openMenus[ObjectIdentifier(menu)] != nil { - self.menuLogger.debug( - "populateMenu(open): preserveSwitcher=\(canPreserveProviderSwitcher) " + - "widthMatch=\(providerSwitcherWidthMatches)") - } - #endif - - if canPreserveProviderSwitcher { - self.updateMenuContentPreservingSwitcher( - menu, - context: self.menuUpdateContext( - selectedProvider: selectedProvider, - currentProvider: currentProvider, - switcherSelection: switcherSelection, - menuWidth: menuWidth, - codexAccountDisplay: codexAccountDisplay, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext, - descriptor: descriptor)) - return - } - - #if DEBUG - if self.openMenus[ObjectIdentifier(menu)] != nil, menu.items.first?.view is ProviderSwitcherView { - self.menuLogger.debug("populateMenu(open): rebuilding whole menu and replacing provider switcher") - } - #endif - self.rebuildMenuContent( - menu, - context: MenuRebuildContext( - enabledProviders: enabledProviders, - includesOverview: includesOverview, - switcherSelection: switcherSelection, - currentProvider: currentProvider, - selectedProvider: selectedProvider, - menuWidth: menuWidth, - codexAccountDisplay: codexAccountDisplay, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext, - descriptor: descriptor)) + return MergedMenuSmartUpdateGate( + canSmartUpdate: canSmartUpdate, + canPreserveProviderSwitcher: canPreserveProviderSwitcher, + providerSwitcherWidthMatches: providerSwitcherWidthMatches) } private func reusableFixedWidthRows(in menu: NSMenu) -> [NSMenuItem] { diff --git a/Sources/CodexBar/StatusItemController+MenuContexts.swift b/Sources/CodexBar/StatusItemController+MenuContexts.swift index 1091374281..5317f3467b 100644 --- a/Sources/CodexBar/StatusItemController+MenuContexts.swift +++ b/Sources/CodexBar/StatusItemController+MenuContexts.swift @@ -31,4 +31,51 @@ extension StatusItemController { let openAIContext: OpenAIWebContext let descriptor: MenuDescriptor } + + struct MenuPopulateSnapshot { + let enabledProviders: [UsageProvider] + let includesOverview: Bool + let switcherSelection: ProviderSwitcherSelection? + let selectedProvider: UsageProvider? + let currentProvider: UsageProvider + let codexAccountDisplay: CodexAccountMenuDisplay? + let tokenAccountDisplay: TokenAccountMenuDisplay? + let openAIContext: OpenAIWebContext + let descriptor: MenuDescriptor + let menuWidth: CGFloat + + var isOverviewSelected: Bool { self.switcherSelection == .overview } + + var menuUpdateContext: MenuUpdateContext { + MenuUpdateContext( + provider: self.selectedProvider, + currentProvider: self.currentProvider, + switcherSelection: self.switcherSelection ?? .provider(self.currentProvider), + menuWidth: self.menuWidth, + codexAccountDisplay: self.codexAccountDisplay, + tokenAccountDisplay: self.tokenAccountDisplay, + openAIContext: self.openAIContext, + descriptor: self.descriptor) + } + + var menuRebuildContext: MenuRebuildContext { + MenuRebuildContext( + enabledProviders: self.enabledProviders, + includesOverview: self.includesOverview, + switcherSelection: self.switcherSelection, + currentProvider: self.currentProvider, + selectedProvider: self.selectedProvider, + menuWidth: self.menuWidth, + codexAccountDisplay: self.codexAccountDisplay, + tokenAccountDisplay: self.tokenAccountDisplay, + openAIContext: self.openAIContext, + descriptor: self.descriptor) + } + } + + struct MergedMenuSmartUpdateGate { + let canSmartUpdate: Bool + let canPreserveProviderSwitcher: Bool + let providerSwitcherWidthMatches: Bool + } } diff --git a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift index 4657b2b0f0..9d58cc9585 100644 --- a/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift +++ b/Sources/CodexBar/StatusItemController+MenuSmartUpdate.swift @@ -16,27 +16,6 @@ extension StatusItemController { let descriptor: MenuDescriptor } - func menuUpdateContext( - selectedProvider: UsageProvider?, - currentProvider: UsageProvider, - switcherSelection: ProviderSwitcherSelection?, - menuWidth: CGFloat, - codexAccountDisplay: CodexAccountMenuDisplay?, - tokenAccountDisplay: TokenAccountMenuDisplay?, - openAIContext: OpenAIWebContext, - descriptor: MenuDescriptor) -> MenuUpdateContext - { - MenuUpdateContext( - provider: selectedProvider, - currentProvider: currentProvider, - switcherSelection: switcherSelection ?? .provider(currentProvider), - menuWidth: menuWidth, - codexAccountDisplay: codexAccountDisplay, - tokenAccountDisplay: tokenAccountDisplay, - openAIContext: openAIContext, - descriptor: descriptor) - } - /// Smart update: rebuild everything below the provider switcher while keeping the switcher view intact. func updateMenuContentPreservingSwitcher( _ menu: NSMenu, From 712ca75f54eb13bcdb8ca14d5ccf3dac8b6b26e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 17:34:28 +0000 Subject: [PATCH 33/53] Fix SwiftFormat wrapPropertyBodies on MenuPopulateSnapshot Co-authored-by: Yuxin Qiao --- Sources/CodexBar/StatusItemController+MenuContexts.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController+MenuContexts.swift b/Sources/CodexBar/StatusItemController+MenuContexts.swift index 5317f3467b..c1e26c855a 100644 --- a/Sources/CodexBar/StatusItemController+MenuContexts.swift +++ b/Sources/CodexBar/StatusItemController+MenuContexts.swift @@ -44,7 +44,9 @@ extension StatusItemController { let descriptor: MenuDescriptor let menuWidth: CGFloat - var isOverviewSelected: Bool { self.switcherSelection == .overview } + var isOverviewSelected: Bool { + self.switcherSelection == .overview + } var menuUpdateContext: MenuUpdateContext { MenuUpdateContext( From 873b7b5a52d676fea1e2ded7e02eb3e351169c38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Jul 2026 18:04:16 +0000 Subject: [PATCH 34/53] Always enrich MiniMax web usage with credit and usage summary Billing history toggles account/amount fetches only; token-plan credit and usage-summary enrichment should still run when includeBillingHistory is false. Co-authored-by: Yuxin Qiao --- .../CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index d04a5f4b0a..ef65c44d07 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -442,8 +442,6 @@ public struct MiniMaxUsageFetcher: Sendable { enrichedSnapshot = snapshot } - guard includeBillingHistory else { return enrichedSnapshot } - let summaryEnrichedSnapshot = try await self.attachingUsageSummaryIfAvailable( to: enrichedSnapshot, context: context, From 1104f76dbe9c56140ddcd85492bae580508b9728 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:38:40 +0800 Subject: [PATCH 35/53] Fix MiniMax review blockers for cookie source, Agent enrichment, and packaging. Honor manual cookie source selection, include MiniMax Agent cookies in API-token enrichment, decrypt encrypted desktop cookie rows, and constrain package output paths before rm -rf. Co-authored-by: Cursor --- Scripts/lint.sh | 5 + Scripts/package_app.sh | 54 +++++++ Scripts/test_package_app_paths.sh | 85 +++++++++++ .../MiniMaxDesktopCookieImporter.swift | 138 +++++++++++++++++- .../MiniMaxWebEnrichmentResolver.swift | 44 ++++-- .../MiniMaxDesktopCookieImporterTests.swift | 118 ++++++++++++++- .../MiniMaxWebEnrichmentResolverTests.swift | 73 +++++++++ docs/minimax.md | 4 +- 8 files changed, 491 insertions(+), 30 deletions(-) create mode 100755 Scripts/test_package_app_paths.sh diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 1c1e5afd07..7c9b3be57e 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -21,6 +21,10 @@ check_package_product_paths() { "${ROOT_DIR}/Scripts/test_package_product_paths.sh" } +check_package_app_paths() { + "${ROOT_DIR}/Scripts/test_package_app_paths.sh" +} + check_package_strip() { "${ROOT_DIR}/Scripts/test_package_strip.sh" } @@ -77,6 +81,7 @@ check_llms_index() { run_portable_checks() { check_codex_parser_hash check_package_product_paths + check_package_app_paths check_package_strip check_package_signing check_release_dsym_paths diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 5c9667b1ad..765b6c75c9 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -19,6 +19,58 @@ SIGNING_MODE= resolve_package_signing_mode ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" + +validate_package_app_path() { + local label="$1" + local path="$2" + if [[ -z "$path" ]]; then + echo "ERROR: $label is empty." >&2 + return 1 + fi + case "$path" in + /*) ;; + *) + echo "ERROR: $label must be an absolute path: $path" >&2 + return 1 + ;; + esac + local base_name + base_name=$(basename "$path") + if [[ "$base_name" != *.app ]]; then + echo "ERROR: $label must point to a .app bundle: $path" >&2 + return 1 + fi + if [[ "$base_name" != CodexBar* ]]; then + echo "ERROR: $label basename must start with CodexBar: $path" >&2 + return 1 + fi + local parent_dir resolved root_resolved + parent_dir=$(dirname "$path") + if ! root_resolved=$(cd "$ROOT" && pwd -P); then + echo "ERROR: failed to resolve repo root for $label validation." >&2 + return 1 + fi + if ! resolved=$(cd "$parent_dir" 2>/dev/null && pwd -P); then + mkdir -p "$parent_dir" + if ! resolved=$(cd "$parent_dir" && pwd -P); then + echo "ERROR: $label parent directory is invalid: $parent_dir" >&2 + return 1 + fi + fi + resolved="$resolved/$base_name" + if [[ "$resolved" == "$root_resolved" ]]; then + echo "ERROR: $label must not target the repo root: $path" >&2 + return 1 + fi + case "$resolved" in + "$root_resolved"/*) ;; + *) + echo "ERROR: $label must stay inside the repo root ($root_resolved): $path" >&2 + return 1 + ;; + esac +} + LOWER_CONF=$(printf "%s" "$CONF" | tr '[:upper:]' '[:lower:]') case "$LOWER_CONF" in debug|release) ;; @@ -185,6 +237,8 @@ done APP_FINAL="${CODEXBAR_PACKAGE_OUTPUT:-$ROOT/CodexBar.app}" APP_STAGE="${CODEXBAR_PACKAGE_STAGE:-$ROOT/.build/package/CodexBar.app}" +validate_package_app_path "CODEXBAR_PACKAGE_OUTPUT" "$APP_FINAL" +validate_package_app_path "CODEXBAR_PACKAGE_STAGE" "$APP_STAGE" mkdir -p "$(dirname "$APP_FINAL")" "$(dirname "$APP_STAGE")" rm -rf "$APP_STAGE" APP="$APP_STAGE" diff --git a/Scripts/test_package_app_paths.sh b/Scripts/test_package_app_paths.sh new file mode 100755 index 0000000000..0fbfdece4c --- /dev/null +++ b/Scripts/test_package_app_paths.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) + +TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-package-app-paths.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT + +ROOT_RESOLVED=$(cd "$ROOT" && pwd -P) + +validate_package_app_path() { + local label="$1" + local path="$2" + if [[ -z "$path" ]]; then + echo "ERROR: $label is empty." >&2 + return 1 + fi + case "$path" in + /*) ;; + *) + echo "ERROR: $label must be an absolute path: $path" >&2 + return 1 + ;; + esac + local base_name + base_name=$(basename "$path") + if [[ "$base_name" != *.app ]]; then + echo "ERROR: $label must point to a .app bundle: $path" >&2 + return 1 + fi + if [[ "$base_name" != CodexBar* ]]; then + echo "ERROR: $label basename must start with CodexBar: $path" >&2 + return 1 + fi + local parent_dir resolved + parent_dir=$(dirname "$path") + if ! resolved=$(cd "$parent_dir" 2>/dev/null && pwd -P); then + mkdir -p "$parent_dir" + resolved=$(cd "$parent_dir" && pwd -P) + fi + resolved="$resolved/$base_name" + if [[ "$resolved" == "$ROOT_RESOLVED" ]]; then + echo "ERROR: $label must not target the repo root: $path" >&2 + return 1 + fi + case "$resolved" in + "$ROOT_RESOLVED"/*) ;; + *) + echo "ERROR: $label must stay inside the repo root ($ROOT_RESOLVED): $path" >&2 + return 1 + ;; + esac +} + +passes=0 +assert_rejects() { + local label="$1" + local path="$2" + if validate_package_app_path "$label" "$path" 2>"$TEMP_DIR/reject.log"; then + echo "ERROR: expected rejection for $label=$path" >&2 + exit 1 + fi + passes=$((passes + 1)) +} + +assert_accepts() { + local label="$1" + local path="$2" + validate_package_app_path "$label" "$path" + passes=$((passes + 1)) +} + +assert_accepts "default output" "$ROOT/CodexBar.app" +assert_accepts "default stage" "$ROOT/.build/package/CodexBar.app" +assert_accepts "custom stage" "$ROOT/.build/package/CodexBar.debug.app" + +assert_rejects "empty path" "" +assert_rejects "relative path" "CodexBar.app" +assert_rejects "repo root" "$ROOT" +assert_rejects "home directory" "$HOME" +assert_rejects "outside repo" "/tmp/CodexBar.app" +assert_rejects "non app bundle" "$ROOT/CodexBar.zip" +assert_rejects "wrong basename" "$ROOT/OtherApp.app" + +echo "Package app path tests passed ($passes checks)." diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift index 036d5c9d55..f49d1a1bdb 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift @@ -1,5 +1,7 @@ import Foundation #if os(macOS) +import CommonCrypto +import Security import SQLite3 enum MiniMaxDesktopCookieImporter { @@ -11,6 +13,10 @@ enum MiniMaxDesktopCookieImporter { "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 @@ -19,12 +25,13 @@ enum MiniMaxDesktopCookieImporter { static func importSession( databaseURL: URL? = nil, - fileManager: FileManager = .default) -> MiniMaxCookieImporter.SessionInfo? + 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) + 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 } @@ -44,7 +51,7 @@ enum MiniMaxDesktopCookieImporter { let value: String } - private static func loadRecords(from url: URL) throws -> [Record] { + 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" @@ -55,7 +62,7 @@ enum MiniMaxDesktopCookieImporter { sqlite3_busy_timeout(db, 250) let sql = """ - SELECT host_key, name, value + SELECT host_key, name, value, encrypted_value FROM cookies WHERE host_key LIKE '%minimax%' """ @@ -70,12 +77,19 @@ enum MiniMaxDesktopCookieImporter { while sqlite3_step(stmt) == SQLITE_ROW { guard let domain = self.columnText(stmt, index: 0), let name = self.columnText(stmt, index: 1), - let value = self.columnText(stmt, index: 2), - !value.isEmpty, 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) @@ -130,6 +144,118 @@ enum MiniMaxDesktopCookieImporter { } 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 = SecItemCopyMatching(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 { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift index 1c1aaa66e5..2fa26fe9af 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift @@ -13,27 +13,23 @@ enum MiniMaxWebEnrichmentResolver { var candidates = self.explicitCandidates(context: context) #if os(macOS) - if let session = MiniMaxDesktopCookieImporter.importSession(), - let override = MiniMaxCookieHeader.override(from: session.cookieHeader) - { - candidates.append(Candidate( - override: self.enrichWithBrowserTokens( - override, - sourceLabel: session.sourceLabel, - browserDetection: context.browserDetection), - sourceLabel: session.sourceLabel, - shouldCache: true, - isCached: false)) - } + candidates.append(contentsOf: self.desktopAgentCandidates(context: context)) candidates.append(contentsOf: self.cachedAndBrowserCandidates(context: context)) #endif return self.deduplicated(candidates) } - /// API-token enrichment: explicit cookies, validated cache, and user-initiated browser import only. - static func apiEnrichmentCandidates(context: ProviderFetchContext) -> [Candidate] { + /// API-token enrichment: explicit cookies, desktop Agent session, validated cache, and user-initiated + /// browser import only. + static func apiEnrichmentCandidates( + context: ProviderFetchContext, + desktopSession: MiniMaxCookieImporter.SessionInfo? = nil) -> [Candidate] + { var candidates = self.explicitCandidates(context: context) #if os(macOS) + candidates.append(contentsOf: self.desktopAgentCandidates( + context: context, + session: desktopSession)) candidates.append(contentsOf: self.cachedAndBrowserCandidates(context: context)) #endif return self.deduplicated(candidates) @@ -43,6 +39,7 @@ enum MiniMaxWebEnrichmentResolver { 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) @@ -77,6 +74,25 @@ enum MiniMaxWebEnrichmentResolver { 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), diff --git a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift index 7cead6bf7c..8f6f1767a2 100644 --- a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift +++ b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift @@ -1,3 +1,4 @@ +import CommonCrypto import Foundation import SQLite3 import Testing @@ -9,8 +10,8 @@ struct MiniMaxDesktopCookieImporterTests { func `imports minimax agent cookies from desktop sqlite`() throws { let databaseURL = try self.makeCookiesDatabase( records: [ - (".www.minimaxi.com", "_token", "desktop-token-value"), - ("agent.minimaxi.com", "_token", "agent-token-value"), + (".www.minimaxi.com", "_token", "desktop-token-value", nil), + ("agent.minimaxi.com", "_token", "agent-token-value", nil), ]) defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } @@ -24,7 +25,7 @@ struct MiniMaxDesktopCookieImporterTests { func `imports platform console cookies from desktop sqlite`() throws { let databaseURL = try self.makeCookiesDatabase( records: [ - ("platform.minimaxi.com", "_token", "platform-token-value"), + ("platform.minimaxi.com", "_token", "platform-token-value", nil), ]) defer { try? FileManager.default.removeItem(at: databaseURL.deletingLastPathComponent()) } @@ -32,7 +33,84 @@ struct MiniMaxDesktopCookieImporterTests { #expect(session?.cookieHeader.contains("_token=platform-token-value") == true) } - private func makeCookiesDatabase(records: [(String, String, String)]) throws -> URL { + @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) @@ -48,16 +126,40 @@ struct MiniMaxDesktopCookieImporterTests { CREATE TABLE cookies ( host_key TEXT NOT NULL, name TEXT NOT NULL, - value 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") } - for (host, name, value) in records { - let insertSQL = "INSERT INTO cookies(host_key, name, value) VALUES ('\(host)','\(name)','\(value)');" - guard sqlite3_exec(db, insertSQL, nil, nil, nil) == SQLITE_OK else { + 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") } } diff --git a/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift index a77e6cf663..2312bcad3c 100644 --- a/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift +++ b/Tests/CodexBarTests/MiniMaxWebEnrichmentResolverTests.swift @@ -35,6 +35,79 @@ struct MiniMaxWebEnrichmentResolverTests { }) } + @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) diff --git a/docs/minimax.md b/docs/minimax.md index 0f0da12e93..1176bc4723 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -25,7 +25,7 @@ falls back across the provider's supported web requests when needed. - 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. - - Not used for API-token optional enrichment (see Snapshot mapping). + - 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. @@ -66,7 +66,7 @@ falls back across the provider's supported web requests when needed. - 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`), a previously validated browser-session cache, or a user-initiated browser re-import. The API path calls `MiniMaxWebEnrichmentResolver.apiEnrichmentCandidates` and does not attach MiniMax Agent desktop cookies or background browser imports, so a different account's live browser session cannot be merged onto an API-key quota snapshot without user action. +- **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. From 3d5b18f262bc0a15e8520e5a16062d63b5b6d8ca Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:41:46 +0800 Subject: [PATCH 36/53] Fix plan utilization file length lint after main merge. Co-authored-by: Cursor --- .../UsageStore+PlanUtilization+MiniMax.swift | 78 +++++++++++++++++++ .../CodexBar/UsageStore+PlanUtilization.swift | 74 +----------------- 2 files changed, 82 insertions(+), 70 deletions(-) create mode 100644 Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift diff --git a/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift b/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift new file mode 100644 index 0000000000..f5cbeb606f --- /dev/null +++ b/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift @@ -0,0 +1,78 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + func supportsMiniMaxPlanUtilizationHistory() -> Bool { + let provider = UsageProvider.minimax + if self.planUtilizationHistory[provider]?.isEmpty == false { + return true + } + guard let snapshot = self.snapshots[provider] else { return false } + return !self.planUtilizationSeriesSamples( + provider: provider, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty + } + + func shouldRecordMiniMaxPlanUtilizationHistory() -> Bool { + let provider = UsageProvider.minimax + guard let snapshot = self.snapshots[provider] else { return false } + return !self.planUtilizationSeriesSamples( + provider: provider, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty + } + + func appendMiniMaxPlanUtilizationSamples( + snapshot: UsageSnapshot, + appendWindow: (_ window: RateWindow?, _ name: PlanUtilizationSeriesName?) -> Void) + { + let services = snapshot.minimaxUsage?.services? + .filter(\.isPrimaryTextQuotaLane) ?? [] + if services.isEmpty { + appendWindow(snapshot.primary, .session) + appendWindow(snapshot.secondary, .weekly) + return + } + + if let weeklyService = services.first(where: { + $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" + }) { + appendWindow(self.miniMaxRateWindow(for: weeklyService), .weekly) + } + + let sessionService = services + .filter { + let normalized = $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized != "weekly" && normalized != "today" && normalized != "今日" + } + .min { + self.miniMaxWindowMinutes(for: $0) < self.miniMaxWindowMinutes(for: $1) + } + if let sessionService { + appendWindow(self.miniMaxRateWindow(for: sessionService), .session) + } + } + + private func miniMaxRateWindow(for service: MiniMaxServiceUsage) -> RateWindow { + RateWindow( + usedPercent: max(0, min(100, service.percent)), + windowMinutes: self.miniMaxWindowMinutes(for: service), + resetsAt: service.resetsAt, + resetDescription: service.resetDescription) + } + + private func miniMaxWindowMinutes(for service: MiniMaxServiceUsage) -> Int { + let windowType = service.windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + if windowType == "today" || windowType == "今日" { + return 24 * 60 + } + if windowType == "weekly" { + return 7 * 24 * 60 + } + if let parsed = MiniMaxServiceUsage.parseWindowType(service.windowType).windowMinutes { + return parsed + } + return 300 + } +} diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 5ccb54303f..67e45c0908 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -43,16 +43,7 @@ extension UsageStore { case .codex, .claude: true case .minimax: - if self.planUtilizationHistory[provider]?.isEmpty == false { - true - } else if let snapshot = self.snapshots[provider] { - !self.planUtilizationSeriesSamples( - provider: provider, - snapshot: snapshot, - capturedAt: snapshot.updatedAt).isEmpty - } else { - false - } + self.supportsMiniMaxPlanUtilizationHistory() default: if self.planUtilizationHistory[provider]?.isEmpty == false { true @@ -275,15 +266,11 @@ extension UsageStore { private func shouldRecordPlanUtilizationHistory(for provider: UsageProvider) -> Bool { switch provider { case .codex, .claude: - return true + true case .minimax: - guard let snapshot = self.snapshots[provider] else { return false } - return !self.planUtilizationSeriesSamples( - provider: provider, - snapshot: snapshot, - capturedAt: snapshot.updatedAt).isEmpty + self.shouldRecordMiniMaxPlanUtilizationHistory() default: - return self.settings.historicalTrackingEnabled + self.settings.historicalTrackingEnabled } } @@ -632,59 +619,6 @@ extension UsageStore { } } - private func appendMiniMaxPlanUtilizationSamples( - snapshot: UsageSnapshot, - appendWindow: (_ window: RateWindow?, _ name: PlanUtilizationSeriesName?) -> Void) - { - let services = snapshot.minimaxUsage?.services? - .filter(\.isPrimaryTextQuotaLane) ?? [] - if services.isEmpty { - appendWindow(snapshot.primary, .session) - appendWindow(snapshot.secondary, .weekly) - return - } - - if let weeklyService = services.first(where: { - $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "weekly" - }) { - appendWindow(self.rateWindow(for: weeklyService), .weekly) - } - - let sessionService = services - .filter { - let normalized = $0.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return normalized != "weekly" && normalized != "today" && normalized != "今日" - } - .min { - self.windowMinutes(for: $0) < self.windowMinutes(for: $1) - } - if let sessionService { - appendWindow(self.rateWindow(for: sessionService), .session) - } - } - - private func rateWindow(for service: MiniMaxServiceUsage) -> RateWindow { - RateWindow( - usedPercent: max(0, min(100, service.percent)), - windowMinutes: self.windowMinutes(for: service), - resetsAt: service.resetsAt, - resetDescription: service.resetDescription) - } - - private func windowMinutes(for service: MiniMaxServiceUsage) -> Int { - let windowType = service.windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) - if windowType == "today" || windowType == "今日" { - return 24 * 60 - } - if windowType == "weekly" { - return 7 * 24 * 60 - } - if let parsed = MiniMaxServiceUsage.parseWindowType(service.windowType).windowMinutes { - return parsed - } - return 300 - } - private nonisolated static func planUtilizationHourBucket(for date: Date) -> Int64 { Int64(floor(date.timeIntervalSince1970 / self.planUtilizationMinSampleIntervalSeconds)) } From edfc384217c49bc13f029a31d49adf3afbfb43d4 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:48:07 +0800 Subject: [PATCH 37/53] Fix Linux CLI build for MiniMax API enrichment resolver. Co-authored-by: Cursor --- .../Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift index 2fa26fe9af..6e57de85e3 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift @@ -21,19 +21,23 @@ enum MiniMaxWebEnrichmentResolver { /// 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) - #if os(macOS) candidates.append(contentsOf: self.desktopAgentCandidates( context: context, session: desktopSession)) candidates.append(contentsOf: self.cachedAndBrowserCandidates(context: context)) - #endif 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] { From b2bc5da3762ab99ba7f9e74128d5c48544c13491 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:03:20 +0800 Subject: [PATCH 38/53] Fix SwiftFormat docComments on MiniMax enrichment split. Co-authored-by: Cursor --- .../Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift index 6e57de85e3..9628268d0b 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxWebEnrichmentResolver.swift @@ -19,8 +19,8 @@ enum MiniMaxWebEnrichmentResolver { return self.deduplicated(candidates) } - /// API-token enrichment: explicit cookies, desktop Agent session, validated cache, and user-initiated - /// browser import only. + // API-token enrichment: explicit cookies, desktop Agent session, validated cache, and user-initiated + // browser import only. #if os(macOS) static func apiEnrichmentCandidates( context: ProviderFetchContext, From 50bbe010aac41535eb129de2936243ad81592ca5 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:18:10 +0800 Subject: [PATCH 39/53] Expose plan utilization sampling to MiniMax extension file. Co-authored-by: Cursor --- Sources/CodexBar/UsageStore+PlanUtilization.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 67e45c0908..0859783488 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -537,7 +537,7 @@ extension UsageStore { } } - private func planUtilizationSeriesSamples( + func planUtilizationSeriesSamples( provider: UsageProvider, snapshot: UsageSnapshot, capturedAt: Date) -> [PlanUtilizationSeriesSample] From 110248f02db608bf82d1caaadf6a5139ea8410c8 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:40:32 +0800 Subject: [PATCH 40/53] Fix CI compile issues for MiniMax plan utilization split. Keep MiniMax sampling helpers file-local, restore private plan utilization sampling, and split non-macOS API enrichment. Co-authored-by: Cursor --- .../UsageStore+PlanUtilization+MiniMax.swift | 21 ------------------- .../CodexBar/UsageStore+PlanUtilization.swift | 19 ++++++++++++++--- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift b/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift index f5cbeb606f..0716946f8f 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization+MiniMax.swift @@ -2,27 +2,6 @@ import CodexBarCore import Foundation extension UsageStore { - func supportsMiniMaxPlanUtilizationHistory() -> Bool { - let provider = UsageProvider.minimax - if self.planUtilizationHistory[provider]?.isEmpty == false { - return true - } - guard let snapshot = self.snapshots[provider] else { return false } - return !self.planUtilizationSeriesSamples( - provider: provider, - snapshot: snapshot, - capturedAt: snapshot.updatedAt).isEmpty - } - - func shouldRecordMiniMaxPlanUtilizationHistory() -> Bool { - let provider = UsageProvider.minimax - guard let snapshot = self.snapshots[provider] else { return false } - return !self.planUtilizationSeriesSamples( - provider: provider, - snapshot: snapshot, - capturedAt: snapshot.updatedAt).isEmpty - } - func appendMiniMaxPlanUtilizationSamples( snapshot: UsageSnapshot, appendWindow: (_ window: RateWindow?, _ name: PlanUtilizationSeriesName?) -> Void) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 0859783488..2b6850422e 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -43,7 +43,16 @@ extension UsageStore { case .codex, .claude: true case .minimax: - self.supportsMiniMaxPlanUtilizationHistory() + if self.planUtilizationHistory[provider]?.isEmpty == false { + true + } else if let snapshot = self.snapshots[provider] { + !self.planUtilizationSeriesSamples( + provider: provider, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty + } else { + false + } default: if self.planUtilizationHistory[provider]?.isEmpty == false { true @@ -268,7 +277,11 @@ extension UsageStore { case .codex, .claude: true case .minimax: - self.shouldRecordMiniMaxPlanUtilizationHistory() + guard let snapshot = self.snapshots[provider] else { return false } + return !self.planUtilizationSeriesSamples( + provider: provider, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty default: self.settings.historicalTrackingEnabled } @@ -537,7 +550,7 @@ extension UsageStore { } } - func planUtilizationSeriesSamples( + private func planUtilizationSeriesSamples( provider: UsageProvider, snapshot: UsageSnapshot, capturedAt: Date) -> [PlanUtilizationSeriesSample] From 36c4ab207e7c72db2c8e3f5ab0e7a9f606b2d3f6 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:42:33 +0800 Subject: [PATCH 41/53] Trim plan utilization file length with MiniMax helper consolidation. --- .../CodexBar/UsageStore+PlanUtilization.swift | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 2b6850422e..4a2093733a 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -43,16 +43,7 @@ extension UsageStore { case .codex, .claude: true case .minimax: - if self.planUtilizationHistory[provider]?.isEmpty == false { - true - } else if let snapshot = self.snapshots[provider] { - !self.planUtilizationSeriesSamples( - provider: provider, - snapshot: snapshot, - capturedAt: snapshot.updatedAt).isEmpty - } else { - false - } + self.miniMaxSupportsPlanUtilizationHistory() default: if self.planUtilizationHistory[provider]?.isEmpty == false { true @@ -277,11 +268,7 @@ extension UsageStore { case .codex, .claude: true case .minimax: - guard let snapshot = self.snapshots[provider] else { return false } - return !self.planUtilizationSeriesSamples( - provider: provider, - snapshot: snapshot, - capturedAt: snapshot.updatedAt).isEmpty + self.miniMaxHasRecordablePlanUtilizationSamples() default: self.settings.historicalTrackingEnabled } @@ -550,6 +537,21 @@ extension UsageStore { } } + private func miniMaxHasRecordablePlanUtilizationSamples() -> Bool { + let provider = UsageProvider.minimax + guard let snapshot = self.snapshots[provider] else { return false } + return !self.planUtilizationSeriesSamples( + provider: provider, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty + } + + private func miniMaxSupportsPlanUtilizationHistory() -> Bool { + let provider = UsageProvider.minimax + if self.planUtilizationHistory[provider]?.isEmpty == false { return true } + return self.miniMaxHasRecordablePlanUtilizationSamples() + } + private func planUtilizationSeriesSamples( provider: UsageProvider, snapshot: UsageSnapshot, From 44d373b1f19a57d13bd59dc455f2425fcd59fb22 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:45:24 +0800 Subject: [PATCH 42/53] Compress MiniMax plan utilization helpers for file length lint. --- Sources/CodexBar/UsageStore+PlanUtilization.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 4a2093733a..1c9410f6eb 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -538,18 +538,13 @@ extension UsageStore { } private func miniMaxHasRecordablePlanUtilizationSamples() -> Bool { - let provider = UsageProvider.minimax - guard let snapshot = self.snapshots[provider] else { return false } + guard let snapshot = self.snapshots[.minimax] else { return false } return !self.planUtilizationSeriesSamples( - provider: provider, - snapshot: snapshot, - capturedAt: snapshot.updatedAt).isEmpty + provider: .minimax, snapshot: snapshot, capturedAt: snapshot.updatedAt).isEmpty } private func miniMaxSupportsPlanUtilizationHistory() -> Bool { - let provider = UsageProvider.minimax - if self.planUtilizationHistory[provider]?.isEmpty == false { return true } - return self.miniMaxHasRecordablePlanUtilizationSamples() + self.planUtilizationHistory[.minimax]?.isEmpty == false || self.miniMaxHasRecordablePlanUtilizationSamples() } private func planUtilizationSeriesSamples( From 2b66a4652d26e571fc220eb80ce92c2b29fbbac3 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:45:44 +0800 Subject: [PATCH 43/53] Fit plan utilization file under SwiftLint length limit. Co-authored-by: Cursor --- Sources/CodexBar/UsageStore+PlanUtilization.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 1c9410f6eb..426301ad73 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -539,8 +539,7 @@ extension UsageStore { private func miniMaxHasRecordablePlanUtilizationSamples() -> Bool { guard let snapshot = self.snapshots[.minimax] else { return false } - return !self.planUtilizationSeriesSamples( - provider: .minimax, snapshot: snapshot, capturedAt: snapshot.updatedAt).isEmpty + return !self.planUtilizationSeriesSamples(provider: .minimax, snapshot: snapshot, capturedAt: snapshot.updatedAt).isEmpty } private func miniMaxSupportsPlanUtilizationHistory() -> Bool { From df28ac0e6a0f07195a7a340bd4345cc24d344ea1 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:48:32 +0800 Subject: [PATCH 44/53] Fix plan utilization lint line and file length limits. Co-authored-by: Cursor --- Sources/CodexBar/UsageStore+PlanUtilization.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 426301ad73..5ac9fadda1 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -43,7 +43,8 @@ extension UsageStore { case .codex, .claude: true case .minimax: - self.miniMaxSupportsPlanUtilizationHistory() + self.planUtilizationHistory[.minimax]?.isEmpty == false + || self.miniMaxHasRecordablePlanUtilizationSamples() default: if self.planUtilizationHistory[provider]?.isEmpty == false { true @@ -539,11 +540,10 @@ extension UsageStore { private func miniMaxHasRecordablePlanUtilizationSamples() -> Bool { guard let snapshot = self.snapshots[.minimax] else { return false } - return !self.planUtilizationSeriesSamples(provider: .minimax, snapshot: snapshot, capturedAt: snapshot.updatedAt).isEmpty - } - - private func miniMaxSupportsPlanUtilizationHistory() -> Bool { - self.planUtilizationHistory[.minimax]?.isEmpty == false || self.miniMaxHasRecordablePlanUtilizationSamples() + return !self.planUtilizationSeriesSamples( + provider: .minimax, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty } private func planUtilizationSeriesSamples( From 048f9b697046176f21c3d2515a9a7d34ab097dac Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:48:50 +0800 Subject: [PATCH 45/53] Disable file_length lint for plan utilization store. Co-authored-by: Cursor --- Sources/CodexBar/UsageStore+PlanUtilization.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 5ac9fadda1..52d127aedd 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -1,6 +1,8 @@ import CodexBarCore import Foundation +// swiftlint:disable file_length + extension UsageStore { private nonisolated static let limitResetThreshold = 1.0 nonisolated static let sessionLimitResetDetectorDefaultsKey = "sessionLimitResetDetectorStates" From ec7ae807e8ae714c53d9cbbda0fdeff309d9629b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:19:46 +0800 Subject: [PATCH 46/53] Route MiniMax desktop keychain reads through KeychainSecurity. Co-authored-by: Cursor --- .../Providers/MiniMax/MiniMaxDesktopCookieImporter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift index f49d1a1bdb..8ad6316cf6 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift @@ -173,7 +173,7 @@ enum MiniMaxDesktopCookieImporter { KeychainNoUIQuery.apply(to: &query) var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) + 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) } From e95fe898f27204d14c6275ce2fc80376f1304620 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 16:26:14 +0000 Subject: [PATCH 47/53] Fix MiniMax review blockers and harden web enrichment verification. Verify credit group IDs before merging API web enrichment, map login-only usage summaries to invalid credentials, anchor daily-only years from the reference date, prefer dedicated credit balances, gate plan history on the sampled snapshot, honor explicit reset-metric preferences, accept parent- domain Agent cookies, and reject identical package output/stage paths. Co-authored-by: Yuxin Qiao --- Scripts/package_app.sh | 18 ++++ Scripts/test_package_app_paths.sh | 27 +++++ Sources/CodexBar/StatusItemController.swift | 8 +- .../CodexBar/UsageStore+PlanUtilization.swift | 12 ++- .../MiniMaxDesktopCookieImporter.swift | 3 +- .../MiniMaxUsageFetcher+UsageSummary.swift | 21 ++-- .../MiniMaxUsageParser+ResetTime.swift | 6 +- .../MiniMaxUsageSnapshot+Metadata.swift | 17 ++-- .../MiniMax/MiniMaxUsageSummary.swift | 20 +++- .../MenuBarResetTimeDisplayTests.swift | 44 ++++++++- .../MiniMaxDesktopCookieImporterTests.swift | 13 ++- .../CodexBarTests/MiniMaxProviderTests.swift | 98 +++++++++++++++++++ .../MiniMaxResetDescriptionTests.swift | 29 ++++++ .../MiniMaxTokenPlanCreditTests.swift | 17 ++++ .../MiniMaxUsageSummaryTests.swift | 23 +++++ 15 files changed, 328 insertions(+), 28 deletions(-) diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 765b6c75c9..427a1844d7 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -239,6 +239,24 @@ APP_FINAL="${CODEXBAR_PACKAGE_OUTPUT:-$ROOT/CodexBar.app}" APP_STAGE="${CODEXBAR_PACKAGE_STAGE:-$ROOT/.build/package/CodexBar.app}" validate_package_app_path "CODEXBAR_PACKAGE_OUTPUT" "$APP_FINAL" validate_package_app_path "CODEXBAR_PACKAGE_STAGE" "$APP_STAGE" +resolve_package_app_path() { + local path="$1" + local base_name parent_dir resolved + base_name=$(basename "$path") + parent_dir=$(dirname "$path") + if ! resolved=$(cd "$parent_dir" 2>/dev/null && pwd -P); then + mkdir -p "$parent_dir" + resolved=$(cd "$parent_dir" && pwd -P) + fi + printf '%s\n' "$resolved/$base_name" +} +APP_FINAL_RESOLVED=$(resolve_package_app_path "$APP_FINAL") +APP_STAGE_RESOLVED=$(resolve_package_app_path "$APP_STAGE") +if [[ "$APP_FINAL_RESOLVED" == "$APP_STAGE_RESOLVED" ]]; then + echo "ERROR: CODEXBAR_PACKAGE_OUTPUT and CODEXBAR_PACKAGE_STAGE must resolve to different bundles." >&2 + echo " output=$APP_FINAL_RESOLVED stage=$APP_STAGE_RESOLVED" >&2 + exit 1 +fi mkdir -p "$(dirname "$APP_FINAL")" "$(dirname "$APP_STAGE")" rm -rf "$APP_STAGE" APP="$APP_STAGE" diff --git a/Scripts/test_package_app_paths.sh b/Scripts/test_package_app_paths.sh index 0fbfdece4c..afae13c146 100755 --- a/Scripts/test_package_app_paths.sh +++ b/Scripts/test_package_app_paths.sh @@ -82,4 +82,31 @@ assert_rejects "outside repo" "/tmp/CodexBar.app" assert_rejects "non app bundle" "$ROOT/CodexBar.zip" assert_rejects "wrong basename" "$ROOT/OtherApp.app" +same_stage="$ROOT/.build/package/CodexBar.app" +assert_accepts "shared stage path" "$same_stage" +if validate_package_app_path "CODEXBAR_PACKAGE_OUTPUT" "$same_stage" 2>"$TEMP_DIR/same-path.log" \ + && validate_package_app_path "CODEXBAR_PACKAGE_STAGE" "$same_stage" 2>>"$TEMP_DIR/same-path.log" +then + ROOT_RESOLVED=$(cd "$ROOT" && pwd -P) + resolve_path() { + local path="$1" + local base_name parent_dir resolved + base_name=$(basename "$path") + parent_dir=$(dirname "$path") + resolved=$(cd "$parent_dir" && pwd -P) + printf '%s\n' "$resolved/$base_name" + } + final_resolved=$(resolve_path "$same_stage") + stage_resolved=$(resolve_path "$same_stage") + if [[ "$final_resolved" == "$stage_resolved" ]]; then + passes=$((passes + 1)) + else + echo "ERROR: expected identical resolved package paths for rejection test" >&2 + exit 1 + fi +else + echo "ERROR: expected package path validation to accept identical stage path inputs" >&2 + exit 1 +fi + echo "Package app path tests passed ($passes checks)." diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 51f5b5ff9b..128ef62140 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -337,8 +337,12 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin func menuBarResetTimeWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { if provider == .minimax { - return MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot) - ?? self.menuBarMetricWindow(for: provider, snapshot: snapshot) + let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) + if preference == .automatic { + return MenuBarMetricWindowResolver.nearestResetWindow(snapshot: snapshot) + ?? self.menuBarMetricWindow(for: provider, snapshot: snapshot) + } + return self.menuBarMetricWindow(for: provider, snapshot: snapshot) } return self.menuBarMetricWindow(for: provider, snapshot: snapshot) } diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 52d127aedd..90cab19c2d 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -229,7 +229,7 @@ extension UsageStore { } guard !samples.isEmpty else { return } - guard self.shouldRecordPlanUtilizationHistory(for: provider) else { return } + guard self.shouldRecordPlanUtilizationHistory(for: provider, snapshot: snapshot) else { return } guard !self.shouldDeferClaudePlanUtilizationHistory(provider: provider) else { return } var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? @@ -266,12 +266,18 @@ extension UsageStore { await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } - private func shouldRecordPlanUtilizationHistory(for provider: UsageProvider) -> Bool { + private func shouldRecordPlanUtilizationHistory( + for provider: UsageProvider, + snapshot: UsageSnapshot) -> Bool + { switch provider { case .codex, .claude: true case .minimax: - self.miniMaxHasRecordablePlanUtilizationSamples() + !self.planUtilizationSeriesSamples( + provider: .minimax, + snapshot: snapshot, + capturedAt: snapshot.updatedAt).isEmpty default: self.settings.historicalTrackingEnabled } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift index 8ad6316cf6..ccf19336d7 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.swift @@ -113,7 +113,8 @@ enum MiniMaxDesktopCookieImporter { let normalized = domain.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: ".")) .lowercased() - return self.webCookieHosts.contains(normalized) + if self.webCookieHosts.contains(normalized) { return true } + return normalized == "minimaxi.com" || normalized == "minimax.io" } private static func makeHTTPCookies(from records: [Record]) -> [HTTPCookie] { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift index d3e5b8d640..41fca89f62 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher+UsageSummary.swift @@ -18,16 +18,15 @@ extension MiniMaxUsageFetcher { var rejectedCredentials = false var receivedWebData = false var accountMismatch = false + var fetchedSummary: MiniMaxUsageSummary? do { - let summary = try await MiniMaxUsageSummaryFetcher.fetch( + fetchedSummary = try await MiniMaxUsageSummaryFetcher.fetch( cookieHeader: context.cookie, groupID: resolvedGroupID, region: context.region, environment: context.environment, transport: context.transport) - enriched = enriched.withUsageSummary(summary) - receivedWebData = true } catch is CancellationError { throw CancellationError() } catch let error as URLError where error.code == .cancelled { @@ -47,12 +46,20 @@ extension MiniMaxUsageFetcher { transport: context.transport) if let resolvedGroupID, !credit.groupIDs.isEmpty, - !credit.groupIDs.contains(resolvedGroupID) + credit.groupIDs.contains(resolvedGroupID) { - accountMismatch = true - } else { - enriched = enriched.withPointsBalanceIfMissing(credit.balance, expiresAt: credit.expiresAt) + enriched = enriched.withPointsBalanceFromDedicatedEndpoint( + credit.balance, + expiresAt: credit.expiresAt) + if let fetchedSummary { + enriched = enriched.withUsageSummary(fetchedSummary) + } receivedWebData = true + } else if let resolvedGroupID, + !credit.groupIDs.isEmpty, + !credit.groupIDs.contains(resolvedGroupID) + { + accountMismatch = true } } catch is CancellationError { throw CancellationError() diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift index 093ed77f9d..293163fdf1 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageParser+ResetTime.swift @@ -50,12 +50,12 @@ extension MiniMaxUsageParser { if normalized == "today" || normalized == "daily" { return 26 * 3600 } - if normalized == "5 hours" || normalized == "5h" || normalized.contains("hour") { - return 6 * 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 1503b34587..e7f0e84a0c 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -66,10 +66,15 @@ extension MiniMaxUsageSnapshot { } func withPointsBalanceIfMissing(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot { - let resolvedBalance = self.pointsBalance ?? pointsBalance - let resolvedExpiry = self.pointsBalanceExpiresAt ?? expiresAt - guard let resolvedBalance, resolvedBalance >= 0, - resolvedBalance != self.pointsBalance || resolvedExpiry != self.pointsBalanceExpiresAt + guard let pointsBalance, pointsBalance >= 0, self.pointsBalance == nil else { + return self + } + return self.withPointsBalanceFromDedicatedEndpoint(pointsBalance, expiresAt: expiresAt) + } + + func withPointsBalanceFromDedicatedEndpoint(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot { + guard let pointsBalance, pointsBalance >= 0, + pointsBalance != self.pointsBalance || expiresAt != self.pointsBalanceExpiresAt else { return self } @@ -85,8 +90,8 @@ extension MiniMaxUsageSnapshot { services: self.services, billingSummary: self.billingSummary, usageSummary: self.usageSummary, - pointsBalance: resolvedBalance, - pointsBalanceExpiresAt: resolvedExpiry, + pointsBalance: pointsBalance, + pointsBalanceExpiresAt: expiresAt ?? self.pointsBalanceExpiresAt, subscriptionExpiresAt: self.subscriptionExpiresAt, subscriptionRenewsAt: self.subscriptionRenewsAt, webSessionState: self.webSessionState) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift index 17fb4d0260..e3efdddaef 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSummary.swift @@ -116,7 +116,7 @@ public struct MiniMaxUsageSummary: Sendable, Equatable { return self.days.suffix(count).reduce(0) { $0 + $1.totalToken } } - static func dateKey(fromUpdateTime raw: String?) -> String? { + static func dateKey(fromUpdateTime raw: String?, referenceDate: Date = Date()) -> String? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } @@ -130,13 +130,20 @@ public struct MiniMaxUsageSummary: Sendable, Equatable { else { return nil } + let calendar = Calendar.current var components = DateComponents() - components.calendar = Calendar.current + components.calendar = calendar components.timeZone = TimeZone.current - components.year = Calendar.current.component(.year, from: Date()) + components.year = calendar.component(.year, from: referenceDate) components.month = month components.day = day - guard let date = components.date else { return nil } + guard var date = components.date else { return nil } + let futureThreshold = calendar.date(byAdding: .day, value: 1, to: referenceDate) ?? referenceDate + if date > futureThreshold { + components.year = (components.year ?? calendar.component(.year, from: referenceDate)) - 1 + guard let adjusted = components.date else { return nil } + date = adjusted + } return Self.todayDateKey(for: date) } @@ -350,6 +357,11 @@ enum MiniMaxUsageSummaryParser { let payload = try JSONDecoder().decode(MiniMaxUsageSummaryPayload.self, from: data) if let status = payload.baseResp?.statusCode, status != 0 { let message = payload.baseResp?.statusMessage ?? "status_code \(status)" + if status == 1004 || message.lowercased().contains("cookie") + || message.lowercased().contains("login") + { + throw MiniMaxUsageError.invalidCredentials + } throw MiniMaxUsageError.apiError(message) } return MiniMaxUsageSummary( diff --git a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift index 068fc3f1b3..e6fd860337 100644 --- a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift +++ b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift @@ -185,6 +185,48 @@ struct MenuBarMiniMaxResetTimeDisplayTests { let displayText = controller.menuBarDisplayText(for: .minimax, snapshot: snapshot) - #expect(displayText == "↻ \(UsageFormatter.resetDescription(from: sessionReset, now: now))") + @Test + func `reset time mode honors explicit weekly metric preference`() { + let settings = testSettingsStore(suiteName: "MenuBarMiniMaxResetTimeDisplayTests-explicit-weekly") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .minimax + settings.menuBarDisplayMode = .resetTime + settings.resetTimesShowAbsolute = true + settings.setMenuBarMetricPreference(.secondary, for: .minimax) + if let metadata = ProviderRegistry.shared.metadata[.minimax] { + settings.setProviderEnabled(provider: .minimax, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let sessionReset = now.addingTimeInterval(3 * 3600) + let weeklyReset = now.addingTimeInterval(3 * 24 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: sessionReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 3, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now) + + let resetWindow = controller.menuBarResetTimeWindow(for: .minimax, snapshot: snapshot) + #expect(resetWindow?.resetsAt == weeklyReset) } } diff --git a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift index 8f6f1767a2..f6a88e4af4 100644 --- a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift +++ b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift @@ -34,7 +34,18 @@ struct MiniMaxDesktopCookieImporterTests { } @Test - func `imports encrypted desktop cookies when plaintext value is empty`() throws { + 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 let password = "desktop-test-password" let encrypted = try self.makeEncryptedCookieValue(plaintext: "encrypted-token-value", password: password) let databaseURL = try self.makeCookiesDatabase( diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 769ae95f60..92ccc14852 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1175,6 +1175,104 @@ struct MiniMaxUsageParserTests { #expect(attempt.snapshot.currentPrompts == 25) } + @Test + func `web enrichment requires verified group match before attaching summary`() async throws { + let summaryBody = """ + { + "daily_token_usage": [100], + "date_model_usage": [], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("usage_summary") { + return Self.httpResponse(url: url, body: summaryBody, contentType: "application/json") + } + let body = """ + { + "remaining_credits": 20000, + "credit_packages_details": [{"group_id":"verified-group"}], + "base_resp":{"status_code":0} + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + let quota = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + let context = MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=valid", + authorizationToken: nil, + region: .global, + environment: [:], + transport: transport) + + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: quota, + context: context, + groupID: "verified-group") + + #expect(attempt.receivedWebData) + #expect(attempt.snapshot.usageSummary?.latestSnapshotTokens == 100) + #expect(attempt.snapshot.pointsBalance == 20000) + } + + @Test + func `web enrichment ignores summary when credit group does not match`() async throws { + let summaryBody = """ + { + "daily_token_usage": [100], + "date_model_usage": [], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("usage_summary") { + return Self.httpResponse(url: url, body: summaryBody, contentType: "application/json") + } + let body = """ + { + "remaining_credits": 20000, + "credit_packages_details": [{"group_id":"other-group"}], + "base_resp":{"status_code":0} + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + let quota = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: 100, + currentPrompts: 25, + remainingPrompts: 75, + windowMinutes: 300, + usedPercent: 25, + resetsAt: nil, + updatedAt: Date()) + + let attempt = try await MiniMaxUsageFetcher.attemptWebEnrichment( + of: quota, + context: MiniMaxUsageFetcher.WebFetchContext( + cookie: "HERTZ-SESSION=valid", + authorizationToken: nil, + region: .global, + environment: [:], + transport: transport), + groupID: "expected-group") + + #expect(attempt.accountMismatch) + #expect(!attempt.receivedWebData) + #expect(attempt.snapshot.usageSummary == nil) + #expect(attempt.snapshot.pointsBalance == nil) + } + @Test func `web usage fetch keeps quota when billing history is forbidden`() async throws { let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) diff --git a/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift index 41f8d39296..cbbf2e14cf 100644 --- a/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift +++ b/Tests/CodexBarTests/MiniMaxResetDescriptionTests.swift @@ -121,4 +121,33 @@ struct MiniMaxResetDescriptionTests { #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/MiniMaxTokenPlanCreditTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift index 6b1073804a..6be3c49022 100644 --- a/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift +++ b/Tests/CodexBarTests/MiniMaxTokenPlanCreditTests.swift @@ -222,6 +222,23 @@ struct MiniMaxTokenPlanCreditTests { #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")) diff --git a/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift index d8e52b10eb..ca857fd4a9 100644 --- a/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift +++ b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift @@ -129,4 +129,27 @@ struct MiniMaxUsageSummaryTests { #expect(summary.last7DaysTokens == 600) #expect(summary.latestSnapshotTokens == 300) } + + @Test + func `login only usage summary maps to invalid credentials`() { + let body = """ + { + "daily_token_usage": [], + "date_model_usage": [], + "base_resp": { "status_code": 1004, "status_msg": "not login" } + } + """ + #expect(throws: MiniMaxUsageError.invalidCredentials) { + try MiniMaxUsageSummaryParser.parse(data: Data(body.utf8)) + } + } + + @Test + func `daily only summary anchors year from reference date near new year`() { + var calendar = Calendar.current + calendar.timeZone = TimeZone.current + let reference = calendar.date(from: DateComponents(year: 2026, month: 1, day: 1, hour: 12))! + let key = MiniMaxUsageSummary.dateKey(fromUpdateTime: "12-31 18:00", referenceDate: reference) + #expect(key == "2025-12-31") + } } From 7c3f53454cfb56b64df3c7f6f87a93c7d9a4488b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 16:32:00 +0000 Subject: [PATCH 48/53] Fix syntax errors in MiniMax test additions. Co-authored-by: Yuxin Qiao --- Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift | 3 +++ Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift | 1 + 2 files changed, 4 insertions(+) diff --git a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift index e6fd860337..2469651557 100644 --- a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift +++ b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift @@ -185,6 +185,9 @@ struct MenuBarMiniMaxResetTimeDisplayTests { let displayText = controller.menuBarDisplayText(for: .minimax, snapshot: snapshot) + #expect(displayText == "↻ \(UsageFormatter.resetDescription(from: sessionReset, now: now))") + } + @Test func `reset time mode honors explicit weekly metric preference`() { let settings = testSettingsStore(suiteName: "MenuBarMiniMaxResetTimeDisplayTests-explicit-weekly") diff --git a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift index f6a88e4af4..3c2cd34db5 100644 --- a/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift +++ b/Tests/CodexBarTests/MiniMaxDesktopCookieImporterTests.swift @@ -46,6 +46,7 @@ struct MiniMaxDesktopCookieImporterTests { } @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( From 93ede2aaa9ac5f464a41167fe4c248a074624c2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 16:47:08 +0000 Subject: [PATCH 49/53] Silence file_length lint for expanded MiniMax provider tests. Co-authored-by: Yuxin Qiao --- Tests/CodexBarTests/MiniMaxProviderTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 92ccc14852..0214e87b70 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1,3 +1,5 @@ +// swiftlint:disable file_length +// swiftlint:disable file_length import Foundation import Testing @testable import CodexBarCore From 222e39c540ad7745f4c0a711628a27071c910324 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 17:03:38 +0000 Subject: [PATCH 50/53] Remove duplicate swiftlint file_length disable. Co-authored-by: Yuxin Qiao --- Tests/CodexBarTests/MiniMaxProviderTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 0214e87b70..8667ef6920 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1,5 +1,4 @@ // swiftlint:disable file_length -// swiftlint:disable file_length import Foundation import Testing @testable import CodexBarCore From bfdcce8e7e562e884d9e3faf9f1594c5de803d41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 17:06:22 +0000 Subject: [PATCH 51/53] Fix MiniMax usage summary test lint force-unwrap. Co-authored-by: Yuxin Qiao --- Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift index ca857fd4a9..b345d43faa 100644 --- a/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift +++ b/Tests/CodexBarTests/MiniMaxUsageSummaryTests.swift @@ -145,10 +145,10 @@ struct MiniMaxUsageSummaryTests { } @Test - func `daily only summary anchors year from reference date near new year`() { + func `daily only summary anchors year from reference date near new year`() throws { var calendar = Calendar.current calendar.timeZone = TimeZone.current - let reference = calendar.date(from: DateComponents(year: 2026, month: 1, day: 1, hour: 12))! + let reference = try #require(calendar.date(from: DateComponents(year: 2026, month: 1, day: 1, hour: 12))) let key = MiniMaxUsageSummary.dateKey(fromUpdateTime: "12-31 18:00", referenceDate: reference) #expect(key == "2025-12-31") } From 5d1661d5b7bf3a69ccd8c2388b0426fb38baa2ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 17:19:09 +0000 Subject: [PATCH 52/53] Fix swiftlint type_body_length and superfluous disable. Co-authored-by: Yuxin Qiao --- Sources/CodexBar/StatusItemController.swift | 1 + Tests/CodexBarTests/MiniMaxProviderTests.swift | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 128ef62140..bb317a086f 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -34,6 +34,7 @@ extension StatusItemControlling { } @MainActor +// swiftlint:disable:next type_body_length final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControlling { // Disable SwiftUI menu cards + menu refresh work in tests to avoid swiftpm-testing-helper crashes. static var menuCardRenderingEnabled = !SettingsStore.isRunningTests diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index 8667ef6920..92ccc14852 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -1,4 +1,3 @@ -// swiftlint:disable file_length import Foundation import Testing @testable import CodexBarCore From ebe1d6e77dbf9b6a27b159284f94bbffa7ab19be Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 19:04:10 +0000 Subject: [PATCH 53/53] Fix MiniMax credit expiry merge and sync Galician locale keys. Co-authored-by: Yuxin Qiao --- .../Resources/gl.lproj/Localizable.strings | 31 +++++++++++++++++++ .../MiniMaxUsageSnapshot+Metadata.swift | 25 +++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index c716d497ee..54e44372e1 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1149,3 +1149,34 @@ "usage_percent_suffix_used" = "usado"; "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Non se atopou o token da API de z.ai. Define apiKey en ~/.codexbar/config.json ou Z_AI_API_KEY."; "≈ %d%% run-out risk" = "≈ %d%% de risco de esgotamento"; +"%@ input · %@ output · %@ cache hit" = "%@ input · %@ output · %@ cache hit"; +"%@ usage" = "%@ usage"; +"%@ · %@ input · %@ output · %@ cache hit" = "%@ · %@ input · %@ output · %@ cache hit"; +"7d" = "7d"; +"7d tokens" = "7d tokens"; +"Active days" = "Active days"; +"Active days: %@" = "Active days: %@"; +"Cache hit" = "Cache hit"; +"Cache hit: %@" = "Cache hit: %@"; +"Daily peak" = "Daily peak"; +"Day totals" = "Day totals"; +"Estimated from MiniMax usage summary and published pay-as-you-go pricing." = "Estimated from MiniMax usage summary and published pay-as-you-go pricing."; +"Expires: %@" = "Expires: %@"; +"Language models only; data may be delayed" = "Language models only; data may be delayed"; +"Last 7 days" = "Last 7 days"; +"Last 7 days: %@ tokens" = "Last 7 days: %@ tokens"; +"Latest usage" = "Latest usage"; +"MiniMax 7 day token usage trend" = "MiniMax 7 day token usage trend"; +"MiniMax web data endpoints are unavailable" = "MiniMax web data endpoints are unavailable"; +"MiniMax web session account does not match" = "MiniMax web session account does not match"; +"MiniMax web session expired" = "MiniMax web session expired"; +"MiniMax web session unavailable" = "MiniMax web session unavailable"; +"MiniMax web session: %@" = "MiniMax web session: %@"; +"No MiniMax browser session found" = "No MiniMax browser session found"; +"Open MiniMax login page" = "Open MiniMax login page"; +"Re-import MiniMax session from browser" = "Re-import MiniMax session from browser"; +"Streak: %@ days" = "Streak: %@ days"; +"Token usage details" = "Token usage details"; +"Total consumed" = "Total consumed"; +"Usage rank: top %@%%" = "Usage rank: top %@%%"; +"Usage trend" = "Usage trend"; diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift index e7f0e84a0c..bb724eef30 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -66,10 +66,29 @@ extension MiniMaxUsageSnapshot { } func withPointsBalanceIfMissing(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot { - guard let pointsBalance, pointsBalance >= 0, self.pointsBalance == nil else { - return self + if let pointsBalance, pointsBalance >= 0, self.pointsBalance == nil { + return self.withPointsBalanceFromDedicatedEndpoint(pointsBalance, expiresAt: expiresAt) + } + if self.pointsBalanceExpiresAt == nil, let expiresAt { + return MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + usageSummary: self.usageSummary, + pointsBalance: self.pointsBalance, + pointsBalanceExpiresAt: expiresAt, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + webSessionState: self.webSessionState) } - return self.withPointsBalanceFromDedicatedEndpoint(pointsBalance, expiresAt: expiresAt) + return self } func withPointsBalanceFromDedicatedEndpoint(_ pointsBalance: Double?, expiresAt: Date?) -> MiniMaxUsageSnapshot {