diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift index 2f58530553..44322400d1 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudePlan.swift @@ -64,6 +64,14 @@ public enum ClaudePlan: String, CaseIterable, Sendable { } public static func fromWebAccount(rateLimitTier: String?, billingType: String?) -> Self? { + self.fromWebAccount(rateLimitTier: rateLimitTier, billingType: billingType, seatTier: nil) + } + + public static func fromWebAccount(rateLimitTier: String?, billingType: String?, seatTier: String?) -> Self? { + if self.teamSeatLoginMethod(seatTier: seatTier) != nil { + return .team + } + if let plan = self.fromRateLimitTier(rateLimitTier) { return plan } @@ -110,9 +118,18 @@ public enum ClaudePlan: String, CaseIterable, Sendable { } public static func webLoginMethod(rateLimitTier: String?, billingType: String?) -> String? { - self.fromWebAccount( + self.webLoginMethod(rateLimitTier: rateLimitTier, billingType: billingType, seatTier: nil) + } + + public static func webLoginMethod(rateLimitTier: String?, billingType: String?, seatTier: String?) -> String? { + if let teamSeat = self.teamSeatLoginMethod(seatTier: seatTier) { + return teamSeat + } + + return self.fromWebAccount( rateLimitTier: rateLimitTier, - billingType: billingType)?.brandedLoginMethod(rateLimitTier: rateLimitTier) + billingType: billingType, + seatTier: seatTier)?.brandedLoginMethod(rateLimitTier: rateLimitTier) } public static func cliCompatibilityLoginMethod(_ loginMethod: String?) -> String? { @@ -150,6 +167,18 @@ public enum ClaudePlan: String, CaseIterable, Sendable { return nil } + private static func teamSeatLoginMethod(seatTier: String?) -> String? { + let tier = Self.normalized(seatTier) + guard tier.hasPrefix("team_") else { return nil } + if tier.contains("standard") { + return "Claude Team Standard" + } + if tier.contains("premium") || tier.contains("tier_1") { + return "Claude Team Premium" + } + return "Claude Team" + } + private static func maxUsageMultiplier(rateLimitTier: String?) -> String? { let words = Self.normalizedWords(rateLimitTier) guard let maxIndex = words.firstIndex(of: Self.max.rawValue), diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 0d4c9e190b..e5f03e5a11 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -1374,11 +1374,20 @@ extension ClaudeUsageFetcher { Self.log.debug(msg) } } - // Only merge usage/cost extras; keep identity fields from the primary data source. + // Only merge usage/cost extras by default; allow web account metadata to refine a generic + // Team label with the exact Standard/Premium seat tier when available. let mergedExtraRateWindows = snapshot.extraRateWindows.isEmpty ? webData.extraRateWindows : snapshot .extraRateWindows let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost - if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { + let mergedLoginMethod = Self.refinedLoginMethod( + current: snapshot.loginMethod, + currentAccountEmail: snapshot.accountEmail, + web: webData.loginMethod, + webAccountEmail: webData.accountEmail) + if mergedProviderCost != snapshot.providerCost || + mergedExtraRateWindows != snapshot.extraRateWindows || + mergedLoginMethod != snapshot.loginMethod + { return ClaudeUsageSnapshot( primary: snapshot.primary, primaryWindowKind: snapshot.primaryWindowKind, @@ -1389,7 +1398,7 @@ extension ClaudeUsageFetcher { updatedAt: snapshot.updatedAt, accountEmail: snapshot.accountEmail, accountOrganization: snapshot.accountOrganization, - loginMethod: snapshot.loginMethod, + loginMethod: mergedLoginMethod, rawText: snapshot.rawText, oauthKeychainPersistentRefHash: snapshot.oauthKeychainPersistentRefHash, oauthHistoryOwnerIdentifier: snapshot.oauthHistoryOwnerIdentifier, @@ -1403,6 +1412,39 @@ extension ClaudeUsageFetcher { return snapshot } + private static func refinedLoginMethod( + current: String?, + currentAccountEmail: String?, + web: String?, + webAccountEmail: String?) -> String? + { + let webText = web?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let webText, !webText.isEmpty else { return current } + guard self.isGenericTeamLoginMethod(current) else { return current } + guard self.isSameAccountEmail(currentAccountEmail, webAccountEmail) else { return current } + let normalized = webText.lowercased() + guard normalized.contains("premium") || normalized.contains("standard") else { return current } + return webText + } + + private static func isSameAccountEmail(_ current: String?, _ web: String?) -> Bool { + guard let current = self.normalizedAccountEmail(current), + let web = self.normalizedAccountEmail(web) + else { return false } + return current == web + } + + private static func normalizedAccountEmail(_ email: String?) -> String? { + let normalized = email?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return (normalized?.isEmpty ?? true) ? nil : normalized + } + + private static func isGenericTeamLoginMethod(_ loginMethod: String?) -> Bool { + guard ClaudePlan.fromCompatibilityLoginMethod(loginMethod) == .team else { return false } + let normalized = loginMethod?.lowercased() ?? "" + return !normalized.contains("premium") && !normalized.contains("standard") + } + // MARK: - Process helpers private static func which(_ tool: String) -> String? { @@ -1468,6 +1510,19 @@ extension ClaudeUsageFetcher { #if DEBUG extension ClaudeUsageFetcher { + public static func _refinedLoginMethodForTesting( + current: String?, + currentAccountEmail: String?, + web: String?, + webAccountEmail: String?) -> String? + { + self.refinedLoginMethod( + current: current, + currentAccountEmail: currentAccountEmail, + web: web, + webAccountEmail: webAccountEmail) + } + public static func _mapOAuthUsageForTesting( _ data: Data, rateLimitTier: String? = nil, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 59fac139fb..4965cccbb6 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -758,6 +758,12 @@ public enum ClaudeWebAPIFetcher { struct Membership: Decodable { let organization: Organization + let seatTier: String? + + enum CodingKeys: String, CodingKey { + case organization + case seatTier = "seat_tier" + } struct Organization: Decodable { let uuid: String? @@ -806,7 +812,8 @@ public enum ClaudeWebAPIFetcher { let membership = Self.selectMembership(response.memberships, orgId: orgId) let plan = ClaudePlan.webLoginMethod( rateLimitTier: membership?.organization.rateLimitTier, - billingType: membership?.organization.billingType) + billingType: membership?.organization.billingType, + seatTier: membership?.seatTier) return WebAccountInfo(email: email, loginMethod: plan) } @@ -968,7 +975,9 @@ public enum ClaudeWebAPIFetcher { targetOrganizationID: String? = nil, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { + _ = sessionKeyInfo _ = targetOrganizationID + _ = logger throw FetchError.notSupportedOnThisPlatform } diff --git a/Tests/CodexBarTests/ClaudePlanResolverTests.swift b/Tests/CodexBarTests/ClaudePlanResolverTests.swift index f7ae24cc1f..9fd7b994cc 100644 --- a/Tests/CodexBarTests/ClaudePlanResolverTests.swift +++ b/Tests/CodexBarTests/ClaudePlanResolverTests.swift @@ -47,6 +47,28 @@ struct ClaudePlanResolverTests { == "Claude Pro") } + @Test + func `web account seat tier maps Team Standard and Premium seats`() { + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "default_raven", + billingType: "stripe_subscription", + seatTier: "team_standard") + == "Claude Team Standard") + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "default_raven", + billingType: "stripe_subscription", + seatTier: "team_tier_1") + == "Claude Team Premium") + #expect( + ClaudePlan.webLoginMethod( + rateLimitTier: "default_raven", + billingType: "stripe_subscription", + seatTier: "team_premium") + == "Claude Team Premium") + } + @Test func `compatibility parser understands current labels`() { #expect(ClaudePlan.fromCompatibilityLoginMethod("Claude Max") == .max) diff --git a/Tests/CodexBarTests/ClaudeTeamTierRefinementTests.swift b/Tests/CodexBarTests/ClaudeTeamTierRefinementTests.swift new file mode 100644 index 0000000000..7c910d9605 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeTeamTierRefinementTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeTeamTierRefinementTests { + private static func makeOAuthUsageResponse() throws -> OAuthUsageResponse { + let json = """ + + { + "five_hour": { + "utilization": 7, + "resets_at": "2025-12-23T16:00:00.000Z" + }, + "seven_day": { + "utilization": 21, + "resets_at": "2025-12-29T23:00:00.000Z" + } + } + + """ + return try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + } + + @Test + func `oauth team login method is not refined when web extras are disabled`() async throws { + let usageResponse = try Self.makeOAuthUsageResponse() + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + useWebExtras: false) + + let fetchOverride: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in + usageResponse + } + let loadCredsOverride: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in + ClaudeOAuthCredentials( + accessToken: "access-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_team", + subscriptionType: nil) + } + let transport = ProviderHTTPTransportHandler { _ in + Issue.record("Claude web account lookup should not run when web extras are disabled") + throw URLError(.badServerResponse) + } + + let snapshot = try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredsOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + + #expect(snapshot.loginMethod == "Claude Team") + #expect(snapshot.accountEmail == nil) + } + + @Test + func `generic team login method refines only when web account email matches`() { + let refined = ClaudeUsageFetcher._refinedLoginMethodForTesting( + current: "Claude Team", + currentAccountEmail: " Primary@Example.com ", + web: "Claude Team Premium", + webAccountEmail: "primary@example.com") + + #expect(refined == "Claude Team Premium") + } + + @Test + func `generic team login method ignores web tier from a different account`() { + let refined = ClaudeUsageFetcher._refinedLoginMethodForTesting( + current: "Claude Team", + currentAccountEmail: "primary@example.com", + web: "Claude Team Premium", + webAccountEmail: "other@example.com") + + #expect(refined == "Claude Team") + } + + @Test + func `generic team login method ignores web tier when account cannot be verified`() { + let refined = ClaudeUsageFetcher._refinedLoginMethodForTesting( + current: "Claude Team", + currentAccountEmail: nil, + web: "Claude Team Standard", + webAccountEmail: "primary@example.com") + + #expect(refined == "Claude Team") + } +} diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift index e5bcf65e6b..86d79c738e 100644 --- a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -3,6 +3,49 @@ import Testing @testable import CodexBarCore struct ClaudeWebUsageExtraWindowTests { + @Test + func `parses Team Premium seat tier from account memberships`() throws { + let json = """ + { + "email_address": "person@example.com", + "memberships": [ + { + "seat_tier": "team_tier_1", + "organization": { + "uuid": "org-team", + "name": "Team Org", + "rate_limit_tier": "default_raven", + "billing_type": "stripe_subscription" + } + } + ] + } + """ + let account = try #require(ClaudeWebAPIFetcher._parseAccountInfoForTesting(Data(json.utf8), orgId: "org-team")) + #expect(account.email == "person@example.com") + #expect(account.loginMethod == "Claude Team Premium") + } + + @Test + func `parses Team Standard seat tier from account memberships`() throws { + let json = """ + { + "memberships": [ + { + "seat_tier": "team_standard", + "organization": { + "uuid": "org-team", + "rate_limit_tier": "default_raven", + "billing_type": "stripe_subscription" + } + } + ] + } + """ + let account = try #require(ClaudeWebAPIFetcher._parseAccountInfoForTesting(Data(json.utf8), orgId: "org-team")) + #expect(account.loginMethod == "Claude Team Standard") + } + @Test func `parses claude web API sonnet usage response`() throws { let json = """