From c385ba4f24c1932b52362ed987329ff3c0e148ec Mon Sep 17 00:00:00 2001 From: Diogo Oliveira de Melo Date: Tue, 7 Jul 2026 16:43:15 -0300 Subject: [PATCH] Add Fable per-model weekly usage Switch OAuthUsageService from probing rate-limit response headers (via a throwaway POST /v1/messages) to the structured GET /api/oauth/usage endpoint. The header approach only exposed session and all-models limits; the usage endpoint returns a `limits` array that also carries per-model scoped weekly limits (e.g. Fable) with display names, and no longer burns a message token on every refresh. - Models: add ScopedUsageLimit and WebUsageData.scopedLimits - ContentView: render a card per scoped weekly limit (Fable now, any future model-scoped limit automatically) - Menu bar: add an optional "F" ring alongside S/W, gated by a new "Show Fable usage" setting; fix ring width to reserve space for every separator (was clipping the trailing ring with 3+ segments) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- .../ClaudeCodeStats/ClaudeCodeStatsApp.swift | 17 ++- .../ClaudeCodeStats/ContentView.swift | 9 +- ClaudeCodeStats/ClaudeCodeStats/Models.swift | 12 ++ .../Services/OAuthUsageService.swift | 128 +++++++++++++----- .../ClaudeCodeStats/Views/SettingsView.swift | 7 + 6 files changed, 130 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 37485a6..d196070 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ There are no tests or linters configured. - **App entry point**: `ClaudeCodeStatsApp.swift` — `MenuBarExtra` with chart icon, red dot badge overlay for updates - **Main view**: `ContentView.swift` — contains the `UsageViewModel` (handles usage data + status polling) and all view components - **Services** (singletons, async/await): - - `OAuthUsageService` — fetches usage data from Anthropic API using OAuth credentials (reads `~/.claude/.credentials.json` first, falls back to macOS Keychain `Claude Code-credentials`), parsing rate limit response headers + - `OAuthUsageService` — fetches usage data from the Anthropic `GET /api/oauth/usage` endpoint using OAuth credentials (reads `~/.claude/.credentials.json` first, falls back to macOS Keychain `Claude Code-credentials`), decoding session, weekly all-models, and per-model scoped weekly limits (e.g. Fable) from the JSON `limits` array - `StatusService` — fetches health status from status.claude.com - `VersionService` — checks installed CLI version (`claude --version` via Process) and latest release from GitHub API; includes `UpdateChecker` ObservableObject for state management diff --git a/ClaudeCodeStats/ClaudeCodeStats/ClaudeCodeStatsApp.swift b/ClaudeCodeStats/ClaudeCodeStats/ClaudeCodeStatsApp.swift index f7ee452..f5da07a 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/ClaudeCodeStatsApp.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/ClaudeCodeStatsApp.swift @@ -7,9 +7,10 @@ struct ClaudeCodeStatsApp: App { @StateObject private var viewModel = UsageViewModel() @AppStorage("showSessionInMenuBar") private var showSession = false @AppStorage("showWeeklyInMenuBar") private var showWeekly = false + @AppStorage("showFableInMenuBar") private var showFable = false private var showRings: Bool { - showSession || showWeekly + showSession || showWeekly || showFable } var body: some Scene { @@ -22,9 +23,12 @@ struct ClaudeCodeStatsApp: App { if showRings { let sessionPct = viewModel.webUsage?.sessionUsage ?? 0 let weeklyPct = viewModel.webUsage?.weeklyUsage ?? 0 + let fablePct = viewModel.webUsage?.scopedLimits + .first(where: { $0.name == "Fable" })?.usage ?? 0 Image(nsImage: renderRings( session: showSession ? sessionPct : nil, - weekly: showWeekly ? weeklyPct : nil + weekly: showWeekly ? weeklyPct : nil, + fable: showFable ? fablePct : nil )) } else { Image(systemName: "chart.bar.fill") @@ -47,7 +51,7 @@ struct ClaudeCodeStatsApp: App { .menuBarExtraStyle(.window) } - private func renderRings(session: Double?, weekly: Double?) -> NSImage { + private func renderRings(session: Double?, weekly: Double?, fable: Double?) -> NSImage { let height: CGFloat = 18 let ringSize: CGFloat = 14 let ringLineWidth: CGFloat = 2.5 @@ -59,16 +63,17 @@ struct ClaudeCodeStatsApp: App { var segments: [(String, Double)] = [] if let session { segments.append(("S", session)) } if let weekly { segments.append(("W", weekly)) } + if let fable { segments.append(("F", fable)) } // Measure total width - let separatorWidth: CGFloat = segments.count > 1 - ? (" | " as NSString).size(withAttributes: textAttrs).width : 0 + let separatorWidth: CGFloat = (" | " as NSString).size(withAttributes: textAttrs).width var totalWidth: CGFloat = 0 for (label, _) in segments { let labelSize = (label as NSString).size(withAttributes: textAttrs) totalWidth += labelSize.width + 2 + ringSize // label + gap + ring } - totalWidth += separatorWidth + // One separator between each pair of segments (count - 1 total) + totalWidth += separatorWidth * CGFloat(max(0, segments.count - 1)) let image = NSImage(size: NSSize(width: totalWidth, height: height), flipped: false) { _ in guard let ctx = NSGraphicsContext.current?.cgContext else { return false } diff --git a/ClaudeCodeStats/ClaudeCodeStats/ContentView.swift b/ClaudeCodeStats/ClaudeCodeStats/ContentView.swift index 02bd18e..d4fa282 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/ContentView.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/ContentView.swift @@ -119,7 +119,14 @@ struct ContentView: View { resetsAt: usage.weeklyResetsAt ) -} + ForEach(usage.scopedLimits) { limit in + UsageCardView( + title: "Weekly Limit (\(limit.name))", + usage: limit.usage, + resetsAt: limit.resetsAt + ) + } + } .padding(12) } diff --git a/ClaudeCodeStats/ClaudeCodeStats/Models.swift b/ClaudeCodeStats/ClaudeCodeStats/Models.swift index d7abb73..6b01f13 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/Models.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/Models.swift @@ -1,10 +1,21 @@ import Foundation +// A weekly limit scoped to a specific model (e.g. "Fable"), shown alongside +// the overall session and all-models limits. +struct ScopedUsageLimit: Identifiable { + let name: String + let usage: Double + let resetsAt: Date + + var id: String { name } +} + struct WebUsageData { let sessionUsage: Double let sessionResetsAt: Date let weeklyUsage: Double let weeklyResetsAt: Date + let scopedLimits: [ScopedUsageLimit] let lastUpdated: Date static var empty: WebUsageData { @@ -13,6 +24,7 @@ struct WebUsageData { sessionResetsAt: Date(), weeklyUsage: 0, weeklyResetsAt: Date(), + scopedLimits: [], lastUpdated: Date() ) } diff --git a/ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift b/ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift index 8611e5e..6b861f6 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/Services/OAuthUsageService.swift @@ -5,7 +5,7 @@ import Security class OAuthUsageService { static let shared = OAuthUsageService() - private let apiURL = "https://api.anthropic.com/v1/messages" + private let usageURL = "https://api.anthropic.com/api/oauth/usage" private let credentialsPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/.claude/.credentials.json" @@ -33,27 +33,19 @@ class OAuthUsageService { throw UsageError.noCredentials } - guard let url = URL(string: apiURL) else { + guard let url = URL(string: usageURL) else { throw UsageError.invalidResponse } var request = URLRequest(url: url) - request.httpMethod = "POST" + request.httpMethod = "GET" request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "content-type") - let body: [String: Any] = [ - "model": "claude-haiku-4-5-20251001", - "max_tokens": 1, - "messages": [["role": "user", "content": "hi"]] - ] - request.httpBody = try JSONSerialization.data(withJSONObject: body) - - let (_, response): (Data, URLResponse) + let (data, response): (Data, URLResponse) do { - (_, response) = try await withRetry { + (data, response) = try await withRetry { try await session.data(for: request) } } catch { @@ -69,12 +61,11 @@ class OAuthUsageService { throw UsageError.tokenExpired } - // 2xx and 429 both carry valid rate-limit headers - guard (200...299).contains(httpResponse.statusCode) || httpResponse.statusCode == 429 else { + guard (200...299).contains(httpResponse.statusCode) else { throw UsageError.invalidResponse } - return parseHeaders(httpResponse) + return try parseUsage(data) } private func readAccessToken() -> String? { @@ -192,35 +183,98 @@ class OAuthUsageService { return token } - private func parseHeaders(_ response: HTTPURLResponse) -> WebUsageData { - let headers = response.allHeaderFields + // Shape of the /api/oauth/usage JSON response (only the fields we consume). + private struct UsageResponse: Decodable { + let fiveHour: Window? + let sevenDay: Window? + let limits: [Limit]? + + enum CodingKeys: String, CodingKey { + case fiveHour = "five_hour" + case sevenDay = "seven_day" + case limits + } + + struct Window: Decodable { + let utilization: Double? + let resetsAt: String? + + enum CodingKeys: String, CodingKey { + case utilization + case resetsAt = "resets_at" + } + } - let sessionUtilization = headerDouble(headers, key: "anthropic-ratelimit-unified-5h-utilization") - let sessionReset = headerDate(headers, key: "anthropic-ratelimit-unified-5h-reset") - let weeklyUtilization = headerDouble(headers, key: "anthropic-ratelimit-unified-7d-utilization") - let weeklyReset = headerDate(headers, key: "anthropic-ratelimit-unified-7d-reset") + struct Limit: Decodable { + let kind: String + let percent: Double? + let resetsAt: String? + let scope: Scope? + + enum CodingKeys: String, CodingKey { + case kind, percent, scope + case resetsAt = "resets_at" + } + + struct Scope: Decodable { + let model: Model? + + struct Model: Decodable { + let displayName: String? + + enum CodingKeys: String, CodingKey { + case displayName = "display_name" + } + } + } + } + } + + private func parseUsage(_ data: Data) throws -> WebUsageData { + guard let decoded = try? JSONDecoder().decode(UsageResponse.self, from: data) else { + throw UsageError.invalidResponse + } + + // Weekly limits scoped to a specific model (e.g. Fable) live only in the + // `limits` array — render each as its own card. + let scopedLimits: [ScopedUsageLimit] = (decoded.limits ?? []).compactMap { limit in + guard limit.kind == "weekly_scoped", + let name = limit.scope?.model?.displayName, !name.isEmpty else { + return nil + } + return ScopedUsageLimit( + name: name, + usage: limit.percent ?? 0, + resetsAt: parseDate(limit.resetsAt) ?? Date() + ) + } return WebUsageData( - sessionUsage: sessionUtilization * 100, - sessionResetsAt: sessionReset ?? Date(), - weeklyUsage: weeklyUtilization * 100, - weeklyResetsAt: weeklyReset ?? Date(), + sessionUsage: decoded.fiveHour?.utilization ?? 0, + sessionResetsAt: parseDate(decoded.fiveHour?.resetsAt) ?? Date(), + weeklyUsage: decoded.sevenDay?.utilization ?? 0, + weeklyResetsAt: parseDate(decoded.sevenDay?.resetsAt) ?? Date(), + scopedLimits: scopedLimits, lastUpdated: Date() ) } - private func headerDouble(_ headers: [AnyHashable: Any], key: String) -> Double { - if let value = headers[key] as? String, let num = Double(value) { - return num - } - return 0 - } + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() - private func headerDate(_ headers: [AnyHashable: Any], key: String) -> Date? { - if let value = headers[key] as? String, let timestamp = TimeInterval(value) { - return Date(timeIntervalSince1970: timestamp) - } - return nil + private static let isoFormatterNoFraction: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private func parseDate(_ string: String?) -> Date? { + guard let string else { return nil } + return Self.isoFormatter.date(from: string) + ?? Self.isoFormatterNoFraction.date(from: string) } private func withRetry( diff --git a/ClaudeCodeStats/ClaudeCodeStats/Views/SettingsView.swift b/ClaudeCodeStats/ClaudeCodeStats/Views/SettingsView.swift index 52c7f72..b29a8ff 100644 --- a/ClaudeCodeStats/ClaudeCodeStats/Views/SettingsView.swift +++ b/ClaudeCodeStats/ClaudeCodeStats/Views/SettingsView.swift @@ -4,6 +4,7 @@ struct SettingsView: View { @Binding var isPresented: Bool @AppStorage("showSessionInMenuBar") private var showSession = false @AppStorage("showWeeklyInMenuBar") private var showWeekly = false + @AppStorage("showFableInMenuBar") private var showFable = false var body: some View { VStack(spacing: 0) { @@ -93,6 +94,12 @@ struct SettingsView: View { .foregroundColor(Theme.textSecondary) .toggleStyle(.switch) .controlSize(.mini) + + Toggle("Show Fable usage", isOn: $showFable) + .font(.system(size: 11)) + .foregroundColor(Theme.textSecondary) + .toggleStyle(.switch) + .controlSize(.mini) } .padding(12) .background(Theme.cardBackground)