diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index a7aebd1..e62c7f6 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -915,6 +915,24 @@ final class StatusBarController: NSObject { return details.dailyUsage } + private func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? { + guard let details else { return nil } + + let configuredPlan = SubscriptionSettingsManager.shared.getPlan(for: .chutes) + let configuredCapUSD = configuredPlan.isSet + ? configuredPlan.cost * ChutesProvider.monthlyValueMultiplier + : nil + let capUSD = configuredCapUSD ?? details.chutesMonthlyValueCapUSD + + if let usedUSD = details.chutesMonthlyValueUsedUSD, + let capUSD, + capUSD > 0 { + return min(max((usedUSD / capUSD) * 100.0, 0), 999) + } + + return details.chutesMonthlyValueUsedPercent + } + private func usagePercentCandidates( identifier: ProviderIdentifier, usage: ProviderUsage, @@ -954,6 +972,7 @@ final class StatusBarController: NSObject { case .nanoGpt: add(details?.sevenDayUsage, priority: .weekly) case .chutes: + add(chutesMonthlyPercentFromDetails(details), priority: .monthly) add(dailyPercentFromDetails(details), priority: .daily) case .synthetic: add(details?.fiveHourUsage, priority: .hourly) @@ -1910,6 +1929,9 @@ final class StatusBarController: NSObject { } else if identifier == .zaiCodingPlan { let percents = [account.details?.tokenUsagePercent, account.details?.mcpUsagePercent].compactMap { $0 } usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents + } else if identifier == .chutes { + let percents = [dailyPercentFromDetails(account.details), chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 } + usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents } else if identifier == .nanoGpt { let percents = [ account.details?.sevenDayUsage, @@ -1963,6 +1985,9 @@ final class StatusBarController: NSObject { } else if identifier == .zaiCodingPlan { let percents = [result.details?.tokenUsagePercent, result.details?.mcpUsagePercent].compactMap { $0 } usedPercents = percents.isEmpty ? [singlePercent] : percents + } else if identifier == .chutes { + let percents = [dailyPercentFromDetails(result.details), chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 } + usedPercents = percents.isEmpty ? [singlePercent] : percents } else if identifier == .nanoGpt { let percents = [ result.details?.sevenDayUsage, diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 6b891b2..ba313b4 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -573,6 +573,12 @@ extension StatusBarController { addSubscriptionItems(to: submenu, provider: .nanoGpt, accountId: accountId) case .chutes: + if let plan = details.planType { + let item = NSMenuItem() + item.view = createDisabledLabelView(text: "Plan: \(plan)") + submenu.addItem(item) + } + if let daily = details.dailyUsage, let limit = details.limit { let used = Int(daily) @@ -580,24 +586,38 @@ extension StatusBarController { let percentage = total > 0 ? Int((Double(used) / Double(total)) * 100) : 0 let item = NSMenuItem() - item.view = createDisabledLabelView(text: String(format: "Daily: %d%% used (%d/%d)", percentage, used, total)) + item.view = createDisabledLabelView(text: String(format: "Daily Requests: %d / %d (%.0f%% used)", used, total, Double(percentage))) submenu.addItem(item) } - submenu.addItem(NSMenuItem.separator()) + let chutesMonthlyValue = resolvedChutesMonthlyValuePresentation(details: details) - if let plan = details.planType { + if let usedUSD = chutesMonthlyValue.usedUSD, + let capUSD = chutesMonthlyValue.capUSD, + let usedPercent = chutesMonthlyValue.usedPercent { let item = NSMenuItem() - item.view = createDisabledLabelView(text: "Plan: \(plan)") + item.view = createDisabledLabelView( + text: String(format: "Monthly Value Used: $%.2f / $%.2f (%.0f%% used)", usedUSD, capUSD, usedPercent) + ) + submenu.addItem(item) + } else if let capUSD = chutesMonthlyValue.capUSD { + let item = NSMenuItem() + item.view = createDisabledLabelView( + text: String(format: "Monthly Cap: $%.2f (5× subscription)", capUSD) + ) submenu.addItem(item) } if let credits = details.creditsBalance { let item = NSMenuItem() - item.view = createDisabledLabelView(text: String(format: "Credits: $%.2f", credits)) + item.view = createDisabledLabelView(text: String(format: "Credits Balance: $%.2f", credits)) submenu.addItem(item) } + let overageItem = NSMenuItem() + overageItem.view = createDisabledLabelView(text: "Overage: PAYGO after cap") + submenu.addItem(overageItem) + addSubscriptionItems(to: submenu, provider: .chutes) case .synthetic: @@ -721,6 +741,24 @@ extension StatusBarController { }?.email } + private func resolvedChutesMonthlyValuePresentation(details: DetailedUsage) -> (usedUSD: Double?, capUSD: Double?, usedPercent: Double?) { + let configuredPlan = SubscriptionSettingsManager.shared.getPlan(for: .chutes) + let configuredCapUSD = configuredPlan.isSet + ? configuredPlan.cost * ChutesProvider.monthlyValueMultiplier + : nil + let capUSD = configuredCapUSD ?? details.chutesMonthlyValueCapUSD + let usedUSD = details.chutesMonthlyValueUsedUSD + + let usedPercent: Double? + if let usedUSD, let capUSD, capUSD > 0 { + usedPercent = min(max((usedUSD / capUSD) * 100.0, 0), 999) + } else { + usedPercent = details.chutesMonthlyValueUsedPercent + } + + return (usedUSD, capUSD, usedPercent) + } + private func addGroupedModelUsageSection( to submenu: NSMenu, modelBreakdown: [String: Double], diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift index 332da35..fa1dfc1 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift @@ -102,6 +102,11 @@ struct DetailedUsage { let creditsBalance: Double? let planType: String? + // Chutes-specific value cap tracking + let chutesMonthlyValueCapUSD: Double? + let chutesMonthlyValueUsedUSD: Double? + let chutesMonthlyValueUsedPercent: Double? + // Claude extra usage toggle let extraUsageEnabled: Bool? // Claude extra usage (monthly credits limit + usage) @@ -184,6 +189,9 @@ struct DetailedUsage { sparkWindowLabel: String? = nil, creditsBalance: Double? = nil, planType: String? = nil, + chutesMonthlyValueCapUSD: Double? = nil, + chutesMonthlyValueUsedUSD: Double? = nil, + chutesMonthlyValueUsedPercent: Double? = nil, extraUsageEnabled: Bool? = nil, extraUsageMonthlyLimitUSD: Double? = nil, extraUsageUsedUSD: Double? = nil, @@ -247,6 +255,9 @@ struct DetailedUsage { self.sparkWindowLabel = sparkWindowLabel self.creditsBalance = creditsBalance self.planType = planType + self.chutesMonthlyValueCapUSD = chutesMonthlyValueCapUSD + self.chutesMonthlyValueUsedUSD = chutesMonthlyValueUsedUSD + self.chutesMonthlyValueUsedPercent = chutesMonthlyValueUsedPercent self.extraUsageEnabled = extraUsageEnabled self.extraUsageMonthlyLimitUSD = extraUsageMonthlyLimitUSD self.extraUsageUsedUSD = extraUsageUsedUSD @@ -292,7 +303,9 @@ extension DetailedUsage: Codable { case sonnetUsage, sonnetReset, opusUsage, opusReset, modelBreakdown, modelResetTimes case secondaryUsage, secondaryReset, primaryReset case sparkUsage, sparkReset, sparkSecondaryUsage, sparkSecondaryReset, sparkWindowLabel - case creditsBalance, planType, extraUsageEnabled + case creditsBalance, planType + case chutesMonthlyValueCapUSD, chutesMonthlyValueUsedUSD, chutesMonthlyValueUsedPercent + case extraUsageEnabled case extraUsageMonthlyLimitUSD, extraUsageUsedUSD, extraUsageUtilizationPercent case sessions, messages, avgCostPerDay, email case dailyHistory, monthlyCost, creditsRemaining, creditsTotal @@ -334,6 +347,9 @@ extension DetailedUsage: Codable { sparkWindowLabel = try container.decodeIfPresent(String.self, forKey: .sparkWindowLabel) creditsBalance = try container.decodeIfPresent(Double.self, forKey: .creditsBalance) planType = try container.decodeIfPresent(String.self, forKey: .planType) + chutesMonthlyValueCapUSD = try container.decodeIfPresent(Double.self, forKey: .chutesMonthlyValueCapUSD) + chutesMonthlyValueUsedUSD = try container.decodeIfPresent(Double.self, forKey: .chutesMonthlyValueUsedUSD) + chutesMonthlyValueUsedPercent = try container.decodeIfPresent(Double.self, forKey: .chutesMonthlyValueUsedPercent) extraUsageEnabled = try container.decodeIfPresent(Bool.self, forKey: .extraUsageEnabled) extraUsageMonthlyLimitUSD = try container.decodeIfPresent(Double.self, forKey: .extraUsageMonthlyLimitUSD) extraUsageUsedUSD = try container.decodeIfPresent(Double.self, forKey: .extraUsageUsedUSD) @@ -400,6 +416,9 @@ extension DetailedUsage: Codable { try container.encodeIfPresent(sparkWindowLabel, forKey: .sparkWindowLabel) try container.encodeIfPresent(creditsBalance, forKey: .creditsBalance) try container.encodeIfPresent(planType, forKey: .planType) + try container.encodeIfPresent(chutesMonthlyValueCapUSD, forKey: .chutesMonthlyValueCapUSD) + try container.encodeIfPresent(chutesMonthlyValueUsedUSD, forKey: .chutesMonthlyValueUsedUSD) + try container.encodeIfPresent(chutesMonthlyValueUsedPercent, forKey: .chutesMonthlyValueUsedPercent) try container.encodeIfPresent(extraUsageEnabled, forKey: .extraUsageEnabled) try container.encodeIfPresent(extraUsageMonthlyLimitUSD, forKey: .extraUsageMonthlyLimitUSD) try container.encodeIfPresent(extraUsageUsedUSD, forKey: .extraUsageUsedUSD) @@ -784,6 +803,7 @@ extension DetailedUsage { || secondaryUsage != nil || secondaryReset != nil || primaryReset != nil || sparkUsage != nil || sparkReset != nil || sparkSecondaryUsage != nil || sparkSecondaryReset != nil || sparkWindowLabel != nil || creditsBalance != nil || planType != nil + || chutesMonthlyValueCapUSD != nil || chutesMonthlyValueUsedUSD != nil || chutesMonthlyValueUsedPercent != nil || extraUsageEnabled != nil || extraUsageMonthlyLimitUSD != nil || extraUsageUsedUSD != nil || extraUsageUtilizationPercent != nil || sessions != nil || messages != nil || avgCostPerDay != nil diff --git a/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift index 344793c..d9b086a 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift @@ -51,8 +51,8 @@ struct ChutesQuotaUsage: Codable { // MARK: - ChutesProvider Implementation /// Provider for Chutes AI API usage tracking -/// Uses quota-based model with daily limits (300/2000/5000 per day) -/// Combines data from /users/me/quotas and /users/me/quota_usage/* endpoints +/// Uses quota-based model with short-window daily limits and monthly 5× value cap tracking. +/// Combines data from /users/me, /users/me/quotas, /users/me/quota_usage/*, and /users/{user_id}/usage. final class ChutesProvider: ProviderProtocol { let identifier: ProviderIdentifier = .chutes let type: ProviderType = .quotaBased @@ -60,6 +60,8 @@ final class ChutesProvider: ProviderProtocol { private let tokenManager: TokenManager private let session: URLSession + static let monthlyValueMultiplier = 5.0 + init(tokenManager: TokenManager = .shared, session: URLSession = .shared) { self.tokenManager = tokenManager self.session = session @@ -94,18 +96,32 @@ final class ChutesProvider: ProviderProtocol { let quota = quotaItem.quota let used = usage.used let remaining = max(0, quota - used) - // Guard against division by zero: treat 0 quota as 0% used - let usedPercentage = quota > 0 ? min(100, Int((Double(used) / Double(quota)) * 100)) : 0 - let remainingPercentage = max(0, 100 - usedPercentage) + let dailyUsedPercent = quota > 0 ? min((Double(used) / Double(quota)) * 100.0, 999.0) : 0 let planTier = Self.getPlanTier(from: quota) + let monthlySubscriptionCost = Self.inferredMonthlySubscriptionCost(planTier: planTier) + let monthlyValueCapUSD = monthlySubscriptionCost.map { $0 * Self.monthlyValueMultiplier } + let monthlyValueUsedUSD = await resolveMonthlyValueUsedUSD( + apiKey: apiKey, + userId: userProfile.userId, + monthlyValueCapUSD: monthlyValueCapUSD, + balance: userProfile.balance + ) + let monthlyValueUsedPercent = Self.calculateMonthlyValueUsedPercent( + usedUSD: monthlyValueUsedUSD, + capUSD: monthlyValueCapUSD + ) + let representativeUsedPercent = monthlyValueUsedPercent ?? dailyUsedPercent + let remainingPercentage = max(0, 100 - Int(representativeUsedPercent.rounded())) - logger.info("Chutes fetched: \(used)/\(quota) used (\(usedPercentage)%), tier: \(planTier), balance: \(userProfile.balance)") + logger.info( + "Chutes fetched: \(used)/\(quota) daily requests used (\(Int(dailyUsedPercent.rounded()))%), tier: \(planTier), monthly cap: \(monthlyValueCapUSD ?? 0), monthly value used: \(monthlyValueUsedUSD ?? -1), balance: \(userProfile.balance)" + ) let providerUsage = ProviderUsage.quotaBased( remaining: remainingPercentage, entitlement: 100, - overagePermitted: false + overagePermitted: true ) let resetPeriod: String @@ -123,6 +139,9 @@ final class ChutesProvider: ProviderProtocol { resetPeriod: resetPeriod, creditsBalance: userProfile.balance, planType: planTier, + chutesMonthlyValueCapUSD: monthlyValueCapUSD, + chutesMonthlyValueUsedUSD: monthlyValueUsedUSD, + chutesMonthlyValueUsedPercent: monthlyValueUsedPercent, authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" ) @@ -230,6 +249,87 @@ final class ChutesProvider: ProviderProtocol { } } + private func fetchMonthlyUsageSummary(apiKey: String, userId: String, startDate: String, endDate: String) async throws -> Any { + let encodedUserId = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId + var components = URLComponents(string: "https://api.chutes.ai/users/\(encodedUserId)/usage") + components?.queryItems = [ + URLQueryItem(name: "page", value: "1"), + URLQueryItem(name: "limit", value: "500"), + URLQueryItem(name: "start_date", value: startDate), + URLQueryItem(name: "end_date", value: endDate) + ] + + guard let url = components?.url else { + throw ProviderError.networkError("Invalid usage summary URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(apiKey, forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ProviderError.networkError("Invalid usage summary response type") + } + + if httpResponse.statusCode == 401 { + throw ProviderError.authenticationFailed("API key invalid") + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw ProviderError.networkError("Usage summary HTTP \(httpResponse.statusCode)") + } + + do { + return try JSONSerialization.jsonObject(with: data) + } catch { + logger.error("Failed to decode Chutes usage summary JSON: \(error.localizedDescription)") + throw ProviderError.decodingError("Invalid usage summary format") + } + } + + private func resolveMonthlyValueUsedUSD( + apiKey: String, + userId: String, + monthlyValueCapUSD: Double?, + balance: Double + ) async -> Double? { + guard let monthlyValueCapUSD, monthlyValueCapUSD > 0 else { + return nil + } + + do { + let (startDate, endDate) = Self.currentMonthDateRangeStrings() + let usageSummary = try await fetchMonthlyUsageSummary( + apiKey: apiKey, + userId: userId, + startDate: startDate, + endDate: endDate + ) + + if let extractedValue = Self.extractMonthlyValueUsedUSD(from: usageSummary) { + logger.debug("Using Chutes usage endpoint for monthly value used: \(extractedValue, privacy: .public)") + return max(0, extractedValue) + } + + logger.warning("Chutes usage summary did not expose a recognized monthly USD field") + } catch { + logger.warning("Failed to load Chutes monthly usage summary: \(error.localizedDescription, privacy: .public)") + } + + if balance >= 0, balance <= monthlyValueCapUSD { + let inferredUsed = max(0, monthlyValueCapUSD - balance) + logger.debug( + "Using Chutes balance fallback for monthly value used: cap=\(monthlyValueCapUSD, privacy: .public), balance=\(balance, privacy: .public), used=\(inferredUsed, privacy: .public)" + ) + return inferredUsed + } + + return nil + } + private static func getPlanTier(from quota: Int) -> String { switch quota { case 300: @@ -243,6 +343,114 @@ final class ChutesProvider: ProviderProtocol { } } + static func inferredMonthlySubscriptionCost(planTier: String) -> Double? { + switch planTier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "base": + return 3 + case "plus": + return 10 + case "pro": + return 20 + default: + return nil + } + } + + static func calculateMonthlyValueUsedPercent(usedUSD: Double?, capUSD: Double?) -> Double? { + guard let usedUSD, let capUSD, capUSD > 0 else { return nil } + return min(max((usedUSD / capUSD) * 100.0, 0), 999) + } + + static func extractMonthlyValueUsedUSD(from json: Any) -> Double? { + if let dictionary = json as? [String: Any] { + if let aggregate = numericValue( + forAnyOf: [ + "total_cost_usd", "total_cost", "cost_usd", "cost", + "paygo_equivalent_usd", "paygo_equivalent", + "amount_usd", "billable_usd", "value_received_usd" + ], + in: dictionary + ) { + return aggregate + } + + for key in ["summary", "totals", "aggregate", "aggregates", "usage_summary", "result"] { + if let nested = dictionary[key], let value = extractMonthlyValueUsedUSD(from: nested) { + return value + } + } + + for key in ["items", "results", "data", "usage", "rows", "entries"] { + if let array = dictionary[key] as? [Any], let value = sumMonthlyValueUsedUSD(in: array) { + return value + } + } + + return nil + } + + if let array = json as? [Any] { + return sumMonthlyValueUsedUSD(in: array) + } + + return nil + } + + private static func sumMonthlyValueUsedUSD(in array: [Any]) -> Double? { + var total: Double = 0 + var found = false + + for element in array { + guard let dictionary = element as? [String: Any] else { continue } + if let value = numericValue( + forAnyOf: [ + "total_cost_usd", "total_cost", "cost_usd", "cost", + "paygo_equivalent_usd", "paygo_equivalent", + "amount_usd", "billable_usd", "value_received_usd" + ], + in: dictionary + ) { + total += value + found = true + } + } + + return found ? total : nil + } + + private static func numericValue(forAnyOf keys: [String], in dictionary: [String: Any]) -> Double? { + for key in keys { + if let value = numericValue(from: dictionary[key]) { + return value + } + } + return nil + } + + private static func numericValue(from value: Any?) -> Double? { + switch value { + case let number as NSNumber: + return number.doubleValue + case let string as String: + return Double(string) + default: + return nil + } + } + + static func currentMonthDateRangeStrings(referenceDate: Date = Date()) -> (String, String) { + var calendar = Calendar.current + calendar.timeZone = TimeZone(identifier: "UTC") ?? TimeZone.current + + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + + let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: referenceDate)) ?? referenceDate + return (formatter.string(from: startOfMonth), formatter.string(from: referenceDate)) + } + private static func parseISO8601Date(_ string: String) -> Date? { let formatterWithFrac = ISO8601DateFormatter() formatterWithFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/CopilotMonitor/CopilotMonitorTests/ProviderUsageTests.swift b/CopilotMonitor/CopilotMonitorTests/ProviderUsageTests.swift index 4ceffb1..4f881e7 100644 --- a/CopilotMonitor/CopilotMonitorTests/ProviderUsageTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ProviderUsageTests.swift @@ -92,6 +92,55 @@ final class ProviderUsageTests: XCTestCase { ) } + func testChutesInferredMonthlySubscriptionCostUsesKnownPlanTiers() { + XCTAssertEqual(ChutesProvider.inferredMonthlySubscriptionCost(planTier: "Base"), 3) + XCTAssertEqual(ChutesProvider.inferredMonthlySubscriptionCost(planTier: "Plus"), 10) + XCTAssertEqual(ChutesProvider.inferredMonthlySubscriptionCost(planTier: "Pro"), 20) + XCTAssertNil(ChutesProvider.inferredMonthlySubscriptionCost(planTier: "Unknown")) + } + + func testChutesCalculateMonthlyValueUsedPercent() { + XCTAssertEqual(ChutesProvider.calculateMonthlyValueUsedPercent(usedUSD: 34, capUSD: 50), 68) + XCTAssertNil(ChutesProvider.calculateMonthlyValueUsedPercent(usedUSD: nil, capUSD: 50)) + XCTAssertNil(ChutesProvider.calculateMonthlyValueUsedPercent(usedUSD: 10, capUSD: 0)) + } + + func testChutesExtractMonthlyValueUsedUSDPrefersAggregateFields() { + let payload: [String: Any] = [ + "summary": [ + "total_cost_usd": 34.25 + ], + "items": [ + ["cost_usd": 10.0], + ["cost_usd": 20.0] + ] + ] + + XCTAssertEqual(ChutesProvider.extractMonthlyValueUsedUSD(from: payload), 34.25) + } + + func testChutesExtractMonthlyValueUsedUSDSumsRecognizedItemFields() { + let payload: [String: Any] = [ + "items": [ + ["cost_usd": 12.5], + ["total_cost": "7.25"], + ["ignored": 99] + ] + ] + + XCTAssertEqual(ChutesProvider.extractMonthlyValueUsedUSD(from: payload), 19.75) + } + + func testChutesCurrentMonthDateRangeUsesUTCMonthBoundaries() throws { + let formatter = ISO8601DateFormatter() + let referenceDate = try XCTUnwrap(formatter.date(from: "2026-03-01T00:30:00+14:00")) + + let range = ChutesProvider.currentMonthDateRangeStrings(referenceDate: referenceDate) + + XCTAssertEqual(range.0, "2026-02-01") + XCTAssertEqual(range.1, "2026-02-28") + } + func testTableFormatterShowsZaiDualPercentWhenBothWindowsExist() { let usage = ProviderUsage.quotaBased(remaining: 30, entitlement: 100, overagePermitted: false) let details = DetailedUsage(tokenUsagePercent: 70, mcpUsagePercent: 40)