diff --git a/CHANGELOG.md b/CHANGELOG.md index 8510feaabfb..dfa553f4f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Claude: preserve model-specific weekly usage beside malformed limit entries in OAuth and Web responses, and ignore boolean percentages (#4614, #4126). Thanks @Akagilnc! - Grok: restore CLI billing through the ACP extension method, including current usage, reset, plan, and prepaid balance fields (#4595). Thanks @noisy! - Menu bar: merged Current selection icons show the selected user plugin's quota instead of falling back to a built-in provider (#4604). Thanks @soimy! - CLI: expose OpenCode Go's local daily and per-model token/cost history through `codexbar cost` (#4606). Thanks @h3x89! diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 68f085b9018..ec582237e27 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -179,7 +179,10 @@ enum ClaudeOAuthUsageFetcher { static func decodeUsageResponse(_ data: Data) throws -> OAuthUsageResponse { let decoder = JSONDecoder() - return try decoder.decode(OAuthUsageResponse.self, from: data) + var usage = try decoder.decode(OAuthUsageResponse.self, from: data) + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + usage.limits = json.map(ClaudeWebExtraRateWindowParser.scopedWeeklyLimits) + return usage } private static func retryAfterDate(from response: HTTPURLResponse, now: Date = Date()) -> Date? { @@ -292,7 +295,8 @@ struct OAuthUsageResponse: Decodable { /// Newer shape (superseding the flat `seven_day_*` fields above for scoped weekly /// windows): a flat list of limit entries, each optionally naming the model it scopes /// to via `scope.model.display_name` (e.g. "Fable" during a promotional access window). - let limits: [OAuthLimitEntry]? + /// Filled by `ClaudeOAuthUsageFetcher.decodeUsageResponse` with the Web path's entry parser. + fileprivate(set) var limits: [ClaudeScopedWeeklyLimitMapper.Limit]? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: DynamicCodingKey.self) @@ -314,7 +318,6 @@ struct OAuthUsageResponse: Decodable { self.sevenDayRoutinesSourceKey = routines.sourceKey self.cloudCredits = Self.decodeValue(in: container, keys: ["iguana_necktie"]) self.extraUsage = Self.decodeValue(in: container, keys: ["extra_usage"]) - self.limits = Self.decodeValue(in: container, keys: ["limits"]) self.resetStatus = Self.decodeValue(in: container, keys: ["cedar_ember"]) } @@ -377,41 +380,6 @@ struct OAuthUsageWindow: Decodable { } } -/// A single entry from the `limits` array. `kind`/`group` classify the limit -/// (e.g. `kind: "weekly_scoped"`, `group: "weekly"`); `scope.model.display_name` names the -/// model it applies to when the limit is scoped to one, rather than the whole account. -struct OAuthLimitEntry: Decodable { - let kind: String? - let group: String? - let percent: Double? - let resetsAt: String? - let scope: OAuthLimitScope? - let isActive: Bool? - - enum CodingKeys: String, CodingKey { - case kind - case group - case percent - case resetsAt = "resets_at" - case scope - case isActive = "is_active" - } -} - -struct OAuthLimitScope: Decodable { - let model: OAuthLimitScopeModel? -} - -struct OAuthLimitScopeModel: Decodable { - let id: String? - let displayName: String? - - enum CodingKeys: String, CodingKey { - case id - case displayName = "display_name" - } -} - struct OAuthExtraUsage: Decodable { let isEnabled: Bool? let monthlyLimit: Double? diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 1488d2f476c..3e92caf6d43 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -1067,19 +1067,10 @@ extension ClaudeUsageFetcher { } private static func oauthScopedWeeklyLimitWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { - let limits = usage.limits?.map { entry in - ClaudeScopedWeeklyLimitMapper.Limit( - kind: entry.kind, - group: entry.group, - percent: entry.percent, - resetsAt: ISO8601DateParser.parse(entry.resetsAt), - modelID: entry.scope?.model?.id, - modelName: entry.scope?.model?.displayName) - } // `is_active` is intentionally not a filter: observed enforceable scoped limits report false. - return ClaudeScopedWeeklyLimitMapper.extraRateWindows( - from: limits, - resetDescription: Self.formatResetDate) + ClaudeScopedWeeklyLimitMapper.extraRateWindows( + from: usage.limits, + resetDescription: self.formatResetDate) } // MARK: - Web API path (uses browser cookies) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift index 49592303f88..bac9839ace5 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift @@ -1,3 +1,4 @@ +import CoreFoundation import Foundation enum ClaudeWebExtraRateWindowParser { @@ -42,8 +43,14 @@ enum ClaudeWebExtraRateWindowParser { } private static func scopedWeeklyLimitWindows(from json: [String: Any]) -> [NamedRateWindow] { - guard let limits = json["limits"] as? [[String: Any]] else { return [] } - let mappedLimits = limits.map { entry in + ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: self.scopedWeeklyLimits(from: json)) + } + + /// Entry-by-entry parse of the `limits` array, shared with the OAuth path: a field of an + /// unexpected type reads as missing instead of discarding the entry or its siblings. + static func scopedWeeklyLimits(from json: [String: Any]) -> [ClaudeScopedWeeklyLimitMapper.Limit] { + guard let limits = json["limits"] as? [Any] else { return [] } + return limits.compactMap { $0 as? [String: Any] }.map { entry in let scope = entry["scope"] as? [String: Any] let model = scope?["model"] as? [String: Any] return ClaudeScopedWeeklyLimitMapper.Limit( @@ -54,7 +61,6 @@ enum ClaudeWebExtraRateWindowParser { modelID: model?["id"] as? String, modelName: model?["display_name"] as? String) } - return ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: mappedLimits) } private static func namedWindow( @@ -86,12 +92,9 @@ enum ClaudeWebExtraRateWindowParser { } private static func percentValue(from value: Any?) -> Double? { - if let intValue = value as? Int { - return Double(intValue) - } - if let doubleValue = value as? Double { - return doubleValue - } - return nil + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { return nil } + return number.doubleValue } } diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index e1b775cbc16..d2a642bf07c 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -217,6 +217,29 @@ struct ClaudeOAuthTests { #expect(fable?.window.resetsAt != nil) } + @Test + func `keeps Fable limit when a sibling entry or its own optional field has an unexpected type`() throws { + let json = """ + { + "five_hour": { "utilization": 11.0, "resets_at": "2026-07-03T00:30:00.282668+00:00" }, + "seven_day": { "utilization": 9.0, "resets_at": "2026-07-08T09:00:00.282694+00:00" }, + "limits": [ + null, + { "kind": "session", "group": "session", "percent": "11", "resets_at": null, "scope": null }, + { + "kind": "weekly_scoped", "group": "weekly", "percent": 5, "resets_at": 1791800000, + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + } + """ + let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + let fable = snap.extraRateWindows.first(where: { $0.id == "claude-weekly-scoped-fable" }) + #expect(fable?.window.usedPercent == 5) + #expect(fable?.window.resetsAt == nil) + } + @Test func `orders O auth scoped weekly windows before daily routines`() throws { let json = """ diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift index 452b70df704..49e62c8fd85 100644 --- a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -3,6 +3,21 @@ import Testing @testable import CodexBarCore struct ClaudeWebUsageExtraWindowTests { + @Test + func `boolean scoped percentages do not become zero or one percent`() throws { + let json = #""" + {"five_hour":{"utilization":12},"limits":[ + {"kind":"weekly_scoped","group":"weekly","percent":true,"scope":{"model":{"display_name":"Fable"}}}, + {"kind":"weekly_scoped","group":"weekly","percent":false,"scope":{"model":{"display_name":"Story"}}} + ]} + """# + let data = Data(json.utf8) + let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data) + #expect(web.extraRateWindows.isEmpty) + #expect(oauth.extraRateWindows.isEmpty) + } + @Test func `parses claude web API sonnet usage response`() throws { let json = """ diff --git a/docs/claude.md b/docs/claude.md index bfc28be4ed1..6753851875f 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -213,6 +213,7 @@ This cleanup applies to the reusable PTY session, not the separate one-shot usag - `seven_day` → weekly window; also becomes the primary fallback when `five_hour` is absent or has no utilization. - `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window. - `limits[].weekly_scoped` → model-specific weekly windows; generic `All models` scopes stay in the main weekly row. + OAuth and Web share per-entry parsing: malformed siblings or optional fields do not hide valid model rows. Boolean and string percentages are ignored. - The menu localizes scoped titles as a model name plus weekly duration; canonical snapshot and CLI titles remain unchanged. - Automatic and Session + Weekly menu bar metrics fall back to the most constrained known scoped weekly window when the regular quota windows are missing. Unknown scoped measurements remain unavailable; Extra usage stays a spend-only fallback. - `seven_day_routines` / `seven_day_cowork` → Daily Routines extra window.