Skip to content

Commit 275c195

Browse files
steipeteshixy96
andcommitted
fix: recognize Claude OAuth subscription type
Co-authored-by: shixy96 <18705837259@163.com>
1 parent 7f7ea9d commit 275c195

9 files changed

Lines changed: 108 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
- Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain!
1111

1212
### Fixes
13+
- Claude: recognize OAuth `subscriptionType` before `rateLimitTier` so Pro accounts with generic Claude Code tiers
14+
open the subscription usage dashboard correctly (#836, fixes #824). Thanks @shixy96!
1315
- Cursor: show Enterprise/Team usage from personal caps and shared pools instead of reporting 100% remaining (#813). Thanks @fcamus00!
1416
- Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu!
1517
- Codex: make OpenAI dashboard refreshes handle non-English pages, lazy-loaded credits history, timeout retries, and unrelated Skillusage rows (#825). Thanks @xiaoqianWX!

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,22 @@ public struct ClaudeOAuthCredentials: Sendable {
1010
public let expiresAt: Date?
1111
public let scopes: [String]
1212
public let rateLimitTier: String?
13+
public let subscriptionType: String?
1314

1415
public init(
1516
accessToken: String,
1617
refreshToken: String?,
1718
expiresAt: Date?,
1819
scopes: [String],
19-
rateLimitTier: String?)
20+
rateLimitTier: String?,
21+
subscriptionType: String? = nil)
2022
{
2123
self.accessToken = accessToken
2224
self.refreshToken = refreshToken
2325
self.expiresAt = expiresAt
2426
self.scopes = scopes
2527
self.rateLimitTier = rateLimitTier
28+
self.subscriptionType = subscriptionType
2629
}
2730

2831
public var isExpired: Bool {
@@ -55,7 +58,8 @@ public struct ClaudeOAuthCredentials: Sendable {
5558
refreshToken: oauth.refreshToken,
5659
expiresAt: expiresAt,
5760
scopes: oauth.scopes ?? [],
58-
rateLimitTier: oauth.rateLimitTier)
61+
rateLimitTier: oauth.rateLimitTier,
62+
subscriptionType: oauth.subscriptionType)
5963
}
6064

6165
private struct Root: Decodable {
@@ -68,13 +72,15 @@ public struct ClaudeOAuthCredentials: Sendable {
6872
let expiresAt: Double?
6973
let scopes: [String]?
7074
let rateLimitTier: String?
75+
let subscriptionType: String?
7176

7277
enum CodingKeys: String, CodingKey {
7378
case accessToken
7479
case refreshToken
7580
case expiresAt
7681
case scopes
7782
case rateLimitTier
83+
case subscriptionType
7884
}
7985
}
8086
}

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -951,13 +951,15 @@ public enum ClaudeOAuthCredentialsStore {
951951
func refreshAccessToken(
952952
refreshToken: String,
953953
existingScopes: [String],
954-
existingRateLimitTier: String?) async throws -> ClaudeOAuthCredentials
954+
existingRateLimitTier: String?,
955+
existingSubscriptionType: String? = nil) async throws -> ClaudeOAuthCredentials
955956
{
956957
try await self.context.run {
957958
let newCredentials = try await self.refreshAccessTokenCore(
958959
refreshToken: refreshToken,
959960
existingScopes: existingScopes,
960-
existingRateLimitTier: existingRateLimitTier)
961+
existingRateLimitTier: existingRateLimitTier,
962+
existingSubscriptionType: existingSubscriptionType)
961963

962964
ClaudeOAuthCredentialsStore.saveRefreshedCredentialsToCache(newCredentials)
963965
ClaudeOAuthCredentialsStore.writeMemoryCache(
@@ -975,7 +977,8 @@ public enum ClaudeOAuthCredentialsStore {
975977
private func refreshAccessTokenCore(
976978
refreshToken: String,
977979
existingScopes: [String],
978-
existingRateLimitTier: String?) async throws -> ClaudeOAuthCredentials
980+
existingRateLimitTier: String?,
981+
existingSubscriptionType: String?) async throws -> ClaudeOAuthCredentials
979982
{
980983
guard ClaudeOAuthRefreshFailureGate.shouldAttempt() else {
981984
let status = ClaudeOAuthRefreshFailureGate.currentBlockStatus()
@@ -1051,7 +1054,8 @@ public enum ClaudeOAuthCredentialsStore {
10511054
refreshToken: tokenResponse.refreshToken ?? refreshToken,
10521055
expiresAt: expiresAt,
10531056
scopes: existingScopes,
1054-
rateLimitTier: existingRateLimitTier)
1057+
rateLimitTier: existingRateLimitTier,
1058+
subscriptionType: existingSubscriptionType)
10551059
}
10561060
}
10571061

@@ -1144,7 +1148,8 @@ public enum ClaudeOAuthCredentialsStore {
11441148
let refreshed = try await refresher.refreshAccessToken(
11451149
refreshToken: refreshToken,
11461150
existingScopes: credentials.scopes,
1147-
existingRateLimitTier: credentials.rateLimitTier)
1151+
existingRateLimitTier: credentials.rateLimitTier,
1152+
existingSubscriptionType: credentials.subscriptionType)
11481153
self.log.info("Token refresh successful, expires in \(refreshed.expiresIn ?? 0) seconds")
11491154
return refreshed
11501155
} catch {
@@ -1167,6 +1172,9 @@ public enum ClaudeOAuthCredentialsStore {
11671172
if let rateLimitTier = credentials.rateLimitTier {
11681173
oauth["rateLimitTier"] = rateLimitTier
11691174
}
1175+
if let subscriptionType = credentials.subscriptionType {
1176+
oauth["subscriptionType"] = subscriptionType
1177+
}
11701178

11711179
let oauthData: [String: Any] = ["claudeAiOauth": oauth]
11721180

@@ -1918,12 +1926,14 @@ extension ClaudeOAuthCredentialsStore {
19181926
public static func refreshAccessToken(
19191927
refreshToken: String,
19201928
existingScopes: [String],
1921-
existingRateLimitTier: String?) async throws -> ClaudeOAuthCredentials
1929+
existingRateLimitTier: String?,
1930+
existingSubscriptionType: String? = nil) async throws -> ClaudeOAuthCredentials
19221931
{
19231932
try await Refresher(context: self.currentCollaboratorContext()).refreshAccessToken(
19241933
refreshToken: refreshToken,
19251934
existingScopes: existingScopes,
1926-
existingRateLimitTier: existingRateLimitTier)
1935+
existingRateLimitTier: existingRateLimitTier,
1936+
existingSubscriptionType: existingSubscriptionType)
19271937
}
19281938

19291939
private enum RefreshFailureDisposition: String {

Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ public enum ClaudePlan: String, CaseIterable, Sendable {
5050
self.fromRateLimitTier(rateLimitTier)
5151
}
5252

53+
public static func fromOAuthCredentials(subscriptionType: String?, rateLimitTier: String?) -> Self? {
54+
self.fromCompatibilityLoginMethod(subscriptionType)
55+
?? self.fromOAuthRateLimitTier(rateLimitTier)
56+
}
57+
5358
public static func fromWebAccount(rateLimitTier: String?, billingType: String?) -> Self? {
5459
if let plan = self.fromRateLimitTier(rateLimitTier) {
5560
return plan
@@ -90,6 +95,12 @@ public enum ClaudePlan: String, CaseIterable, Sendable {
9095
self.fromOAuthRateLimitTier(rateLimitTier)?.brandedLoginMethod
9196
}
9297

98+
public static func oauthLoginMethod(subscriptionType: String?, rateLimitTier: String?) -> String? {
99+
self.fromOAuthCredentials(
100+
subscriptionType: subscriptionType,
101+
rateLimitTier: rateLimitTier)?.brandedLoginMethod
102+
}
103+
93104
public static func webLoginMethod(rateLimitTier: String?, billingType: String?) -> String? {
94105
self.fromWebAccount(rateLimitTier: rateLimitTier, billingType: billingType)?.brandedLoginMethod
95106
}

Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,9 @@ extension ClaudeUsageFetcher {
856856
windowMinutes: 7 * 24 * 60)
857857
let extraRateWindows = Self.oauthExtraRateWindows(from: usage)
858858

859-
let loginMethod = ClaudePlan.oauthLoginMethod(rateLimitTier: credentials.rateLimitTier)
859+
let loginMethod = ClaudePlan.oauthLoginMethod(
860+
subscriptionType: credentials.subscriptionType,
861+
rateLimitTier: credentials.rateLimitTier)
860862
let providerCost = Self.oauthExtraUsageCost(usage.extraUsage, loginMethod: loginMethod)
861863

862864
return ClaudeUsageSnapshot(
@@ -1158,15 +1160,17 @@ extension ClaudeUsageFetcher {
11581160
extension ClaudeUsageFetcher {
11591161
public static func _mapOAuthUsageForTesting(
11601162
_ data: Data,
1161-
rateLimitTier: String? = nil) throws -> ClaudeUsageSnapshot
1163+
rateLimitTier: String? = nil,
1164+
subscriptionType: String? = nil) throws -> ClaudeUsageSnapshot
11621165
{
11631166
let usage = try ClaudeOAuthUsageFetcher.decodeUsageResponse(data)
11641167
let creds = ClaudeOAuthCredentials(
11651168
accessToken: "test",
11661169
refreshToken: nil,
11671170
expiresAt: Date().addingTimeInterval(3600),
11681171
scopes: [],
1169-
rateLimitTier: rateLimitTier)
1172+
rateLimitTier: rateLimitTier,
1173+
subscriptionType: subscriptionType)
11701174
return try Self.mapOAuthUsage(usage, credentials: creds)
11711175
}
11721176
}

Tests/CodexBarTests/ClaudeOAuthTests.swift

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ struct ClaudeOAuthTests {
1212
"refreshToken": "test-refresh",
1313
"expiresAt": 4102444800000,
1414
"scopes": ["usage:read"],
15-
"rateLimitTier": "default_claude_max_20x"
15+
"rateLimitTier": "default_claude_max_20x",
16+
"subscriptionType": "pro"
1617
}
1718
}
1819
"""
@@ -21,6 +22,7 @@ struct ClaudeOAuthTests {
2122
#expect(creds.refreshToken == "test-refresh")
2223
#expect(creds.scopes == ["usage:read"])
2324
#expect(creds.rateLimitTier == "default_claude_max_20x")
25+
#expect(creds.subscriptionType == "pro")
2426
#expect(creds.isExpired == false)
2527
}
2628

@@ -81,6 +83,20 @@ struct ClaudeOAuthTests {
8183
#expect(snap.loginMethod == "Claude Pro")
8284
}
8385

86+
@Test
87+
func `maps O auth subscription type when rate limit tier is generic`() throws {
88+
let json = """
89+
{
90+
"five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }
91+
}
92+
"""
93+
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(
94+
Data(json.utf8),
95+
rateLimitTier: "default_claude_ai",
96+
subscriptionType: "pro")
97+
#expect(snap.loginMethod == "Claude Pro")
98+
}
99+
84100
@Test
85101
func `maps O auth design and routines usage windows`() throws {
86102
let json = """

Tests/CodexBarTests/ClaudePlanResolverTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ struct ClaudePlanResolverTests {
1111
#expect(ClaudePlan.oauthLoginMethod(rateLimitTier: "claude_enterprise") == "Claude Enterprise")
1212
}
1313

14+
@Test
15+
func `oauth subscription type overrides generic rate limit tier`() {
16+
#expect(
17+
ClaudePlan.oauthLoginMethod(subscriptionType: "pro", rateLimitTier: "default_claude_ai")
18+
== "Claude Pro")
19+
#expect(ClaudePlan.oauthLoginMethod(subscriptionType: nil, rateLimitTier: "default_claude_ai") == nil)
20+
}
21+
1422
@Test
1523
func `web fallback preserves stripe Claude compatibility`() {
1624
#expect(

Tests/CodexBarTests/StatusMenuTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,42 @@ struct StatusMenuTests {
101101
#expect(controller.dashboardURL(for: .alibaba) == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL)
102102
}
103103

104+
@Test
105+
func `claude subscription dashboard action opens usage page`() {
106+
self.disableMenuCardsForTesting()
107+
let settings = self.makeSettings()
108+
settings.statusChecksEnabled = false
109+
settings.refreshFrequency = .manual
110+
111+
let fetcher = UsageFetcher()
112+
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
113+
store._setSnapshotForTesting(
114+
UsageSnapshot(
115+
primary: RateWindow(
116+
usedPercent: 12,
117+
windowMinutes: 300,
118+
resetsAt: nil,
119+
resetDescription: nil),
120+
secondary: nil,
121+
tertiary: nil,
122+
updatedAt: Date(),
123+
identity: ProviderIdentitySnapshot(
124+
providerID: .claude,
125+
accountEmail: nil,
126+
accountOrganization: nil,
127+
loginMethod: "Claude Pro")),
128+
provider: .claude)
129+
let controller = StatusItemController(
130+
store: store,
131+
settings: settings,
132+
account: fetcher.loadAccountInfo(),
133+
updater: DisabledUpdaterController(),
134+
preferencesSelection: PreferencesSelection(),
135+
statusBar: self.makeStatusBarForTesting())
136+
137+
#expect(controller.dashboardURL(for: .claude)?.absoluteString == "https://claude.ai/settings/usage")
138+
}
139+
104140
@Test
105141
func `remembers provider when menu opens`() {
106142
self.disableMenuCardsForTesting()

docs/claude.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ Usage source picker:
5757
- `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window.
5858
- `extra_usage` → Extra usage cost (monthly spend/limit).
5959
- Successful OAuth login enables Claude and selects OAuth as the usage source.
60-
- Plan inference: `rate_limit_tier` from credentials maps to Max/Pro/Team/Enterprise.
60+
- Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to
61+
Max/Pro/Team/Enterprise.
6162

6263
## Web API (cookies)
6364
- Preferences → Providers → Claude → Cookie source (Automatic or Manual).

0 commit comments

Comments
 (0)