Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +183 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse OAuth limits without a whole-body serialization gate

When an OAuth response includes an otherwise ignored field whose JSON number Foundation cannot materialize (for example a future field on a sibling limit with 1e400), JSONDecoder successfully builds the usage response but this whole-body JSONSerialization re-read returns nil, so usage.limits is left empty. Before this change the typed decoder ignored such unknown fields and retained a valid weekly_scoped sibling, whereas the new path silently removes its model row; decode limits through a lossy Decodable entry wrapper instead of making it contingent on serializing the entire response.

Useful? React with 👍 / 👎.

return usage
}

private static func retryAfterDate(from response: HTTPURLResponse, now: Date = Date()) -> Date? {
Expand Down Expand Up @@ -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)
Expand All @@ -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"])
}

Expand Down Expand Up @@ -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?
Expand Down
15 changes: 3 additions & 12 deletions Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CoreFoundation
import Foundation

enum ClaudeWebExtraRateWindowParser {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
}
}
23 changes: 23 additions & 0 deletions Tests/CodexBarTests/ClaudeOAuthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
15 changes: 15 additions & 0 deletions Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
1 change: 1 addition & 0 deletions docs/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down