1534 lines
66 KiB
Swift
1534 lines
66 KiB
Swift
import Foundation
|
|
|
|
public protocol ClaudeUsageFetching: Sendable {
|
|
func loadLatestUsage(model: String) async throws -> ClaudeUsageSnapshot
|
|
func debugRawProbe(model: String) async -> String
|
|
func detectVersion() -> String?
|
|
}
|
|
|
|
public struct ClaudeUsageSnapshot: Sendable {
|
|
public enum PrimaryWindowKind: Equatable, Sendable {
|
|
case usage
|
|
case spendLimit
|
|
}
|
|
|
|
public let primary: RateWindow
|
|
public let primaryWindowKind: PrimaryWindowKind
|
|
public let secondary: RateWindow?
|
|
public let opus: RateWindow?
|
|
public let extraRateWindows: [NamedRateWindow]
|
|
public let providerCost: ProviderCostSnapshot?
|
|
public let updatedAt: Date
|
|
public let accountEmail: String?
|
|
public let accountOrganization: String?
|
|
public let loginMethod: String?
|
|
public let rawText: String?
|
|
/// Present only when the credential used for this OAuth fetch matches the current Claude Keychain item.
|
|
public let oauthKeychainPersistentRefHash: String?
|
|
/// One-way, high-entropy ownership evidence derived from the exact credential used for this OAuth fetch.
|
|
public let oauthHistoryOwnerIdentifier: String?
|
|
/// True when a prompt-free comparison proved this credential differs from Claude Code's Keychain entry.
|
|
public let oauthKeychainCredentialMismatch: Bool
|
|
/// True when a prompt-free probe proved Claude Code has no Keychain credential.
|
|
public let oauthKeychainCredentialAbsent: Bool
|
|
/// True when a Claude CLI credential won routing but the prompt-free Keychain comparison was unavailable.
|
|
public let oauthKeychainCredentialUnavailable: Bool
|
|
|
|
public init(
|
|
primary: RateWindow,
|
|
primaryWindowKind: PrimaryWindowKind = .usage,
|
|
secondary: RateWindow?,
|
|
opus: RateWindow?,
|
|
extraRateWindows: [NamedRateWindow] = [],
|
|
providerCost: ProviderCostSnapshot? = nil,
|
|
updatedAt: Date,
|
|
accountEmail: String?,
|
|
accountOrganization: String?,
|
|
loginMethod: String?,
|
|
rawText: String?,
|
|
oauthKeychainPersistentRefHash: String? = nil,
|
|
oauthHistoryOwnerIdentifier: String? = nil,
|
|
oauthKeychainCredentialMismatch: Bool = false,
|
|
oauthKeychainCredentialAbsent: Bool = false,
|
|
oauthKeychainCredentialUnavailable: Bool = false)
|
|
{
|
|
self.primary = primary
|
|
self.primaryWindowKind = primaryWindowKind
|
|
self.secondary = secondary
|
|
self.opus = opus
|
|
self.extraRateWindows = extraRateWindows
|
|
self.providerCost = providerCost
|
|
self.updatedAt = updatedAt
|
|
self.accountEmail = accountEmail
|
|
self.accountOrganization = accountOrganization
|
|
self.loginMethod = loginMethod
|
|
self.rawText = rawText
|
|
self.oauthKeychainPersistentRefHash = oauthKeychainPersistentRefHash
|
|
self.oauthHistoryOwnerIdentifier = oauthHistoryOwnerIdentifier
|
|
self.oauthKeychainCredentialMismatch = oauthKeychainCredentialMismatch
|
|
self.oauthKeychainCredentialAbsent = oauthKeychainCredentialAbsent
|
|
self.oauthKeychainCredentialUnavailable = oauthKeychainCredentialUnavailable
|
|
}
|
|
}
|
|
|
|
public enum ClaudeUsageError: LocalizedError, Sendable {
|
|
case claudeNotInstalled
|
|
case parseFailed(String)
|
|
case oauthFailed(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .claudeNotInstalled:
|
|
"Claude CLI is not installed. Install it from https://code.claude.com/docs/en/overview."
|
|
case let .parseFailed(details):
|
|
"Could not parse Claude usage: \(details)"
|
|
case let .oauthFailed(details):
|
|
details
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable {
|
|
private static let sessionWindowMinutes = 5 * 60
|
|
private static let weeklyWindowMinutes = 7 * 24 * 60
|
|
private static let cliAutoProbeTimeout: TimeInterval = 12
|
|
private static let cliProbeTimeout: TimeInterval = 24
|
|
private static let cliRetryProbeTimeout: TimeInterval = 60
|
|
private struct Configuration {
|
|
let environment: [String: String]
|
|
let runtime: ProviderRuntime
|
|
let dataSource: ClaudeUsageDataSource
|
|
let oauthKeychainPromptCooldownEnabled: Bool
|
|
let allowBackgroundDelegatedRefresh: Bool
|
|
let allowStartupBootstrapPrompt: Bool
|
|
let useWebExtras: Bool
|
|
let manualCookieHeader: String?
|
|
let webOrganizationID: String?
|
|
let keepCLISessionsAlive: Bool
|
|
let browserDetection: BrowserDetection
|
|
}
|
|
|
|
private let configuration: Configuration
|
|
private static let log = CodexBarLog.logger(LogCategories.claudeUsage)
|
|
private static var isClaudeOAuthFlowDebugEnabled: Bool {
|
|
ProcessInfo.processInfo.environment["CODEXBAR_DEBUG_CLAUDE_OAUTH_FLOW"] == "1"
|
|
}
|
|
|
|
private var environment: [String: String] {
|
|
self.configuration.environment
|
|
}
|
|
|
|
private var runtime: ProviderRuntime {
|
|
self.configuration.runtime
|
|
}
|
|
|
|
private var dataSource: ClaudeUsageDataSource {
|
|
self.configuration.dataSource
|
|
}
|
|
|
|
private var oauthKeychainPromptCooldownEnabled: Bool {
|
|
self.configuration.oauthKeychainPromptCooldownEnabled
|
|
}
|
|
|
|
private var allowsDelegatedOAuthRefresh: Bool {
|
|
self.runtime == .app
|
|
}
|
|
|
|
private var allowsOAuthClaudeVersionDetection: Bool {
|
|
self.runtime == .app
|
|
}
|
|
|
|
private var allowBackgroundDelegatedRefresh: Bool {
|
|
self.configuration.allowBackgroundDelegatedRefresh
|
|
}
|
|
|
|
private var allowStartupBootstrapPrompt: Bool {
|
|
self.configuration.allowStartupBootstrapPrompt
|
|
}
|
|
|
|
private var useWebExtras: Bool {
|
|
self.configuration.useWebExtras
|
|
}
|
|
|
|
private var manualCookieHeader: String? {
|
|
self.configuration.manualCookieHeader
|
|
}
|
|
|
|
private var webOrganizationID: String? {
|
|
self.configuration.webOrganizationID
|
|
}
|
|
|
|
private var keepCLISessionsAlive: Bool {
|
|
self.configuration.keepCLISessionsAlive
|
|
}
|
|
|
|
private var browserDetection: BrowserDetection {
|
|
self.configuration.browserDetection
|
|
}
|
|
|
|
private struct ClaudeOAuthKeychainPromptPolicy {
|
|
let mode: ClaudeOAuthKeychainPromptMode
|
|
let isApplicable: Bool
|
|
let interaction: ProviderInteraction
|
|
|
|
var canPromptNow: Bool {
|
|
switch self.mode {
|
|
case .never:
|
|
false
|
|
case .onlyOnUserAction:
|
|
self.interaction == .userInitiated
|
|
case .always:
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Respect the Keychain prompt cooldown for background operations to avoid spamming system dialogs.
|
|
/// User actions (menu open / refresh / settings) are allowed to bypass the cooldown.
|
|
var shouldRespectKeychainPromptCooldown: Bool {
|
|
self.interaction != .userInitiated
|
|
}
|
|
|
|
var interactionLabel: String {
|
|
self.interaction == .userInitiated ? "user" : "background"
|
|
}
|
|
}
|
|
|
|
private static func currentClaudeOAuthInteractivePromptPolicy() -> ClaudeOAuthKeychainPromptPolicy {
|
|
let policy = ClaudeOAuthKeychainPromptPolicy(
|
|
mode: ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode(),
|
|
isApplicable: true,
|
|
interaction: ProviderInteractionContext.current)
|
|
|
|
// User actions should be able to immediately retry a Security.framework fallback repair after a background
|
|
// cooldown was recorded, even when /usr/bin/security is the primary reader.
|
|
if policy.interaction == .userInitiated {
|
|
if ClaudeOAuthKeychainAccessGate.clearDenied() {
|
|
Self.log.info("Claude OAuth keychain cooldown cleared by user action")
|
|
}
|
|
}
|
|
return policy
|
|
}
|
|
|
|
private static func currentClaudeOAuthDelegatedRefreshPolicy() -> ClaudeOAuthKeychainPromptPolicy {
|
|
// Delegated refresh must honor the stored prompt mode for every keychain read strategy.
|
|
// securityCLIExperimental is not "prompt policy N/A" for CLI touch repair.
|
|
ClaudeOAuthKeychainPromptPolicy(
|
|
mode: ClaudeOAuthKeychainPromptPreference.storedMode(),
|
|
isApplicable: true,
|
|
interaction: ProviderInteractionContext.current)
|
|
}
|
|
|
|
private static func assertDelegatedRefreshAllowedInCurrentInteraction(
|
|
policy: ClaudeOAuthKeychainPromptPolicy,
|
|
allowBackgroundDelegatedRefresh: Bool) throws
|
|
{
|
|
if policy.mode == .never {
|
|
throw ClaudeUsageError.oauthFailed("Delegated refresh is disabled by 'never' keychain policy.")
|
|
}
|
|
if policy.mode == .onlyOnUserAction,
|
|
policy.interaction != .userInitiated,
|
|
!allowBackgroundDelegatedRefresh
|
|
{
|
|
throw ClaudeUsageError.oauthFailed(
|
|
"Claude OAuth token expired, but background repair is suppressed when Keychain prompt policy "
|
|
+ "is set to only prompt on user action. Open the CodexBar menu or click Refresh to retry.")
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
@TaskLocal static var loadOAuthCredentialsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)?
|
|
@TaskLocal static var fetchOAuthUsageOverride: (@Sendable (
|
|
String,
|
|
Bool) async throws -> OAuthUsageResponse)?
|
|
@TaskLocal static var delegatedRefreshAttemptOverride: (@Sendable (
|
|
Date,
|
|
TimeInterval,
|
|
[String: String]) async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)?
|
|
@TaskLocal static var hasCachedCredentialsOverride: Bool?
|
|
#endif
|
|
|
|
/// Creates a new ClaudeUsageFetcher.
|
|
/// - Parameters:
|
|
/// - environment: Process environment (default: current process environment)
|
|
/// - dataSource: Usage data source (default: OAuth API).
|
|
/// - useWebExtras: If true, attempts to enrich usage with Claude web data (cookies).
|
|
public init(
|
|
browserDetection: BrowserDetection,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
runtime: ProviderRuntime = .app,
|
|
dataSource: ClaudeUsageDataSource = .oauth,
|
|
oauthKeychainPromptCooldownEnabled: Bool = false,
|
|
allowBackgroundDelegatedRefresh: Bool = false,
|
|
allowStartupBootstrapPrompt: Bool = false,
|
|
useWebExtras: Bool = false,
|
|
manualCookieHeader: String? = nil,
|
|
webOrganizationID: String? = nil,
|
|
keepCLISessionsAlive: Bool = false)
|
|
{
|
|
self.configuration = Configuration(
|
|
environment: environment,
|
|
runtime: runtime,
|
|
dataSource: dataSource,
|
|
oauthKeychainPromptCooldownEnabled: oauthKeychainPromptCooldownEnabled,
|
|
allowBackgroundDelegatedRefresh: allowBackgroundDelegatedRefresh,
|
|
allowStartupBootstrapPrompt: allowStartupBootstrapPrompt,
|
|
useWebExtras: useWebExtras,
|
|
manualCookieHeader: manualCookieHeader,
|
|
webOrganizationID: webOrganizationID,
|
|
keepCLISessionsAlive: keepCLISessionsAlive,
|
|
browserDetection: browserDetection)
|
|
}
|
|
|
|
private struct OAuthExecutor {
|
|
let fetcher: ClaudeUsageFetcher
|
|
|
|
func load(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot {
|
|
do {
|
|
let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy()
|
|
|
|
#if DEBUG
|
|
let hasCache = if let hasCachedCredentialsOverride = ClaudeUsageFetcher.hasCachedCredentialsOverride {
|
|
hasCachedCredentialsOverride
|
|
} else if ClaudeUsageFetcher.loadOAuthCredentialsOverride != nil {
|
|
false
|
|
} else {
|
|
ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment)
|
|
}
|
|
#else
|
|
let hasCache = ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: self.fetcher.environment)
|
|
#endif
|
|
|
|
let startupBootstrapOverride = self.shouldAllowStartupBootstrapPrompt(
|
|
policy: promptPolicy,
|
|
hasCache: hasCache)
|
|
let allowKeychainPrompt = (promptPolicy.canPromptNow || startupBootstrapOverride) && !hasCache
|
|
ClaudeUsageFetcher.logOAuthBootstrapPromptDecision(
|
|
allowKeychainPrompt: allowKeychainPrompt,
|
|
policy: promptPolicy,
|
|
hasCache: hasCache,
|
|
startupBootstrapOverride: startupBootstrapOverride)
|
|
|
|
let credentialRecord = try await ClaudeOAuthCredentialsStore.$allowBackgroundPromptBootstrap
|
|
.withValue(startupBootstrapOverride) {
|
|
try await ClaudeUsageFetcher.loadOAuthCredentialRecord(
|
|
environment: self.fetcher.environment,
|
|
allowKeychainPrompt: allowKeychainPrompt,
|
|
respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown)
|
|
}
|
|
let credentials = credentialRecord.credentials
|
|
|
|
try self.validateRequiredOAuthScope(credentials)
|
|
let usage = try await ClaudeUsageFetcher.fetchOAuthUsage(
|
|
accessToken: credentials.accessToken,
|
|
detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection)
|
|
let keychainMatch = ClaudeOAuthCredentialsStore
|
|
.claudeKeychainCredentialMatchWithoutPrompt(for: credentialRecord)
|
|
return try ClaudeUsageFetcher.mapOAuthUsage(
|
|
usage,
|
|
credentials: credentials,
|
|
oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash,
|
|
oauthHistoryOwnerIdentifier: credentialRecord.historyOwnerIdentifier,
|
|
oauthKeychainCredentialMismatch: keychainMatch.isMismatch,
|
|
oauthKeychainCredentialAbsent: keychainMatch.isAbsent,
|
|
oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable)
|
|
} catch let error as CancellationError {
|
|
throw error
|
|
} catch let error as ClaudeUsageError {
|
|
throw error
|
|
} catch let error as ClaudeOAuthCredentialsError {
|
|
if case .refreshDelegatedToClaudeCLI = error {
|
|
return try await self.loadAfterDelegatedRefresh(allowDelegatedRetry: allowDelegatedRetry)
|
|
}
|
|
throw ClaudeUsageError.oauthFailed(error.localizedDescription)
|
|
} catch let error as ClaudeOAuthFetchError {
|
|
if case .rateLimited = error {
|
|
throw ClaudeUsageError.oauthFailed(error.localizedDescription)
|
|
}
|
|
ClaudeOAuthCredentialsStore.invalidateCache()
|
|
if case let .serverError(statusCode, body) = error,
|
|
statusCode == 403,
|
|
body?.contains("user:profile") ?? false
|
|
{
|
|
throw ClaudeUsageError.oauthFailed(
|
|
"Claude OAuth token does not meet scope requirement 'user:profile'. "
|
|
+ "Run `claude setup-token` to re-generate credentials, or switch Claude Source to "
|
|
+ "Web/CLI.")
|
|
}
|
|
throw ClaudeUsageError.oauthFailed(error.localizedDescription)
|
|
} catch {
|
|
throw ClaudeUsageError.oauthFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func shouldAllowStartupBootstrapPrompt(
|
|
policy: ClaudeOAuthKeychainPromptPolicy,
|
|
hasCache: Bool) -> Bool
|
|
{
|
|
guard self.fetcher.allowStartupBootstrapPrompt else { return false }
|
|
guard !hasCache else { return false }
|
|
guard ClaudeOAuthKeychainPromptPreference.securityFrameworkFallbackMode() == .onlyOnUserAction else {
|
|
return false
|
|
}
|
|
guard policy.interaction == .background else { return false }
|
|
return ProviderRefreshContext.current == .startup
|
|
}
|
|
|
|
private func loadAfterDelegatedRefresh(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot {
|
|
guard allowDelegatedRetry else {
|
|
throw ClaudeUsageError.oauthFailed(
|
|
"Claude OAuth token expired. CodexBar CLI does not launch Claude to refresh credentials. "
|
|
+ "Run `claude login`, then retry.")
|
|
}
|
|
|
|
try Task.checkCancellation()
|
|
|
|
let delegatedPromptPolicy = ClaudeUsageFetcher.currentClaudeOAuthDelegatedRefreshPolicy()
|
|
try ClaudeUsageFetcher.assertDelegatedRefreshAllowedInCurrentInteraction(
|
|
policy: delegatedPromptPolicy,
|
|
allowBackgroundDelegatedRefresh: self.fetcher.allowBackgroundDelegatedRefresh)
|
|
|
|
let delegatedOutcome = await ClaudeUsageFetcher.attemptDelegatedRefresh(
|
|
environment: self.fetcher.environment)
|
|
ClaudeUsageFetcher.log.info(
|
|
"Claude OAuth delegated refresh attempted",
|
|
metadata: [
|
|
"outcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome),
|
|
])
|
|
|
|
do {
|
|
if self.fetcher.oauthKeychainPromptCooldownEnabled {
|
|
switch delegatedOutcome {
|
|
case .skippedByCooldown, .cliUnavailable:
|
|
throw ClaudeUsageError.oauthFailed(
|
|
"Claude OAuth token expired; delegated refresh is unavailable (outcome="
|
|
+ "\(ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome))).")
|
|
case .attemptedSucceeded, .attemptedFailed:
|
|
break
|
|
}
|
|
}
|
|
|
|
try Task.checkCancellation()
|
|
|
|
_ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()
|
|
|
|
let didSyncSilently = delegatedOutcome == .attemptedSucceeded
|
|
&& ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(now: Date())
|
|
|
|
let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy()
|
|
ClaudeUsageFetcher.logDeferredBackgroundDelegatedRecoveryIfNeeded(
|
|
delegatedOutcome: delegatedOutcome,
|
|
didSyncSilently: didSyncSilently,
|
|
policy: promptPolicy)
|
|
let retryAllowKeychainPrompt = promptPolicy.canPromptNow && !didSyncSilently
|
|
if retryAllowKeychainPrompt {
|
|
ClaudeUsageFetcher.log.info(
|
|
"Claude OAuth keychain prompt allowed (post-delegation retry)",
|
|
metadata: [
|
|
"interaction": promptPolicy.interactionLabel,
|
|
"promptMode": promptPolicy.mode.rawValue,
|
|
"promptPolicyApplicable": "\(promptPolicy.isApplicable)",
|
|
"delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome),
|
|
"didSyncSilently": "\(didSyncSilently)",
|
|
])
|
|
}
|
|
if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled {
|
|
ClaudeUsageFetcher.log.debug(
|
|
"Claude OAuth credential load (post-delegation retry start)",
|
|
metadata: [
|
|
"cooldownEnabled": "\(self.fetcher.oauthKeychainPromptCooldownEnabled)",
|
|
"didSyncSilently": "\(didSyncSilently)",
|
|
"allowKeychainPrompt": "\(retryAllowKeychainPrompt)",
|
|
"delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome),
|
|
"interaction": promptPolicy.interactionLabel,
|
|
"promptMode": promptPolicy.mode.rawValue,
|
|
"promptPolicyApplicable": "\(promptPolicy.isApplicable)",
|
|
])
|
|
}
|
|
|
|
let refreshedRecord = try await ClaudeUsageFetcher.loadOAuthCredentialRecord(
|
|
environment: self.fetcher.environment,
|
|
allowKeychainPrompt: retryAllowKeychainPrompt,
|
|
respectKeychainPromptCooldown: promptPolicy.shouldRespectKeychainPromptCooldown)
|
|
let refreshedCredentials = refreshedRecord.credentials
|
|
if ClaudeUsageFetcher.isClaudeOAuthFlowDebugEnabled {
|
|
ClaudeUsageFetcher.log.debug(
|
|
"Claude OAuth credential load (post-delegation retry)",
|
|
metadata: [
|
|
"cooldownEnabled": "\(self.fetcher.oauthKeychainPromptCooldownEnabled)",
|
|
"didSyncSilently": "\(didSyncSilently)",
|
|
"allowKeychainPrompt": "\(retryAllowKeychainPrompt)",
|
|
"delegatedOutcome": ClaudeUsageFetcher.delegatedRefreshOutcomeLabel(delegatedOutcome),
|
|
"interaction": promptPolicy.interactionLabel,
|
|
"promptMode": promptPolicy.mode.rawValue,
|
|
"promptPolicyApplicable": "\(promptPolicy.isApplicable)",
|
|
])
|
|
}
|
|
|
|
try self.validateRequiredOAuthScope(refreshedCredentials)
|
|
let usage = try await ClaudeUsageFetcher.fetchOAuthUsage(
|
|
accessToken: refreshedCredentials.accessToken,
|
|
detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection)
|
|
let keychainMatch = ClaudeOAuthCredentialsStore
|
|
.claudeKeychainCredentialMatchWithoutPrompt(for: refreshedRecord)
|
|
return try ClaudeUsageFetcher.mapOAuthUsage(
|
|
usage,
|
|
credentials: refreshedCredentials,
|
|
oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash,
|
|
oauthHistoryOwnerIdentifier: refreshedRecord.historyOwnerIdentifier,
|
|
oauthKeychainCredentialMismatch: keychainMatch.isMismatch,
|
|
oauthKeychainCredentialAbsent: keychainMatch.isAbsent,
|
|
oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable)
|
|
} catch {
|
|
ClaudeUsageFetcher.log.debug(
|
|
"Claude OAuth post-delegation retry failed",
|
|
metadata: ClaudeUsageFetcher.delegatedRetryFailureMetadata(
|
|
error: error,
|
|
oauthKeychainPromptCooldownEnabled: self.fetcher.oauthKeychainPromptCooldownEnabled,
|
|
delegatedOutcome: delegatedOutcome))
|
|
throw ClaudeUsageError.oauthFailed(
|
|
ClaudeUsageFetcher.delegatedRefreshFailureMessage(
|
|
for: delegatedOutcome,
|
|
retryError: error))
|
|
}
|
|
}
|
|
|
|
private func validateRequiredOAuthScope(_ credentials: ClaudeOAuthCredentials) throws {
|
|
guard credentials.scopes.contains("user:profile") else {
|
|
let scopes = credentials.scopes.joined(separator: ", ")
|
|
let detail = scopes.isEmpty
|
|
? "Claude OAuth token missing 'user:profile' scope."
|
|
: "Claude OAuth token missing 'user:profile' scope (has: \(scopes))."
|
|
throw ClaudeUsageError.oauthFailed(
|
|
detail + " Run `claude setup-token` to re-generate credentials, or switch Claude Source to "
|
|
+ "Web/CLI.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct StepExecutor {
|
|
let fetcher: ClaudeUsageFetcher
|
|
|
|
func loadLatestUsage(model: String) async throws -> ClaudeUsageSnapshot {
|
|
switch self.fetcher.dataSource {
|
|
case .auto:
|
|
return try await self.executeAuto(model: model)
|
|
case .api:
|
|
throw ClaudeUsageError.parseFailed("Claude Admin API usage is handled by the provider descriptor.")
|
|
case .oauth:
|
|
var snapshot = try await self.fetcher.loadViaOAuth(
|
|
allowDelegatedRetry: self.fetcher.allowsDelegatedOAuthRefresh)
|
|
snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot)
|
|
return snapshot
|
|
case .web:
|
|
return try await self.fetcher.loadViaWebAPI()
|
|
case .cli:
|
|
return try await self.loadViaCLIWithRetry(model: model)
|
|
}
|
|
}
|
|
|
|
private func executeAuto(model: String) async throws -> ClaudeUsageSnapshot {
|
|
let plan = await self.makeAutoFetchPlan()
|
|
self.logAutoPlan(plan)
|
|
|
|
let executionSteps = plan.executionSteps
|
|
for (index, step) in executionSteps.enumerated() {
|
|
do {
|
|
return try await self.execute(step: step, model: model)
|
|
} catch {
|
|
if index < executionSteps.count - 1 {
|
|
ClaudeUsageFetcher.log.debug(
|
|
"Claude planner step failed; falling back to next step",
|
|
metadata: [
|
|
"step": step.dataSource.rawValue,
|
|
"reason": step.inclusionReason.rawValue,
|
|
"errorType": String(describing: type(of: error)),
|
|
])
|
|
continue
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
throw ClaudeUsageError.parseFailed("Claude planner produced no executable steps.")
|
|
}
|
|
|
|
private func makeAutoFetchPlan() async -> ClaudeFetchPlan {
|
|
let hasWebSession =
|
|
if let header = self.fetcher.manualCookieHeader {
|
|
ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header)
|
|
} else {
|
|
ClaudeWebAPIFetcher.hasSessionKey(browserDetection: self.fetcher.browserDetection)
|
|
}
|
|
let hasCLI = ClaudeCLIResolver.isAvailable(environment: self.fetcher.environment)
|
|
return ClaudeSourcePlanner.resolve(input: ClaudeSourcePlanningInput(
|
|
runtime: self.fetcher.runtime,
|
|
selectedDataSource: .auto,
|
|
webExtrasEnabled: self.fetcher.useWebExtras,
|
|
hasWebSession: hasWebSession,
|
|
hasCLI: hasCLI,
|
|
hasOAuthCredentials: ClaudeOAuthPlanningAvailability.isAvailable(
|
|
runtime: self.fetcher.runtime,
|
|
sourceMode: .auto,
|
|
environment: self.fetcher.environment)))
|
|
}
|
|
|
|
private func logAutoPlan(_ plan: ClaudeFetchPlan) {
|
|
var metadata: [String: String] = [
|
|
"plannerOrder": plan.orderLabel,
|
|
"selected": plan.preferredStep?.dataSource.rawValue ?? "none",
|
|
"noSourceAvailable": "\(plan.isNoSourceAvailable)",
|
|
"webExtrasEnabled": "\(self.fetcher.useWebExtras)",
|
|
"oauthReadStrategy": ClaudeOAuthKeychainReadStrategyPreference.current().rawValue,
|
|
]
|
|
for (index, step) in plan.orderedSteps.enumerated() {
|
|
metadata["step\(index)"] =
|
|
"\(step.dataSource.rawValue):\(step.inclusionReason.rawValue):\(step.isPlausiblyAvailable)"
|
|
}
|
|
ClaudeUsageFetcher.log.debug("Claude auto source planner", metadata: metadata)
|
|
}
|
|
|
|
private func execute(step: ClaudeFetchPlanStep, model: String) async throws -> ClaudeUsageSnapshot {
|
|
switch step.dataSource {
|
|
case .api:
|
|
throw ClaudeUsageError.parseFailed("Planner emitted invalid api execution step.")
|
|
case .oauth:
|
|
var snapshot = try await self.fetcher.loadViaOAuth(
|
|
allowDelegatedRetry: self.fetcher.allowsDelegatedOAuthRefresh)
|
|
snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot)
|
|
return snapshot
|
|
case .web:
|
|
return try await self.fetcher.loadViaWebAPI()
|
|
case .cli:
|
|
return try await self.loadViaAutoCLI(model: model)
|
|
case .auto:
|
|
throw ClaudeUsageError.parseFailed("Planner emitted invalid auto execution step.")
|
|
}
|
|
}
|
|
|
|
private func loadViaAutoCLI(model: String) async throws -> ClaudeUsageSnapshot {
|
|
do {
|
|
return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliAutoProbeTimeout)
|
|
} catch {
|
|
if error is CancellationError {
|
|
throw error
|
|
}
|
|
guard Self.shouldRetryCLIProbe(after: error) else { throw error }
|
|
return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout)
|
|
}
|
|
}
|
|
|
|
private func loadViaCLIWithRetry(model: String) async throws -> ClaudeUsageSnapshot {
|
|
do {
|
|
return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliProbeTimeout)
|
|
} catch {
|
|
if error is CancellationError {
|
|
throw error
|
|
}
|
|
guard Self.shouldRetryCLIProbe(after: error) else { throw error }
|
|
return try await self.loadViaCLI(model: model, timeout: ClaudeUsageFetcher.cliRetryProbeTimeout)
|
|
}
|
|
}
|
|
|
|
private func loadViaCLI(model: String, timeout: TimeInterval) async throws -> ClaudeUsageSnapshot {
|
|
if ClaudeCLIRateLimitGate.blockedUntil() != nil {
|
|
throw ClaudeUsageError.parseFailed(ClaudeCLIRateLimitGate.message)
|
|
}
|
|
|
|
var snapshot: ClaudeUsageSnapshot
|
|
do {
|
|
snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: timeout)
|
|
} catch {
|
|
if error is CancellationError {
|
|
throw error
|
|
}
|
|
if ClaudeUsageFetcher.isCLIRateLimitError(error) {
|
|
ClaudeCLIRateLimitGate.recordRateLimit()
|
|
throw error
|
|
}
|
|
guard Self.shouldTryDirectCLIUsage(after: error) else { throw error }
|
|
let ptyError = error
|
|
do {
|
|
snapshot = try await self.fetcher.loadViaDirectCLI(
|
|
timeout: Self.directCLIUsageTimeout(for: timeout))
|
|
} catch let directError {
|
|
if directError is CancellationError {
|
|
throw directError
|
|
}
|
|
if ClaudeUsageFetcher.isCLIRateLimitError(directError) {
|
|
ClaudeCLIRateLimitGate.recordRateLimit()
|
|
throw directError
|
|
}
|
|
guard Self.directCLIErrorShouldReplacePTYError(directError) else { throw ptyError }
|
|
throw directError
|
|
}
|
|
}
|
|
ClaudeCLIRateLimitGate.recordSuccess()
|
|
snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot)
|
|
return snapshot
|
|
}
|
|
|
|
private static func directCLIUsageTimeout(for ptyTimeout: TimeInterval) -> TimeInterval {
|
|
min(max(ptyTimeout / 3, 6), 8)
|
|
}
|
|
|
|
private static func directCLIErrorShouldReplacePTYError(_ error: Error) -> Bool {
|
|
if case let ClaudeStatusProbeError.parseFailed(message) = error {
|
|
return message.lowercased().contains("subscription")
|
|
}
|
|
if case let ClaudeUsageError.parseFailed(message) = error {
|
|
return message.lowercased().contains("subscription")
|
|
}
|
|
return false
|
|
}
|
|
|
|
private static func shouldTryDirectCLIUsage(after error: Error) -> Bool {
|
|
if case ClaudeStatusProbeError.timedOut = error {
|
|
return true
|
|
}
|
|
if case let ClaudeStatusProbeError.parseFailed(message) = error {
|
|
let lower = message.lowercased()
|
|
return lower.contains("still loading usage") || lower.contains("could not load usage data")
|
|
}
|
|
let message = error.localizedDescription.lowercased()
|
|
return message.contains("timed out") || message.contains("timeout")
|
|
}
|
|
|
|
private static func shouldRetryCLIProbe(after error: Error) -> Bool {
|
|
if case ClaudeStatusProbeError.timedOut = error {
|
|
return true
|
|
}
|
|
if case let ClaudeStatusProbeError.parseFailed(message) = error {
|
|
return message.lowercased().contains("still loading usage")
|
|
}
|
|
let message = error.localizedDescription.lowercased()
|
|
return message.contains("timed out") || message.contains("timeout")
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ClaudeUsageFetcher {
|
|
// MARK: - Parsing helpers
|
|
|
|
public static func parse(json: Data) -> ClaudeUsageSnapshot? {
|
|
self.parse(json: json, now: .init())
|
|
}
|
|
|
|
package static func parse(json: Data, now: Date) -> ClaudeUsageSnapshot? {
|
|
guard let output = String(data: json, encoding: .utf8) else { return nil }
|
|
return try? Self.parse(output: output, now: now)
|
|
}
|
|
|
|
private static func parse(output: String, now: Date) throws -> ClaudeUsageSnapshot {
|
|
guard
|
|
let data = output.data(using: .utf8),
|
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
else {
|
|
throw ClaudeUsageError.parseFailed(output.prefix(500).description)
|
|
}
|
|
|
|
if let ok = obj["ok"] as? Bool, !ok {
|
|
let hint = obj["hint"] as? String ?? (obj["pane_preview"] as? String ?? "")
|
|
throw ClaudeUsageError.parseFailed(hint)
|
|
}
|
|
|
|
func firstWindowDict(_ keys: [String]) -> [String: Any]? {
|
|
for key in keys {
|
|
if let dict = obj[key] as? [String: Any] {
|
|
return dict
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func makeWindow(_ dict: [String: Any]?, windowMinutes: Int) -> RateWindow? {
|
|
guard let dict else { return nil }
|
|
let pct = (dict["pct_used"] as? NSNumber)?.doubleValue ?? 0
|
|
let resetText = dict["resets"] as? String
|
|
return RateWindow(
|
|
usedPercent: pct,
|
|
windowMinutes: windowMinutes,
|
|
resetsAt: ClaudeStatusProbe.parseResetDate(
|
|
from: resetText,
|
|
now: now,
|
|
expectedWindow: TimeInterval(windowMinutes * 60)),
|
|
resetDescription: resetText)
|
|
}
|
|
|
|
guard let session = makeWindow(
|
|
firstWindowDict(["session_5h"]),
|
|
windowMinutes: Self.sessionWindowMinutes)
|
|
else {
|
|
throw ClaudeUsageError.parseFailed("missing session data")
|
|
}
|
|
let weekAll = makeWindow(
|
|
firstWindowDict(["week_all_models", "week_all"]),
|
|
windowMinutes: Self.weeklyWindowMinutes)
|
|
|
|
let rawEmail = (obj["account_email"] as? String)?.trimmingCharacters(
|
|
in: .whitespacesAndNewlines)
|
|
let email = (rawEmail?.isEmpty ?? true) ? nil : rawEmail
|
|
let rawOrg = (obj["account_org"] as? String)?.trimmingCharacters(
|
|
in: .whitespacesAndNewlines)
|
|
let org = (rawOrg?.isEmpty ?? true) ? nil : rawOrg
|
|
let loginMethod = (obj["login_method"] as? String)?.trimmingCharacters(
|
|
in: .whitespacesAndNewlines)
|
|
let opusWindow = makeWindow(
|
|
firstWindowDict([
|
|
"week_sonnet",
|
|
"week_sonnet_only",
|
|
"week_opus",
|
|
]),
|
|
windowMinutes: Self.weeklyWindowMinutes)
|
|
return ClaudeUsageSnapshot(
|
|
primary: session,
|
|
secondary: weekAll,
|
|
opus: opusWindow,
|
|
providerCost: nil,
|
|
updatedAt: now,
|
|
accountEmail: email,
|
|
accountOrganization: org,
|
|
loginMethod: loginMethod,
|
|
rawText: output)
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
public func detectVersion() -> String? {
|
|
ProviderVersionDetector.claudeVersion()
|
|
}
|
|
|
|
public func debugRawProbe(model: String = "sonnet") async -> String {
|
|
do {
|
|
let snap = try await self.loadViaPTY(model: model, timeout: Self.cliProbeTimeout)
|
|
let opus = snap.opus?.remainingPercent ?? -1
|
|
let email = snap.accountEmail ?? "nil"
|
|
let org = snap.accountOrganization ?? "nil"
|
|
let weekly = snap.secondary?.remainingPercent ?? -1
|
|
let primary = snap.primary.remainingPercent
|
|
return """
|
|
session_left=\(primary) weekly_left=\(weekly)
|
|
opus_left=\(opus) email \(email) org \(org)
|
|
\(snap)
|
|
"""
|
|
} catch {
|
|
return "Probe failed: \(error)"
|
|
}
|
|
}
|
|
|
|
public func loadLatestUsage(model: String = "sonnet") async throws -> ClaudeUsageSnapshot {
|
|
try await StepExecutor(fetcher: self).loadLatestUsage(model: model)
|
|
}
|
|
|
|
public static func isCLIRateLimitError(_ error: Error) -> Bool {
|
|
ClaudeCLIRateLimitGate.isRateLimitError(error)
|
|
}
|
|
}
|
|
|
|
extension ClaudeUsageFetcher {
|
|
// MARK: - OAuth API path
|
|
|
|
private static func logOAuthBootstrapPromptDecision(
|
|
allowKeychainPrompt: Bool,
|
|
policy: ClaudeOAuthKeychainPromptPolicy,
|
|
hasCache: Bool,
|
|
startupBootstrapOverride: Bool)
|
|
{
|
|
guard allowKeychainPrompt else { return }
|
|
self.log.info(
|
|
"Claude OAuth keychain prompt allowed (bootstrap)",
|
|
metadata: [
|
|
"interaction": policy.interactionLabel,
|
|
"promptMode": policy.mode.rawValue,
|
|
"promptPolicyApplicable": "\(policy.isApplicable)",
|
|
"hasCache": "\(hasCache)",
|
|
"startupBootstrapOverride": "\(startupBootstrapOverride)",
|
|
])
|
|
}
|
|
|
|
private static func logDeferredBackgroundDelegatedRecoveryIfNeeded(
|
|
delegatedOutcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome,
|
|
didSyncSilently: Bool,
|
|
policy: ClaudeOAuthKeychainPromptPolicy)
|
|
{
|
|
guard delegatedOutcome == .attemptedSucceeded else { return }
|
|
guard !didSyncSilently else { return }
|
|
guard policy.mode == .onlyOnUserAction else { return }
|
|
guard policy.interaction == .background else { return }
|
|
self.log.info(
|
|
"Claude OAuth delegated refresh completed; background recovery deferred until user action",
|
|
metadata: [
|
|
"interaction": policy.interactionLabel,
|
|
"promptMode": policy.mode.rawValue,
|
|
"delegatedOutcome": self.delegatedRefreshOutcomeLabel(delegatedOutcome),
|
|
])
|
|
}
|
|
|
|
private func loadViaOAuth(allowDelegatedRetry: Bool) async throws -> ClaudeUsageSnapshot {
|
|
try await OAuthExecutor(fetcher: self).load(allowDelegatedRetry: allowDelegatedRetry)
|
|
}
|
|
|
|
private static func loadOAuthCredentialRecord(
|
|
environment: [String: String],
|
|
allowKeychainPrompt: Bool,
|
|
respectKeychainPromptCooldown: Bool) async throws -> ClaudeOAuthCredentialRecord
|
|
{
|
|
#if DEBUG
|
|
if let override = loadOAuthCredentialsOverride {
|
|
return try await ClaudeOAuthCredentialRecord(
|
|
credentials: override(environment, allowKeychainPrompt, respectKeychainPromptCooldown),
|
|
owner: .environment,
|
|
source: .environment)
|
|
}
|
|
#endif
|
|
return try await ClaudeOAuthCredentialsStore.loadRecordWithAutoRefresh(
|
|
environment: environment,
|
|
allowKeychainPrompt: allowKeychainPrompt,
|
|
respectKeychainPromptCooldown: respectKeychainPromptCooldown)
|
|
}
|
|
|
|
private static func fetchOAuthUsage(
|
|
accessToken: String,
|
|
detectClaudeVersion: Bool) async throws -> OAuthUsageResponse
|
|
{
|
|
#if DEBUG
|
|
if let override = fetchOAuthUsageOverride {
|
|
return try await override(accessToken, detectClaudeVersion)
|
|
}
|
|
#endif
|
|
return try await ClaudeOAuthUsageFetcher.fetchUsage(
|
|
accessToken: accessToken,
|
|
detectClaudeVersion: detectClaudeVersion)
|
|
}
|
|
|
|
private static func attemptDelegatedRefresh(
|
|
now: Date = Date(),
|
|
timeout: TimeInterval = 15,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment)
|
|
async -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome
|
|
{
|
|
#if DEBUG
|
|
if let override = delegatedRefreshAttemptOverride {
|
|
return await override(now, timeout, environment)
|
|
}
|
|
#endif
|
|
return await ClaudeOAuthDelegatedRefreshCoordinator.attempt(
|
|
now: now,
|
|
timeout: timeout,
|
|
environment: environment)
|
|
}
|
|
|
|
private static func delegatedRefreshOutcomeLabel(
|
|
_ outcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome) -> String
|
|
{
|
|
switch outcome {
|
|
case .skippedByCooldown:
|
|
"skippedByCooldown"
|
|
case .cliUnavailable:
|
|
"cliUnavailable"
|
|
case .attemptedSucceeded:
|
|
"attemptedSucceeded"
|
|
case .attemptedFailed:
|
|
"attemptedFailed"
|
|
}
|
|
}
|
|
|
|
private static func delegatedRefreshFailureMessage(
|
|
for outcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome,
|
|
retryError: Error) -> String
|
|
{
|
|
if let oauthError = retryError as? ClaudeOAuthFetchError,
|
|
case .rateLimited = oauthError
|
|
{
|
|
return oauthError.localizedDescription
|
|
}
|
|
|
|
switch outcome {
|
|
case .skippedByCooldown:
|
|
return "Claude OAuth token expired and delegated refresh is cooling down. "
|
|
+ "Please retry shortly, or run `claude login`."
|
|
case .cliUnavailable:
|
|
return "Claude OAuth token expired and Claude CLI is not available for delegated refresh. "
|
|
+ "Install/configure `claude`, or run `claude login`."
|
|
case .attemptedSucceeded:
|
|
return "Claude OAuth token is still unavailable after delegated Claude CLI refresh. "
|
|
+ "Run `claude login`, then retry."
|
|
case let .attemptedFailed(message):
|
|
return "Claude OAuth token expired and delegated Claude CLI refresh failed: \(message). "
|
|
+ "Run `claude login`, then retry."
|
|
}
|
|
}
|
|
|
|
private static func delegatedRetryFailureMetadata(
|
|
error: Error,
|
|
oauthKeychainPromptCooldownEnabled: Bool,
|
|
delegatedOutcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome) -> [String: String]
|
|
{
|
|
var metadata: [String: String] = [
|
|
"errorType": String(describing: type(of: error)),
|
|
"cooldownEnabled": "\(oauthKeychainPromptCooldownEnabled)",
|
|
"delegatedOutcome": Self.delegatedRefreshOutcomeLabel(delegatedOutcome),
|
|
]
|
|
|
|
// Avoid `localizedDescription` here: some error types include server response bodies in their
|
|
// `errorDescription`, which can leak potentially identifying information into logs.
|
|
if let oauthError = error as? ClaudeOAuthFetchError {
|
|
switch oauthError {
|
|
case .unauthorized:
|
|
metadata["oauthError"] = "unauthorized"
|
|
case let .rateLimited(retryAfter):
|
|
metadata["oauthError"] = "rateLimited"
|
|
metadata["retryAfter"] = retryAfter.map { "\($0.timeIntervalSince1970)" } ?? "nil"
|
|
case .invalidResponse:
|
|
metadata["oauthError"] = "invalidResponse"
|
|
case let .serverError(statusCode, body):
|
|
metadata["oauthError"] = "serverError"
|
|
metadata["httpStatus"] = "\(statusCode)"
|
|
metadata["bodyLength"] = "\(body?.utf8.count ?? 0)"
|
|
case let .networkError(underlying):
|
|
metadata["oauthError"] = "networkError"
|
|
metadata["underlyingType"] = String(describing: type(of: underlying))
|
|
}
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
private static func mapOAuthUsage(
|
|
_ usage: OAuthUsageResponse,
|
|
credentials: ClaudeOAuthCredentials,
|
|
oauthKeychainPersistentRefHash: String? = nil,
|
|
oauthHistoryOwnerIdentifier: String? = nil,
|
|
oauthKeychainCredentialMismatch: Bool = false,
|
|
oauthKeychainCredentialAbsent: Bool = false,
|
|
oauthKeychainCredentialUnavailable: Bool = false) throws -> ClaudeUsageSnapshot
|
|
{
|
|
let oauthHistoryOwnerIdentifier = ClaudeOAuthCredentials.normalizedHistoryOwnerIdentifier(
|
|
oauthHistoryOwnerIdentifier) ?? credentials.historyOwnerIdentifier
|
|
|
|
func makeWindow(_ window: OAuthUsageWindow?, windowMinutes: Int?) -> RateWindow? {
|
|
guard let window,
|
|
let utilization = window.utilization
|
|
else { return nil }
|
|
let resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt)
|
|
let resetDescription = resetDate.map(Self.formatResetDate)
|
|
return RateWindow(
|
|
usedPercent: utilization,
|
|
windowMinutes: windowMinutes,
|
|
resetsAt: resetDate,
|
|
resetDescription: resetDescription)
|
|
}
|
|
|
|
let loginMethod = ClaudePlan.oauthLoginMethod(
|
|
subscriptionType: credentials.subscriptionType,
|
|
rateLimitTier: credentials.rateLimitTier)
|
|
let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60)
|
|
?? makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60)
|
|
?? makeWindow(usage.sevenDayOAuthApps, windowMinutes: 7 * 24 * 60)
|
|
?? makeWindow(usage.sevenDaySonnet, windowMinutes: 7 * 24 * 60)
|
|
?? makeWindow(usage.sevenDayOpus, windowMinutes: 7 * 24 * 60)
|
|
let treatAsSpendLimit = primary == nil && usage.extraUsage?.isEnabled == true
|
|
let providerCost = Self.oauthExtraUsageCost(
|
|
usage.extraUsage,
|
|
loginMethod: loginMethod,
|
|
treatAsSpendLimit: treatAsSpendLimit)
|
|
|
|
guard let primary else {
|
|
if let spendLimit = Self.oauthSpendLimitWindow(from: providerCost, extraUsage: usage.extraUsage) {
|
|
return ClaudeUsageSnapshot(
|
|
primary: spendLimit,
|
|
primaryWindowKind: .spendLimit,
|
|
secondary: nil,
|
|
opus: nil,
|
|
extraRateWindows: Self.oauthExtraRateWindows(from: usage),
|
|
providerCost: providerCost,
|
|
updatedAt: Date(),
|
|
accountEmail: nil,
|
|
accountOrganization: nil,
|
|
loginMethod: loginMethod,
|
|
rawText: nil,
|
|
oauthKeychainPersistentRefHash: oauthKeychainPersistentRefHash,
|
|
oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier,
|
|
oauthKeychainCredentialMismatch: oauthKeychainCredentialMismatch,
|
|
oauthKeychainCredentialAbsent: oauthKeychainCredentialAbsent,
|
|
oauthKeychainCredentialUnavailable: oauthKeychainCredentialUnavailable)
|
|
}
|
|
throw ClaudeUsageError.parseFailed("missing session data")
|
|
}
|
|
|
|
let weekly = makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60)
|
|
let modelSpecific = makeWindow(
|
|
usage.sevenDaySonnet ?? usage.sevenDayOpus,
|
|
windowMinutes: 7 * 24 * 60)
|
|
let extraRateWindows = Self.oauthExtraRateWindows(from: usage)
|
|
|
|
return ClaudeUsageSnapshot(
|
|
primary: primary,
|
|
secondary: weekly,
|
|
opus: modelSpecific,
|
|
extraRateWindows: extraRateWindows,
|
|
providerCost: providerCost,
|
|
updatedAt: Date(),
|
|
accountEmail: nil,
|
|
accountOrganization: nil,
|
|
loginMethod: loginMethod,
|
|
rawText: nil,
|
|
oauthKeychainPersistentRefHash: oauthKeychainPersistentRefHash,
|
|
oauthHistoryOwnerIdentifier: oauthHistoryOwnerIdentifier,
|
|
oauthKeychainCredentialMismatch: oauthKeychainCredentialMismatch,
|
|
oauthKeychainCredentialAbsent: oauthKeychainCredentialAbsent,
|
|
oauthKeychainCredentialUnavailable: oauthKeychainCredentialUnavailable)
|
|
}
|
|
|
|
private static func oauthExtraUsageCost(
|
|
_ extra: OAuthExtraUsage?,
|
|
loginMethod: String?,
|
|
treatAsSpendLimit: Bool = false) -> ProviderCostSnapshot?
|
|
{
|
|
guard let extra, extra.isEnabled == true else { return nil }
|
|
guard let used = extra.usedCredits,
|
|
let limit = extra.monthlyLimit
|
|
else { return nil }
|
|
let currency = extra.currency?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let code = (currency?.isEmpty ?? true) ? "USD" : currency!
|
|
let isSpendLimit = treatAsSpendLimit || ClaudePlan.fromCompatibilityLoginMethod(loginMethod) == .enterprise
|
|
let normalized = Self.normalizeClaudeExtraUsageAmounts(
|
|
used: used,
|
|
limit: limit,
|
|
treatAsMajorUnits: false)
|
|
return ProviderCostSnapshot(
|
|
used: normalized.used,
|
|
limit: normalized.limit,
|
|
currencyCode: code,
|
|
period: isSpendLimit ? "Spend limit" : "Monthly cap",
|
|
resetsAt: nil,
|
|
updatedAt: Date())
|
|
}
|
|
|
|
private static func oauthSpendLimitWindow(
|
|
from providerCost: ProviderCostSnapshot?,
|
|
extraUsage: OAuthExtraUsage?) -> RateWindow?
|
|
{
|
|
guard let providerCost,
|
|
providerCost.limit > 0
|
|
else { return nil }
|
|
let usedPercent = extraUsage?.utilization ?? (providerCost.used / providerCost.limit) * 100
|
|
let used = UsageFormatter.currencyString(providerCost.used, currencyCode: providerCost.currencyCode)
|
|
let limit = UsageFormatter.currencyString(providerCost.limit, currencyCode: providerCost.currencyCode)
|
|
return RateWindow(
|
|
usedPercent: min(100, max(0, usedPercent)),
|
|
windowMinutes: nil,
|
|
resetsAt: providerCost.resetsAt,
|
|
resetDescription: "\(providerCost.period ?? "Spend limit"): \(used) / \(limit)")
|
|
}
|
|
|
|
private static func normalizeClaudeExtraUsageAmounts(
|
|
used: Double,
|
|
limit: Double,
|
|
treatAsMajorUnits: Bool) -> (used: Double, limit: Double)
|
|
{
|
|
if treatAsMajorUnits {
|
|
return (used: used, limit: limit)
|
|
}
|
|
|
|
// Claude's OAuth API returns values in cents (minor units), same as the Web API.
|
|
// Always convert to dollars (major units) for display consistency.
|
|
// See: ClaudeWebAPIFetcher.swift which always divides by 100.
|
|
return (used: used / 100.0, limit: limit / 100.0)
|
|
}
|
|
|
|
private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] {
|
|
let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [
|
|
(
|
|
id: "claude-routines",
|
|
title: "Daily Routines",
|
|
window: usage.sevenDayRoutines,
|
|
sourceKey: usage.sevenDayRoutinesSourceKey),
|
|
]
|
|
if let routinesKey = usage.sevenDayRoutinesSourceKey {
|
|
Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)")
|
|
}
|
|
let routineWindows: [NamedRateWindow] = definitions.compactMap { definition in
|
|
let utilization: Double
|
|
let resetDate: Date?
|
|
if let window = definition.window, let parsedUtilization = window.utilization {
|
|
utilization = parsedUtilization
|
|
resetDate = ClaudeOAuthUsageFetcher.parseISO8601Date(window.resetsAt)
|
|
} else if definition.sourceKey != nil {
|
|
// Keep product bars visible when the API returns a known key with null payload.
|
|
utilization = 0
|
|
resetDate = nil
|
|
} else {
|
|
return nil
|
|
}
|
|
let resetDescription = resetDate.map(Self.formatResetDate)
|
|
return NamedRateWindow(
|
|
id: definition.id,
|
|
title: definition.title,
|
|
window: RateWindow(
|
|
usedPercent: utilization,
|
|
windowMinutes: Self.weeklyWindowMinutes,
|
|
resetsAt: resetDate,
|
|
resetDescription: resetDescription))
|
|
}
|
|
return routineWindows + Self.oauthScopedWeeklyLimitWindows(from: usage)
|
|
}
|
|
|
|
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: ClaudeOAuthUsageFetcher.parseISO8601Date(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)
|
|
}
|
|
|
|
// MARK: - Web API path (uses browser cookies)
|
|
|
|
/// The 5-hour session (`primary`) window for a Claude web usage payload.
|
|
///
|
|
/// When `five_hour` is null the web fetcher reports a synthesized 0% session (an account with no live
|
|
/// session window). Flag that placeholder so lane classifiers — e.g. the combined Session + Weekly
|
|
/// menu-bar metric — surface the weekly lane instead of a phantom 5h session. The flag keys off the
|
|
/// reported presence of the session object, so a real session that is merely idle (0% used, possibly
|
|
/// with no reset) stays unflagged and keeps rendering.
|
|
static func webPrimaryWindow(from webData: ClaudeWebAPIFetcher.WebUsageData) -> RateWindow {
|
|
RateWindow(
|
|
usedPercent: webData.sessionPercentUsed,
|
|
windowMinutes: 5 * 60,
|
|
resetsAt: webData.sessionResetsAt,
|
|
resetDescription: webData.sessionResetsAt.map { Self.formatResetDate($0) },
|
|
isSyntheticPlaceholder: !webData.hasLiveSessionWindow)
|
|
}
|
|
|
|
private func loadViaWebAPI() async throws -> ClaudeUsageSnapshot {
|
|
let webData: ClaudeWebAPIFetcher.WebUsageData =
|
|
if let header = self.manualCookieHeader {
|
|
try await ClaudeWebAPIFetcher.fetchUsage(
|
|
cookieHeader: header,
|
|
targetOrganizationID: self.webOrganizationID)
|
|
{ msg in
|
|
Self.log.debug(msg)
|
|
}
|
|
} else {
|
|
try await ClaudeWebAPIFetcher.fetchUsage(
|
|
browserDetection: self.browserDetection,
|
|
targetOrganizationID: self.webOrganizationID)
|
|
{ msg in
|
|
Self.log.debug(msg)
|
|
}
|
|
}
|
|
// Convert web API data to ClaudeUsageSnapshot format
|
|
let primary = Self.webPrimaryWindow(from: webData)
|
|
|
|
let secondary: RateWindow? = webData.weeklyPercentUsed.map { pct in
|
|
RateWindow(
|
|
usedPercent: pct,
|
|
windowMinutes: 7 * 24 * 60,
|
|
resetsAt: webData.weeklyResetsAt,
|
|
resetDescription: webData.weeklyResetsAt.map { Self.formatResetDate($0) })
|
|
}
|
|
|
|
let opus: RateWindow? = webData.opusPercentUsed.map { opusPct in
|
|
RateWindow(
|
|
usedPercent: opusPct,
|
|
windowMinutes: 7 * 24 * 60,
|
|
resetsAt: webData.weeklyResetsAt,
|
|
resetDescription: webData.weeklyResetsAt.map { Self.formatResetDate($0) })
|
|
}
|
|
|
|
return ClaudeUsageSnapshot(
|
|
primary: primary,
|
|
secondary: secondary,
|
|
opus: opus,
|
|
extraRateWindows: webData.extraRateWindows,
|
|
providerCost: webData.extraUsageCost,
|
|
updatedAt: Date(),
|
|
accountEmail: webData.accountEmail,
|
|
accountOrganization: webData.accountOrganization,
|
|
loginMethod: webData.loginMethod,
|
|
rawText: nil)
|
|
}
|
|
|
|
private static func formatResetDate(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "MMM d 'at' h:mma"
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
// MARK: - PTY-based probe (no tmux)
|
|
|
|
private func loadViaPTY(model: String, timeout: TimeInterval = 10) async throws -> ClaudeUsageSnapshot {
|
|
guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else {
|
|
throw ClaudeUsageError.claudeNotInstalled
|
|
}
|
|
let probe = ClaudeStatusProbe(
|
|
claudeBinary: claudeBinary,
|
|
timeout: timeout,
|
|
keepCLISessionsAlive: self.keepCLISessionsAlive)
|
|
let snap = try await probe.fetch()
|
|
|
|
return try Self.makeSnapshot(from: snap)
|
|
}
|
|
|
|
private func loadViaDirectCLI(timeout: TimeInterval) async throws -> ClaudeUsageSnapshot {
|
|
guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else {
|
|
throw ClaudeUsageError.claudeNotInstalled
|
|
}
|
|
|
|
let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL()
|
|
var environment = ClaudeCLISession.launchEnvironment(baseEnv: self.environment)
|
|
environment["PWD"] = workingDirectory.path
|
|
defer {
|
|
ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts(
|
|
probeDirectory: workingDirectory,
|
|
environment: environment)
|
|
}
|
|
|
|
let result = try await SubprocessRunner.run(
|
|
binary: claudeBinary,
|
|
arguments: ["/usage"],
|
|
environment: environment,
|
|
timeout: timeout,
|
|
standardInput: FileHandle.nullDevice,
|
|
currentDirectoryURL: workingDirectory,
|
|
label: "claude-direct-usage")
|
|
let snap = try ClaudeStatusProbe.parse(text: result.stdout)
|
|
return try Self.makeSnapshot(from: snap)
|
|
}
|
|
|
|
private static func makeSnapshot(
|
|
from snap: ClaudeStatusSnapshot,
|
|
now: Date = .init()) throws -> ClaudeUsageSnapshot
|
|
{
|
|
guard let sessionPctLeft = snap.sessionPercentLeft else {
|
|
throw ClaudeUsageError.parseFailed("missing session data")
|
|
}
|
|
|
|
func makeWindow(pctLeft: Int?, reset: String?, windowMinutes: Int) -> RateWindow? {
|
|
guard let left = pctLeft else { return nil }
|
|
let used = max(0, min(100, 100 - Double(left)))
|
|
let resetClean = reset?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return RateWindow(
|
|
usedPercent: used,
|
|
windowMinutes: windowMinutes,
|
|
resetsAt: ClaudeStatusProbe.parseResetDate(
|
|
from: resetClean,
|
|
now: now,
|
|
expectedWindow: TimeInterval(windowMinutes * 60)),
|
|
resetDescription: resetClean)
|
|
}
|
|
|
|
let primary = makeWindow(
|
|
pctLeft: sessionPctLeft,
|
|
reset: snap.primaryResetDescription,
|
|
windowMinutes: Self.sessionWindowMinutes)!
|
|
let weekly = makeWindow(
|
|
pctLeft: snap.weeklyPercentLeft,
|
|
reset: snap.secondaryResetDescription,
|
|
windowMinutes: Self.weeklyWindowMinutes)
|
|
let opus = makeWindow(
|
|
pctLeft: snap.opusPercentLeft,
|
|
reset: snap.opusResetDescription,
|
|
windowMinutes: Self.weeklyWindowMinutes)
|
|
|
|
return ClaudeUsageSnapshot(
|
|
primary: primary,
|
|
secondary: weekly,
|
|
opus: opus,
|
|
extraRateWindows: snap.extraRateWindows,
|
|
providerCost: nil,
|
|
updatedAt: now,
|
|
accountEmail: snap.accountEmail,
|
|
accountOrganization: snap.accountOrganization,
|
|
loginMethod: snap.loginMethod,
|
|
rawText: snap.rawText)
|
|
}
|
|
|
|
private func applyWebExtrasIfNeeded(to snapshot: ClaudeUsageSnapshot) async -> ClaudeUsageSnapshot {
|
|
guard self.useWebExtras, self.dataSource != .web else { return snapshot }
|
|
do {
|
|
let webData: ClaudeWebAPIFetcher.WebUsageData =
|
|
if let header = self.manualCookieHeader {
|
|
try await ClaudeWebAPIFetcher.fetchUsage(
|
|
cookieHeader: header,
|
|
targetOrganizationID: self.webOrganizationID)
|
|
{ msg in
|
|
Self.log.debug(msg)
|
|
}
|
|
} else {
|
|
try await ClaudeWebAPIFetcher.fetchUsage(
|
|
browserDetection: self.browserDetection,
|
|
targetOrganizationID: self.webOrganizationID)
|
|
{ msg in
|
|
Self.log.debug(msg)
|
|
}
|
|
}
|
|
// Only merge usage/cost extras; keep identity fields from the primary data source.
|
|
let mergedExtraRateWindows = Self.mergeExtraRateWindows(
|
|
primary: snapshot.extraRateWindows,
|
|
web: webData.extraRateWindows)
|
|
let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost
|
|
if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows {
|
|
return ClaudeUsageSnapshot(
|
|
primary: snapshot.primary,
|
|
primaryWindowKind: snapshot.primaryWindowKind,
|
|
secondary: snapshot.secondary,
|
|
opus: snapshot.opus,
|
|
extraRateWindows: mergedExtraRateWindows,
|
|
providerCost: mergedProviderCost,
|
|
updatedAt: snapshot.updatedAt,
|
|
accountEmail: snapshot.accountEmail,
|
|
accountOrganization: snapshot.accountOrganization,
|
|
loginMethod: snapshot.loginMethod,
|
|
rawText: snapshot.rawText,
|
|
oauthKeychainPersistentRefHash: snapshot.oauthKeychainPersistentRefHash,
|
|
oauthHistoryOwnerIdentifier: snapshot.oauthHistoryOwnerIdentifier,
|
|
oauthKeychainCredentialMismatch: snapshot.oauthKeychainCredentialMismatch,
|
|
oauthKeychainCredentialAbsent: snapshot.oauthKeychainCredentialAbsent,
|
|
oauthKeychainCredentialUnavailable: snapshot.oauthKeychainCredentialUnavailable)
|
|
}
|
|
} catch {
|
|
Self.log.debug("Claude web extras fetch failed: \(error.localizedDescription)")
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
private static func mergeExtraRateWindows(
|
|
primary: [NamedRateWindow],
|
|
web: [NamedRateWindow]) -> [NamedRateWindow]
|
|
{
|
|
guard !primary.isEmpty else { return web }
|
|
guard !web.isEmpty else { return primary }
|
|
|
|
let primaryScopedKeys = Set(primary.compactMap(self.scopedExtraRateWindowMergeKey))
|
|
var webScopedIDsByKey: [String: Set<String>] = [:]
|
|
for window in web {
|
|
guard let scopedKey = self.scopedExtraRateWindowMergeKey(window) else { continue }
|
|
webScopedIDsByKey[scopedKey, default: []].insert(window.id)
|
|
}
|
|
var seenIDs = Set(primary.map(\.id))
|
|
var merged = primary
|
|
for window in web where seenIDs.insert(window.id).inserted {
|
|
if let scopedKey = self.scopedExtraRateWindowMergeKey(window),
|
|
primaryScopedKeys.contains(scopedKey),
|
|
webScopedIDsByKey[scopedKey]?.count == 1
|
|
{
|
|
continue
|
|
}
|
|
merged.append(window)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
private static func scopedExtraRateWindowMergeKey(_ window: NamedRateWindow) -> String? {
|
|
guard window.id.hasPrefix("claude-weekly-scoped-") else { return nil }
|
|
|
|
let modelName = window.title.replacingOccurrences(
|
|
of: #"\s+only\s*$"#,
|
|
with: "",
|
|
options: [.regularExpression, .caseInsensitive])
|
|
let normalizedName = String(modelName.lowercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains))
|
|
guard !normalizedName.isEmpty else { return nil }
|
|
return normalizedName
|
|
}
|
|
|
|
// MARK: - Process helpers
|
|
|
|
private static func which(_ tool: String) -> String? {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
|
|
process.arguments = [tool]
|
|
let pipe = Pipe()
|
|
process.standardOutput = pipe
|
|
try? process.run()
|
|
process.waitUntilExit()
|
|
guard process.terminationStatus == 0 else { return nil }
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
guard
|
|
let path = String(data: data, encoding: .utf8)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!path.isEmpty
|
|
else { return nil }
|
|
return path
|
|
}
|
|
|
|
private static func readString(cmd: String, args: [String]) -> String? {
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: cmd)
|
|
task.arguments = args
|
|
let pipe = Pipe()
|
|
task.standardOutput = pipe
|
|
try? task.run()
|
|
task.waitUntilExit()
|
|
guard task.terminationStatus == 0 else { return nil }
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private static func oauthCredentialProbeErrorLabel(_ error: Error) -> String {
|
|
guard let oauthError = error as? ClaudeOAuthCredentialsError else {
|
|
return String(describing: type(of: error))
|
|
}
|
|
|
|
return switch oauthError {
|
|
case .decodeFailed:
|
|
"decodeFailed"
|
|
case .missingOAuth:
|
|
"missingOAuth"
|
|
case .mcpOAuthOnlyKeychain:
|
|
"mcpOAuthOnlyKeychain"
|
|
case .missingAccessToken:
|
|
"missingAccessToken"
|
|
case .notFound:
|
|
"notFound"
|
|
case let .keychainError(status):
|
|
"keychainError:\(status)"
|
|
case .readFailed:
|
|
"readFailed"
|
|
case .refreshFailed:
|
|
"refreshFailed"
|
|
case .noRefreshToken:
|
|
"noRefreshToken"
|
|
case .refreshDelegatedToClaudeCLI:
|
|
"refreshDelegatedToClaudeCLI"
|
|
}
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
extension ClaudeUsageFetcher {
|
|
public static func _mergeExtraRateWindowsForTesting(
|
|
primary: [NamedRateWindow],
|
|
web: [NamedRateWindow]) -> [NamedRateWindow]
|
|
{
|
|
self.mergeExtraRateWindows(primary: primary, web: web)
|
|
}
|
|
|
|
public static func _mapOAuthUsageForTesting(
|
|
_ data: Data,
|
|
rateLimitTier: String? = nil,
|
|
subscriptionType: String? = nil) throws -> ClaudeUsageSnapshot
|
|
{
|
|
let usage = try ClaudeOAuthUsageFetcher.decodeUsageResponse(data)
|
|
let creds = ClaudeOAuthCredentials(
|
|
accessToken: "test",
|
|
refreshToken: nil,
|
|
expiresAt: Date().addingTimeInterval(3600),
|
|
scopes: [],
|
|
rateLimitTier: rateLimitTier,
|
|
subscriptionType: subscriptionType)
|
|
return try Self.mapOAuthUsage(usage, credentials: creds)
|
|
}
|
|
}
|
|
#endif
|