Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 @@ -4,6 +4,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!
- Claude: try readable OAuth credentials before starting the CLI in Linux Auto mode, avoiding unnecessary interactive startup while preserving explicit source selection (#4613, #4615). Thanks @yeseoLee!
- Claude: include Desktop Cowork audit transcripts in local token and cost history, using the existing response deduplication and ignoring result totals (#3209). Thanks @vinschger and @Akagilnc!

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 @@ -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.
Expand Down