Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ public struct ClaudeUsageStrategy: Equatable, Sendable {
public let useWebExtras: Bool
}

#if DEBUG
extension ClaudeProviderDescriptor {
public static func _snapshotFromClaudeUsageForTesting(_ usage: ClaudeUsageSnapshot) -> UsageSnapshot {
ClaudeOAuthFetchStrategy._snapshotForTesting(from: usage)
}
}
#endif

struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy {
let id: String = "claude.oauth"
let kind: ProviderFetchKind = .oauth
Expand Down Expand Up @@ -248,19 +256,34 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy {
}

fileprivate static func snapshot(from usage: ClaudeUsageSnapshot) -> UsageSnapshot {
let hideUsageMetrics = Self.shouldHideUsageMetrics(for: usage)
let primary: RateWindow? = hideUsageMetrics ? nil : usage.primary
let secondary: RateWindow? = hideUsageMetrics ? nil : usage.secondary
let tertiary: RateWindow? = hideUsageMetrics ? nil : usage.opus
let identity = ProviderIdentitySnapshot(
providerID: .claude,
accountEmail: usage.accountEmail,
accountOrganization: usage.accountOrganization,
loginMethod: usage.loginMethod)
return UsageSnapshot(
primary: usage.primary,
secondary: usage.secondary,
tertiary: usage.opus,
primary: primary,
secondary: secondary,
tertiary: tertiary,
providerCost: usage.providerCost,
updatedAt: usage.updatedAt,
identity: identity)
}

private static func shouldHideUsageMetrics(for usage: ClaudeUsageSnapshot) -> Bool {
// Derived directly from the web usage payload shape to avoid depending on best-effort account fetches.
usage.usageMetricsUnavailable
}

#if DEBUG
static func _snapshotForTesting(from usage: ClaudeUsageSnapshot) -> UsageSnapshot {
self.snapshot(from: usage)
}
#endif
}

