From e1a6864be6939d4b417e2d6c627c5fae32e8d0a6 Mon Sep 17 00:00:00 2001 From: Akagilnc Date: Sun, 18 Oct 2026 19:28:53 +0900 Subject: [PATCH 1/4] fix(claude): share the per-entry limits parser between OAuth and Web OAuth decoded the whole `limits` array as one typed value, so a single entry with an unexpected field type dropped every model-scoped weekly row. Parse the array with the Web path's entry-by-entry parser instead and remove the OAuth-only limit types. Co-Authored-By: claude-sonnet-5-5 --- CHANGELOG.md | 4 ++ .../ClaudeOAuth/ClaudeOAuthUsageFetcher.swift | 44 ++----------- .../Providers/Claude/ClaudeUsageFetcher.swift | 15 +---- .../ClaudeWebExtraRateWindowParser.swift | 9 ++- .../ClaudeLimitsEntryParsingTests.swift | 62 +++++++++++++++++++ 5 files changed, 82 insertions(+), 52 deletions(-) create mode 100644 Tests/CodexBarTests/ClaudeLimitsEntryParsingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 636ab772be1..5b33630b584 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.77.1 — Unreleased +### Fixed + +- Claude: parse OAuth usage `limits` entry by entry with the Web parser, so one malformed entry no longer hides the model-scoped weekly rows beside it (#4126). Thanks @Akagilnc! + ## 0.77.0 — 2026-10-16 ### Highlights diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 68f085b9018..208e438a321 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..293789f1532 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift @@ -42,8 +42,14 @@ enum ClaudeWebExtraRateWindowParser { } private static func scopedWeeklyLimitWindows(from json: [String: Any]) -> [NamedRateWindow] { + 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? [[String: Any]] else { return [] } - let mappedLimits = limits.map { entry in + return limits.map { entry in let scope = entry["scope"] as? [String: Any] let model = scope?["model"] as? [String: Any] return ClaudeScopedWeeklyLimitMapper.Limit( @@ -54,7 +60,6 @@ enum ClaudeWebExtraRateWindowParser { modelID: model?["id"] as? String, modelName: model?["display_name"] as? String) } - return ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: mappedLimits) } private static func namedWindow( diff --git a/Tests/CodexBarTests/ClaudeLimitsEntryParsingTests.swift b/Tests/CodexBarTests/ClaudeLimitsEntryParsingTests.swift new file mode 100644 index 00000000000..8154402c165 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeLimitsEntryParsingTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// OAuth and Web parse the usage `limits` array through the same per-entry parser. +@Suite(.serialized, ClaudeOAuthDefaultsFixtures()) +struct ClaudeLimitsEntryParsingTests { + private static let fableLimit = """ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 11, + "resets_at": "2026-10-15T09:00:00+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + """ + + private static func payload(_ entries: [String]) -> Data { + Data(""" + { + "five_hour": { "utilization": 12.0, "resets_at": "2026-10-11T05:00:00+00:00" }, + "seven_day": { "utilization": 14.0, "resets_at": "2026-10-15T09:00:00+00:00" }, + "limits": [\(entries.joined(separator: ","))] + } + """.utf8) + } + + @Test(arguments: [ + #"{"kind":"session","group":"session","percent":"12","resets_at":null,"scope":null}"#, + #"{"kind":"session","group":"session","percent":12,"resets_at":1791800000,"scope":null}"#, + #"{"kind":"session","group":"session","percent":12,"resets_at":null,"scope":"account"}"#, + #"{"kind":"weekly_all","group":"weekly","percent":14,"scope":{"model":{"id":42}},"is_active":"no"}"#, + ]) + func `O auth and web keep Fable when a sibling limit entry has an unexpected shape`(sibling: String) throws { + let data = Self.payload([sibling, Self.fableLimit]) + let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data) + let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + + #expect(oauth.primary?.usedPercent == 12) + #expect(oauth.extraRateWindows.map(\.id) == ["claude-weekly-scoped-fable"]) + #expect(web.extraRateWindows.map(\.id) == ["claude-weekly-scoped-fable"]) + } + + @Test + func `O auth and web show Fable without optional fields of an unexpected type`() throws { + let fable = """ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 11, "resets_at": 1791800000, + "scope": { "model": { "id": 42, "display_name": "Fable" }, "surface": null }, + "is_active": "false" + } + """ + let data = Self.payload([fable]) + let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data).extraRateWindows + let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data).extraRateWindows + + for windows in [oauth, web] { + #expect(windows.map(\.id) == ["claude-weekly-scoped-fable"]) + #expect(windows.first?.window.usedPercent == 11) + #expect(windows.first?.window.resetsAt == nil) + } + } +} From 7a398530304873996bc3fcf0f482328319872f78 Mon Sep 17 00:00:00 2001 From: Akagilnc Date: Sun, 18 Oct 2026 20:24:38 +0900 Subject: [PATCH 2/4] fix(claude): keep OAuth usage decoding when the limits re-read fails The per-entry limits parse re-reads the body with JSONSerialization, which rejects numbers JSONDecoder accepts (e.g. 1e400) and threw for the whole response. Fall back to an empty limits list instead, as before. Fold the cross-path limits tests into ClaudeWebUsageExtraWindowTests using the per-test OAuth defaults fixture, assert the Web primary percent in the sibling-shape case, and drop the separate suite. Co-Authored-By: Claude Sonnet 5.5 --- .../ClaudeOAuth/ClaudeOAuthUsageFetcher.swift | 2 +- .../ClaudeLimitsEntryParsingTests.swift | 62 ------------------- .../ClaudeWebUsageExtraWindowTests.swift | 58 +++++++++++++++++ 3 files changed, 59 insertions(+), 63 deletions(-) delete mode 100644 Tests/CodexBarTests/ClaudeLimitsEntryParsingTests.swift diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 208e438a321..ec582237e27 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -180,7 +180,7 @@ enum ClaudeOAuthUsageFetcher { static func decodeUsageResponse(_ data: Data) throws -> OAuthUsageResponse { let decoder = JSONDecoder() var usage = try decoder.decode(OAuthUsageResponse.self, from: data) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] usage.limits = json.map(ClaudeWebExtraRateWindowParser.scopedWeeklyLimits) return usage } diff --git a/Tests/CodexBarTests/ClaudeLimitsEntryParsingTests.swift b/Tests/CodexBarTests/ClaudeLimitsEntryParsingTests.swift deleted file mode 100644 index 8154402c165..00000000000 --- a/Tests/CodexBarTests/ClaudeLimitsEntryParsingTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -import Foundation -import Testing -@testable import CodexBarCore - -/// OAuth and Web parse the usage `limits` array through the same per-entry parser. -@Suite(.serialized, ClaudeOAuthDefaultsFixtures()) -struct ClaudeLimitsEntryParsingTests { - private static let fableLimit = """ - { - "kind": "weekly_scoped", "group": "weekly", "percent": 11, - "resets_at": "2026-10-15T09:00:00+00:00", - "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, - "is_active": false - } - """ - - private static func payload(_ entries: [String]) -> Data { - Data(""" - { - "five_hour": { "utilization": 12.0, "resets_at": "2026-10-11T05:00:00+00:00" }, - "seven_day": { "utilization": 14.0, "resets_at": "2026-10-15T09:00:00+00:00" }, - "limits": [\(entries.joined(separator: ","))] - } - """.utf8) - } - - @Test(arguments: [ - #"{"kind":"session","group":"session","percent":"12","resets_at":null,"scope":null}"#, - #"{"kind":"session","group":"session","percent":12,"resets_at":1791800000,"scope":null}"#, - #"{"kind":"session","group":"session","percent":12,"resets_at":null,"scope":"account"}"#, - #"{"kind":"weekly_all","group":"weekly","percent":14,"scope":{"model":{"id":42}},"is_active":"no"}"#, - ]) - func `O auth and web keep Fable when a sibling limit entry has an unexpected shape`(sibling: String) throws { - let data = Self.payload([sibling, Self.fableLimit]) - let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data) - let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - - #expect(oauth.primary?.usedPercent == 12) - #expect(oauth.extraRateWindows.map(\.id) == ["claude-weekly-scoped-fable"]) - #expect(web.extraRateWindows.map(\.id) == ["claude-weekly-scoped-fable"]) - } - - @Test - func `O auth and web show Fable without optional fields of an unexpected type`() throws { - let fable = """ - { - "kind": "weekly_scoped", "group": "weekly", "percent": 11, "resets_at": 1791800000, - "scope": { "model": { "id": 42, "display_name": "Fable" }, "surface": null }, - "is_active": "false" - } - """ - let data = Self.payload([fable]) - let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data).extraRateWindows - let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data).extraRateWindows - - for windows in [oauth, web] { - #expect(windows.map(\.id) == ["claude-weekly-scoped-fable"]) - #expect(windows.first?.window.usedPercent == 11) - #expect(windows.first?.window.resetsAt == nil) - } - } -} diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift index 452b70df704..99e24cb78e8 100644 --- a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -355,4 +355,62 @@ struct ClaudeWebUsageExtraWindowTests { let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) #expect(parsed.extraRateWindows.map(\.title) == ["Opus only", "Fable only", "Daily Routines"]) } + + // MARK: - Shared per-entry `limits` parsing (OAuth and Web) + + private static let fableLimit = """ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 11, + "resets_at": "2026-10-15T09:00:00+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + """ + + private static func payload(_ entries: [String]) -> Data { + Data(""" + { + "five_hour": { "utilization": 12.0, "resets_at": "2026-10-11T05:00:00+00:00" }, + "seven_day": { "utilization": 14.0, "resets_at": "2026-10-15T09:00:00+00:00" }, + "limits": [\(entries.joined(separator: ","))] + } + """.utf8) + } + + @Test(ClaudeOAuthDefaultsFixtures(), arguments: [ + #"{"kind":"session","group":"session","percent":"12","resets_at":null,"scope":null}"#, + #"{"kind":"session","group":"session","percent":12,"resets_at":1791800000,"scope":null}"#, + #"{"kind":"session","group":"session","percent":12,"resets_at":null,"scope":"account"}"#, + #"{"kind":"weekly_all","group":"weekly","percent":14,"scope":{"model":{"id":42}},"is_active":"no"}"#, + ]) + func `O auth and web keep Fable when a sibling limit entry has an unexpected shape`(sibling: String) throws { + let data = Self.payload([sibling, Self.fableLimit]) + let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data) + let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) + + #expect(oauth.primary?.usedPercent == 12) + #expect(oauth.extraRateWindows.map(\.id) == ["claude-weekly-scoped-fable"]) + #expect(web.sessionPercentUsed == 12) + #expect(web.extraRateWindows.map(\.id) == ["claude-weekly-scoped-fable"]) + } + + @Test(ClaudeOAuthDefaultsFixtures()) + func `O auth and web show Fable without optional fields of an unexpected type`() throws { + let fable = """ + { + "kind": "weekly_scoped", "group": "weekly", "percent": 11, "resets_at": 1791800000, + "scope": { "model": { "id": 42, "display_name": "Fable" }, "surface": null }, + "is_active": "false" + } + """ + let data = Self.payload([fable]) + let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data).extraRateWindows + let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data).extraRateWindows + + for windows in [oauth, web] { + #expect(windows.map(\.id) == ["claude-weekly-scoped-fable"]) + #expect(windows.first?.window.usedPercent == 11) + #expect(windows.first?.window.resetsAt == nil) + } + } } From 2ca67674b1dc7898029a4baa3456ad2676721106 Mon Sep 17 00:00:00 2001 From: Akagilnc Date: Sun, 18 Oct 2026 21:22:03 +0900 Subject: [PATCH 3/4] test(claude): cover OAuth limits entries with unexpected field types Co-Authored-By: Claude Sonnet 5.5 --- Tests/CodexBarTests/ClaudeOAuthTests.swift | 22 +++++++ .../ClaudeWebUsageExtraWindowTests.swift | 58 ------------------- 2 files changed, 22 insertions(+), 58 deletions(-) diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index e1b775cbc16..52c68f12d56 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -217,6 +217,28 @@ 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": [ + { "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 99e24cb78e8..452b70df704 100644 --- a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -355,62 +355,4 @@ struct ClaudeWebUsageExtraWindowTests { let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) #expect(parsed.extraRateWindows.map(\.title) == ["Opus only", "Fable only", "Daily Routines"]) } - - // MARK: - Shared per-entry `limits` parsing (OAuth and Web) - - private static let fableLimit = """ - { - "kind": "weekly_scoped", "group": "weekly", "percent": 11, - "resets_at": "2026-10-15T09:00:00+00:00", - "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, - "is_active": false - } - """ - - private static func payload(_ entries: [String]) -> Data { - Data(""" - { - "five_hour": { "utilization": 12.0, "resets_at": "2026-10-11T05:00:00+00:00" }, - "seven_day": { "utilization": 14.0, "resets_at": "2026-10-15T09:00:00+00:00" }, - "limits": [\(entries.joined(separator: ","))] - } - """.utf8) - } - - @Test(ClaudeOAuthDefaultsFixtures(), arguments: [ - #"{"kind":"session","group":"session","percent":"12","resets_at":null,"scope":null}"#, - #"{"kind":"session","group":"session","percent":12,"resets_at":1791800000,"scope":null}"#, - #"{"kind":"session","group":"session","percent":12,"resets_at":null,"scope":"account"}"#, - #"{"kind":"weekly_all","group":"weekly","percent":14,"scope":{"model":{"id":42}},"is_active":"no"}"#, - ]) - func `O auth and web keep Fable when a sibling limit entry has an unexpected shape`(sibling: String) throws { - let data = Self.payload([sibling, Self.fableLimit]) - let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data) - let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - - #expect(oauth.primary?.usedPercent == 12) - #expect(oauth.extraRateWindows.map(\.id) == ["claude-weekly-scoped-fable"]) - #expect(web.sessionPercentUsed == 12) - #expect(web.extraRateWindows.map(\.id) == ["claude-weekly-scoped-fable"]) - } - - @Test(ClaudeOAuthDefaultsFixtures()) - func `O auth and web show Fable without optional fields of an unexpected type`() throws { - let fable = """ - { - "kind": "weekly_scoped", "group": "weekly", "percent": 11, "resets_at": 1791800000, - "scope": { "model": { "id": 42, "display_name": "Fable" }, "surface": null }, - "is_active": "false" - } - """ - let data = Self.payload([fable]) - let oauth = try ClaudeUsageFetcher._mapOAuthUsageForTesting(data).extraRateWindows - let web = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data).extraRateWindows - - for windows in [oauth, web] { - #expect(windows.map(\.id) == ["claude-weekly-scoped-fable"]) - #expect(windows.first?.window.usedPercent == 11) - #expect(windows.first?.window.resetsAt == nil) - } - } } From da84d88444eae1f7c65c3b23a1b95ac1dde52000 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Oct 2026 20:01:56 -0700 Subject: [PATCH 4/4] fix(claude): preserve valid scoped limits beside malformed entries Refs #4614, #4126 Co-authored-by: Akagilnc --- CHANGELOG.md | 2 +- .../ClaudeWebExtraRateWindowParser.swift | 16 +++++++--------- Tests/CodexBarTests/ClaudeOAuthTests.swift | 1 + .../ClaudeWebUsageExtraWindowTests.swift | 15 +++++++++++++++ docs/claude.md | 1 + 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b33630b584..63fb6c6f324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Claude: parse OAuth usage `limits` entry by entry with the Web parser, so one malformed entry no longer hides the model-scoped weekly rows beside it (#4126). Thanks @Akagilnc! +- Claude: preserve model-specific weekly usage beside malformed limit entries in OAuth and Web responses, and ignore boolean percentages (#4614, #4126). Thanks @Akagilnc! ## 0.77.0 — 2026-10-16 diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift index 293789f1532..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 { @@ -48,8 +49,8 @@ enum ClaudeWebExtraRateWindowParser { /// 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? [[String: Any]] else { return [] } - return limits.map { entry in + 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( @@ -91,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 52c68f12d56..d2a642bf07c 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -224,6 +224,7 @@ struct ClaudeOAuthTests { "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, 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 55abbed5a20..ed1f2f7b1a7 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -209,6 +209,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.