Skip to content

Commit 46cd1fb

Browse files
committed
fix: harden Claude scoped weekly limits
1 parent 22f6b1a commit 46cd1fb

6 files changed

Lines changed: 151 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 0.38.1 — Unreleased
44

55
### Added
6+
- Claude: show model-scoped weekly quota windows, including promotional Fable limits, from OAuth and web usage responses. Thanks @konon4!
67
- Usage refresh: add an opt-in Adaptive cadence that polls every 2–30 minutes based on recent menu use, Low Power Mode, and thermal state. Thanks @hhh2210!
78
- Codex: show a conservative 1.5× pace-headroom hint in menus and CLI output when usage is safely ahead of the reset curve. Thanks @astuteprogrammer!
89

Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,11 @@ struct OAuthLimitScope: Decodable {
273273
}
274274

275275
struct OAuthLimitScopeModel: Decodable {
276+
let id: String?
276277
let displayName: String?
277278

278279
enum CodingKeys: String, CodingKey {
280+
case id
279281
case displayName = "display_name"
280282
}
281283
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Foundation
2+
3+
/// Shared mapping for the model-scoped weekly limits returned by both Claude usage APIs.
4+
enum ClaudeScopedWeeklyLimitMapper {
5+
struct Limit {
6+
let kind: String?
7+
let group: String?
8+
let percent: Double?
9+
let resetsAt: Date?
10+
let modelID: String?
11+
let modelName: String?
12+
}
13+
14+
static func extraRateWindows(
15+
from limits: [Limit]?,
16+
resetDescription: ((Date) -> String)? = nil) -> [NamedRateWindow]
17+
{
18+
guard let limits else { return [] }
19+
var seenIDs: Set<String> = []
20+
21+
return limits.compactMap { limit in
22+
guard limit.group == "weekly", limit.kind == "weekly_scoped" else { return nil }
23+
guard let percent = limit.percent, percent.isFinite else { return nil }
24+
guard let modelName = self.nonEmpty(limit.modelName) else { return nil }
25+
let identity = self.nonEmpty(limit.modelID) ?? modelName
26+
let slug = self.slug(identity)
27+
guard !slug.isEmpty else { return nil }
28+
29+
let id = "claude-weekly-scoped-\(slug)"
30+
guard seenIDs.insert(id).inserted else { return nil }
31+
32+
return NamedRateWindow(
33+
id: id,
34+
title: "\(modelName) only",
35+
window: RateWindow(
36+
usedPercent: percent,
37+
windowMinutes: 7 * 24 * 60,
38+
resetsAt: limit.resetsAt,
39+
resetDescription: limit.resetsAt.flatMap { resetDescription?($0) }))
40+
}
41+
}
42+
43+
private static func nonEmpty(_ value: String?) -> String? {
44+
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
45+
return if let trimmed, !trimmed.isEmpty { trimmed } else { nil }
46+
}
47+
48+
private static func slug(_ value: String) -> String {
49+
var result = ""
50+
var lastWasDash = false
51+
for scalar in value.lowercased().unicodeScalars {
52+
if CharacterSet.alphanumerics.contains(scalar) {
53+
result.unicodeScalars.append(scalar)
54+
lastWasDash = false
55+
} else if !lastWasDash {
56+
result.append("-")
57+
lastWasDash = true
58+
}
59+
}
60+
return result.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
61+
}
62+
}

Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,32 +1153,20 @@ extension ClaudeUsageFetcher {
11531153
return routineWindows + Self.oauthScopedWeeklyLimitWindows(from: usage)
11541154
}
11551155

1156-
/// Surfaces any per-model weekly quota carve-out reported in the newer `limits` array —
1157-
/// e.g. Anthropic's July 2026 promotional "up to 50% of your weekly limit on Fable 5"
1158-
/// window, exposed as `kind: "weekly_scoped"` with `scope.model.display_name: "Fable"`.
1159-
///
1160-
/// Deliberately generic: it surfaces *any* named scoped weekly limit rather than matching
1161-
/// on "Fable" specifically, since these entries are promo-driven and Anthropic may reuse
1162-
/// the same `weekly_scoped` shape for a different model once this promotion ends.
11631156
private static func oauthScopedWeeklyLimitWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] {
1164-
guard let limits = usage.limits else { return [] }
1165-
return limits.compactMap { entry -> NamedRateWindow? in
1166-
guard entry.group == "weekly", entry.kind == "weekly_scoped" else { return nil }
1167-
guard let percent = entry.percent else { return nil }
1168-
let modelName = entry.scope?.model?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
1169-
guard let modelName, !modelName.isEmpty else { return nil }
1170-
let resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(entry.resetsAt)
1171-
let resetDescription = resetDate.map(Self.formatResetDate)
1172-
let slug = modelName.lowercased().replacingOccurrences(of: " ", with: "-")
1173-
return NamedRateWindow(
1174-
id: "claude-weekly-scoped-\(slug)",
1175-
title: "\(modelName) only",
1176-
window: RateWindow(
1177-
usedPercent: percent,
1178-
windowMinutes: Self.weeklyWindowMinutes,
1179-
resetsAt: resetDate,
1180-
resetDescription: resetDescription))
1157+
let limits = usage.limits?.map { entry in
1158+
ClaudeScopedWeeklyLimitMapper.Limit(
1159+
kind: entry.kind,
1160+
group: entry.group,
1161+
percent: entry.percent,
1162+
resetsAt: ClaudeOAuthUsageFetcher.parseISO8601Date(entry.resetsAt),
1163+
modelID: entry.scope?.model?.id,
1164+
modelName: entry.scope?.model?.displayName)
11811165
}
1166+
// `is_active` is intentionally not a filter: observed enforceable scoped limits report false.
1167+
return ClaudeScopedWeeklyLimitMapper.extraRateWindows(
1168+
from: limits,
1169+
resetDescription: Self.formatResetDate)
11821170
}
11831171

11841172
// MARK: - Web API path (uses browser cookies)

Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -50,32 +50,20 @@ enum ClaudeWebExtraRateWindowParser {
5050
return (windows, sourceKeys)
5151
}
5252

53-
/// Surfaces any per-model weekly quota carve-out reported in the newer `limits` array —
54-
/// e.g. Anthropic's July 2026 promotional "up to 50% of your weekly limit on Fable 5"
55-
/// window, exposed as `kind: "weekly_scoped"` with `scope.model.display_name: "Fable"`.
56-
///
57-
/// Deliberately generic: it surfaces *any* named scoped weekly limit rather than matching
58-
/// on "Fable" specifically, since these entries are promo-driven and Anthropic may reuse
59-
/// the same `weekly_scoped` shape for a different model once this promotion ends.
6053
private static func scopedWeeklyLimitWindows(from json: [String: Any]) -> [NamedRateWindow] {
6154
guard let limits = json["limits"] as? [[String: Any]] else { return [] }
62-
return limits.compactMap { entry -> NamedRateWindow? in
63-
guard entry["group"] as? String == "weekly", entry["kind"] as? String == "weekly_scoped" else {
64-
return nil
65-
}
66-
guard let percent = Self.percentValue(from: entry["percent"]) else { return nil }
55+
let mappedLimits = limits.map { entry in
6756
let scope = entry["scope"] as? [String: Any]
6857
let model = scope?["model"] as? [String: Any]
69-
let modelName = (model?["display_name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
70-
guard let modelName, !modelName.isEmpty else { return nil }
71-
let resetsAt = (entry["resets_at"] as? String).flatMap(Self.parseISO8601Date)
72-
let slug = modelName.lowercased().replacingOccurrences(of: " ", with: "-")
73-
return Self.namedWindow(
74-
id: "claude-weekly-scoped-\(slug)",
75-
title: "\(modelName) only",
76-
usedPercent: percent,
77-
resetsAt: resetsAt)
58+
return ClaudeScopedWeeklyLimitMapper.Limit(
59+
kind: entry["kind"] as? String,
60+
group: entry["group"] as? String,
61+
percent: Self.percentValue(from: entry["percent"]),
62+
resetsAt: (entry["resets_at"] as? String).flatMap(Self.parseISO8601Date),
63+
modelID: model?["id"] as? String,
64+
modelName: model?["display_name"] as? String)
7865
}
66+
return ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: mappedLimits)
7967
}
8068

8169
private static func namedWindow(
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Foundation
2+
import Testing
3+
@testable import CodexBarCore
4+
5+
struct ClaudeScopedWeeklyLimitMapperTests {
6+
@Test
7+
func `model id provides a stable safe identifier and duplicate limits collapse`() throws {
8+
let reset = Date(timeIntervalSince1970: 1_783_507_200)
9+
let limits = [
10+
Self.limit(modelID: "claude/fable.5:promo", modelName: "Fable", resetsAt: reset),
11+
Self.limit(modelID: "claude/fable.5:promo", modelName: "Fable renamed", resetsAt: reset),
12+
]
13+
14+
let windows = ClaudeScopedWeeklyLimitMapper.extraRateWindows(
15+
from: limits,
16+
resetDescription: { _ in "Jul 8" })
17+
let window = try #require(windows.first)
18+
19+
#expect(windows.count == 1)
20+
#expect(window.id == "claude-weekly-scoped-claude-fable-5-promo")
21+
#expect(window.title == "Fable only")
22+
#expect(window.window.resetsAt == reset)
23+
#expect(window.window.resetDescription == "Jul 8")
24+
}
25+
26+
@Test
27+
func `display name supplies the identifier when the API omits a model id`() throws {
28+
let windows = ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: [
29+
Self.limit(modelID: " ", modelName: " Team / Research "),
30+
])
31+
32+
let window = try #require(windows.first)
33+
#expect(window.id == "claude-weekly-scoped-team-research")
34+
#expect(window.title == "Team / Research only")
35+
}
36+
37+
@Test
38+
func `unrelated malformed and unnamed limits are ignored`() {
39+
let limits = [
40+
Self.limit(kind: "session", modelName: "Fable"),
41+
Self.limit(group: "monthly", modelName: "Fable"),
42+
Self.limit(percent: .nan, modelName: "Fable"),
43+
Self.limit(modelName: " "),
44+
]
45+
46+
#expect(ClaudeScopedWeeklyLimitMapper.extraRateWindows(from: limits).isEmpty)
47+
}
48+
49+
private static func limit(
50+
kind: String = "weekly_scoped",
51+
group: String = "weekly",
52+
percent: Double = 5,
53+
modelID: String? = nil,
54+
modelName: String?,
55+
resetsAt: Date? = nil) -> ClaudeScopedWeeklyLimitMapper.Limit
56+
{
57+
ClaudeScopedWeeklyLimitMapper.Limit(
58+
kind: kind,
59+
group: group,
60+
percent: percent,
61+
resetsAt: resetsAt,
62+
modelID: modelID,
63+
modelName: modelName)
64+
}
65+
}

0 commit comments

Comments
 (0)