struct ClaudeWebFetchStrategy: ProviderFetchStrategy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public struct ClaudeUsageSnapshot: Sendable {
public let primary: RateWindow
public let secondary: RateWindow?
public let opus: RateWindow?
public let usageMetricsUnavailable: Bool
public let providerCost: ProviderCostSnapshot?
public let updatedAt: Date
public let accountEmail: String?
Expand All @@ -21,6 +22,7 @@ public struct ClaudeUsageSnapshot: Sendable {
primary: RateWindow,
secondary: RateWindow?,
opus: RateWindow?,
usageMetricsUnavailable: Bool = false,
providerCost: ProviderCostSnapshot? = nil,
updatedAt: Date,
accountEmail: String?,
Expand All @@ -31,6 +33,7 @@ public struct ClaudeUsageSnapshot: Sendable {
self.primary = primary
self.secondary = secondary
self.opus = opus
self.usageMetricsUnavailable = usageMetricsUnavailable
self.providerCost = providerCost
self.updatedAt = updatedAt
self.accountEmail = accountEmail
Expand Down Expand Up @@ -801,6 +804,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
primary: primary,
secondary: secondary,
opus: opus,
usageMetricsUnavailable: webData.usageMetricsUnavailable,
providerCost: providerCost,
updatedAt: Date(),
accountEmail: webData.accountEmail,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ public enum ClaudeWebAPIFetcher {
public let weeklyPercentUsed: Double?
public let weeklyResetsAt: Date?
public let opusPercentUsed: Double?
public let usageMetricsUnavailable: Bool
public let extraUsageCost: ProviderCostSnapshot?
public let accountOrganization: String?
public let accountEmail: String?
Expand All @@ -95,6 +96,7 @@ public enum ClaudeWebAPIFetcher {
weeklyPercentUsed: Double?,
weeklyResetsAt: Date?,
opusPercentUsed: Double?,
usageMetricsUnavailable: Bool,
extraUsageCost: ProviderCostSnapshot?,
accountOrganization: String?,
accountEmail: String?,
Expand All @@ -105,6 +107,7 @@ public enum ClaudeWebAPIFetcher {
self.weeklyPercentUsed = weeklyPercentUsed
self.weeklyResetsAt = weeklyResetsAt
self.opusPercentUsed = opusPercentUsed
self.usageMetricsUnavailable = usageMetricsUnavailable
self.extraUsageCost = extraUsageCost
self.accountOrganization = accountOrganization
self.accountEmail = accountEmail
Expand Down Expand Up @@ -185,28 +188,35 @@ public enum ClaudeWebAPIFetcher {
let organization = try await fetchOrganizationInfo(sessionKey: sessionKey, logger: log)
log("Organization resolved")

let account = await fetchAccountInfo(sessionKey: sessionKey, orgId: organization.id, logger: log)
var usage = try await fetchUsageData(orgId: organization.id, sessionKey: sessionKey, logger: log)
if usage.extraUsageCost == nil,
let extra = await fetchExtraUsageCost(orgId: organization.id, sessionKey: sessionKey, logger: log)
let extra = await fetchExtraUsageCost(
orgId: organization.id,
accountUUID: account?.accountUUID,
sessionKey: sessionKey,
logger: log)
{
usage = WebUsageData(
sessionPercentUsed: usage.sessionPercentUsed,
sessionResetsAt: usage.sessionResetsAt,
weeklyPercentUsed: usage.weeklyPercentUsed,
weeklyResetsAt: usage.weeklyResetsAt,
opusPercentUsed: usage.opusPercentUsed,
usageMetricsUnavailable: usage.usageMetricsUnavailable,
extraUsageCost: extra,
accountOrganization: usage.accountOrganization,
accountEmail: usage.accountEmail,
loginMethod: usage.loginMethod)
}
if let account = await fetchAccountInfo(sessionKey: sessionKey, orgId: organization.id, logger: log) {
if let account {
usage = WebUsageData(
sessionPercentUsed: usage.sessionPercentUsed,
sessionResetsAt: usage.sessionResetsAt,
weeklyPercentUsed: usage.weeklyPercentUsed,
weeklyResetsAt: usage.weeklyResetsAt,
opusPercentUsed: usage.opusPercentUsed,
usageMetricsUnavailable: usage.usageMetricsUnavailable,
extraUsageCost: usage.extraUsageCost,
accountOrganization: usage.accountOrganization,
accountEmail: account.email,
Expand All @@ -219,6 +229,7 @@ public enum ClaudeWebAPIFetcher {
weeklyPercentUsed: usage.weeklyPercentUsed,
weeklyResetsAt: usage.weeklyResetsAt,
opusPercentUsed: usage.opusPercentUsed,
usageMetricsUnavailable: usage.usageMetricsUnavailable,
extraUsageCost: usage.extraUsageCost,
accountOrganization: name,
accountEmail: usage.accountEmail,
Expand Down Expand Up @@ -453,27 +464,28 @@ public enum ClaudeWebAPIFetcher {
}

// Parse five_hour (session) usage
let usageMetricsUnavailable = self.usageWindowsUnavailable(in: json)
var sessionPercent: Double?
var sessionResets: Date?
if let fiveHour = json["five_hour"] as? [String: Any] {
if let utilization = fiveHour["utilization"] as? Int {
sessionPercent = Double(utilization)
if let utilization = fiveHour["utilization"] as? NSNumber {
sessionPercent = utilization.doubleValue
}
if let resetsAt = fiveHour["resets_at"] as? String {
sessionResets = self.parseISO8601Date(resetsAt)
}
}
guard let sessionPercent else {
// If we can't parse session utilization, treat this as a failure so callers can fall back to the CLI.
guard sessionPercent != nil || usageMetricsUnavailable else {
// Keep malformed payloads on the fallback path. Only all-null enterprise payloads are accepted.
throw FetchError.invalidResponse
}

// Parse seven_day (weekly) usage
var weeklyPercent: Double?
var weeklyResets: Date?
if let sevenDay = json["seven_day"] as? [String: Any] {
if let utilization = sevenDay["utilization"] as? Int {
weeklyPercent = Double(utilization)
if let utilization = sevenDay["utilization"] as? NSNumber {
weeklyPercent = utilization.doubleValue
}
if let resetsAt = sevenDay["resets_at"] as? String {
weeklyResets = self.parseISO8601Date(resetsAt)
Expand All @@ -483,23 +495,39 @@ public enum ClaudeWebAPIFetcher {
// Parse seven_day_opus (Opus-specific weekly) usage
var opusPercent: Double?
if let sevenDayOpus = json["seven_day_opus"] as? [String: Any] {
if let utilization = sevenDayOpus["utilization"] as? Int {
opusPercent = Double(utilization)
if let utilization = sevenDayOpus["utilization"] as? NSNumber {
opusPercent = utilization.doubleValue
}
}

return WebUsageData(
sessionPercentUsed: sessionPercent,
sessionPercentUsed: sessionPercent ?? 0,
sessionResetsAt: sessionResets,
weeklyPercentUsed: weeklyPercent,
weeklyResetsAt: weeklyResets,
opusPercentUsed: opusPercent,
usageMetricsUnavailable: usageMetricsUnavailable,
extraUsageCost: nil,
accountOrganization: nil,
accountEmail: nil,
loginMethod: nil)
}

private static func usageWindowsUnavailable(in json: [String: Any]) -> Bool {
let keys = [
"five_hour",
"seven_day",
"seven_day_oauth_apps",
"seven_day_opus",
"seven_day_sonnet",
"seven_day_cowork",
"iguana_necktie",
]
let values = keys.compactMap { json[$0] }
guard !values.isEmpty else { return false }
return values.allSatisfy { $0 is NSNull }
}

// MARK: - Extra usage cost (Claude "Extra")

private struct OverageSpendLimitResponse: Decodable {
Expand All @@ -519,10 +547,24 @@ public enum ClaudeWebAPIFetcher {
/// Best-effort fetch of Claude Extra spend/limit (does not fail the main usage fetch).
private static func fetchExtraUsageCost(
orgId: String,
accountUUID: String?,
sessionKey: String,
logger: ((String) -> Void)? = nil) async -> ProviderCostSnapshot?
{
let preferredURL = self.overageSpendLimitURL(orgId: orgId, accountUUID: accountUUID)
if let snapshot = await self.fetchExtraUsageCost(url: preferredURL, sessionKey: sessionKey, logger: logger) {
return snapshot
}
guard accountUUID != nil else { return nil }
let fallbackURL = self.overageSpendLimitURL(orgId: orgId, accountUUID: nil)
return await self.fetchExtraUsageCost(url: fallbackURL, sessionKey: sessionKey, logger: logger)
}

private static func fetchExtraUsageCost(
url: URL,
sessionKey: String,
logger: ((String) -> Void)? = nil) async -> ProviderCostSnapshot?
{
let url = URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit")!
var request = URLRequest(url: url)
request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")
request.setValue("application/json", forHTTPHeaderField: "Accept")
Expand All @@ -540,6 +582,14 @@ public enum ClaudeWebAPIFetcher {
}
}

private static func overageSpendLimitURL(orgId: String, accountUUID: String?) -> URL {
var components = URLComponents(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit")!
if let accountUUID {
components.queryItems = [URLQueryItem(name: "account_uuid", value: accountUUID)]
}
return components.url!
}

private static func parseOverageSpendLimit(_ data: Data) -> ProviderCostSnapshot? {
guard let decoded = try? JSONDecoder().decode(OverageSpendLimitResponse.self, from: data) else { return nil }
guard decoded.isEnabled == true else { return nil }
Expand Down Expand Up @@ -628,19 +678,25 @@ public enum ClaudeWebAPIFetcher {

public struct WebAccountInfo: Sendable {
public let email: String?
public let accountUUID: String?
public let loginMethod: String?

public init(email: String?, loginMethod: String?) {
public init(email: String?, accountUUID: String?, loginMethod: String?) {
self.email = email
self.accountUUID = accountUUID
self.loginMethod = loginMethod
}
}

private struct AccountResponse: Decodable {
let uuid: String?
let accountUUID: String?
let emailAddress: String?
let memberships: [Membership]?

enum CodingKeys: String, CodingKey {
case uuid
case accountUUID = "account_uuid"
case emailAddress = "email_address"
case memberships
}
Expand Down Expand Up @@ -690,11 +746,13 @@ public enum ClaudeWebAPIFetcher {
private static func parseAccountInfo(_ data: Data, orgId: String?) -> WebAccountInfo? {
guard let response = try? JSONDecoder().decode(AccountResponse.self, from: data) else { return nil }
let email = response.emailAddress?.trimmingCharacters(in: .whitespacesAndNewlines)
let accountUUID = (response.accountUUID ?? response.uuid)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let membership = Self.selectMembership(response.memberships, orgId: orgId)
let plan = Self.inferPlan(
rateLimitTier: membership?.organization.rateLimitTier,
billingType: membership?.organization.billingType)
return WebAccountInfo(email: email, loginMethod: plan)
return WebAccountInfo(email: email, accountUUID: accountUUID, loginMethod: plan)
}

private static func selectMembership(
Expand Down
Loading