Skip to content
Merged
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
18 changes: 15 additions & 3 deletions Sources/CodexBar/PreferencesDebugPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,11 @@ struct DebugPane: View {
private func loadLog(_ provider: UsageProvider) {
self.isLoadingLog = true
Task {
let text = await self.store.debugLog(for: provider)
let text = await ProviderInteractionContext.$current.withValue(.userInitiated) {
await ProviderRefreshContext.$current.withValue(.regular) {
await self.store.debugLog(for: provider)
}
}
await MainActor.run {
self.logText = text
self.isLoadingLog = false
Expand All @@ -442,11 +446,19 @@ struct DebugPane: View {
Task {
if self.logText.isEmpty {
self.isLoadingLog = true
let text = await self.store.debugLog(for: provider)
let text = await ProviderInteractionContext.$current.withValue(.userInitiated) {
await ProviderRefreshContext.$current.withValue(.regular) {
await self.store.debugLog(for: provider)
}
}
await MainActor.run { self.logText = text }
self.isLoadingLog = false
}
_ = await self.store.dumpLog(toFileFor: provider)
_ = await ProviderInteractionContext.$current.withValue(.userInitiated) {
await ProviderRefreshContext.$current.withValue(.regular) {
await self.store.dumpLog(toFileFor: provider)
}
}
}
}

Expand Down
64 changes: 37 additions & 27 deletions Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,15 @@ extension SettingsStore {
extension SettingsStore {
func claudeSettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
.ClaudeProviderSettings {
ProviderSettingsSnapshot.ClaudeProviderSettings(
let account = self.selectedClaudeTokenAccount(tokenOverride: tokenOverride)
let routing = self.claudeCredentialRouting(account: account)
return ProviderSettingsSnapshot.ClaudeProviderSettings(
usageDataSource: self.claudeUsageDataSource,
webExtrasEnabled: self.claudeWebExtrasEnabled,
cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride),
manualCookieHeader: self.claudeSnapshotCookieHeader(tokenOverride: tokenOverride))
cookieSource: self.claudeSnapshotCookieSource(tokenOverride: tokenOverride, routing: routing),
manualCookieHeader: self.claudeSnapshotCookieHeader(
routing: routing,
hasSelectedAccount: account != nil))
}

private static func claudeUsageDataSource(from source: ProviderSourceMode?) -> ClaudeUsageDataSource {
Expand All @@ -71,42 +75,48 @@ extension SettingsStore {
}
}

private func claudeSnapshotCookieHeader(tokenOverride: TokenAccountOverride?) -> String {
let fallback = self.claudeCookieHeader
guard let support = TokenAccountSupportCatalog.support(for: .claude),
case .cookieHeader = support.injection
else {
return fallback
}
guard let account = ProviderTokenAccountSelection.selectedAccount(
provider: .claude,
settings: self,
override: tokenOverride)
else {
return fallback
}
if TokenAccountSupportCatalog.isClaudeOAuthToken(account.token) {
return ""
private func claudeSnapshotCookieHeader(
routing: ClaudeCredentialRouting,
hasSelectedAccount: Bool) -> String
{
switch routing {
case .none:
hasSelectedAccount ? "" : self.claudeCookieHeader
case .oauth:
""
case let .webCookie(header):
header
}
return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support)
}

private func claudeSnapshotCookieSource(tokenOverride: TokenAccountOverride?) -> ProviderCookieSource {
private func claudeSnapshotCookieSource(
tokenOverride: TokenAccountOverride?,
routing: ClaudeCredentialRouting) -> ProviderCookieSource
{
let fallback = self.claudeCookieSource
guard let support = TokenAccountSupportCatalog.support(for: .claude),
support.requiresManualCookieSource
else {
return fallback
}
if let account = ProviderTokenAccountSelection.selectedAccount(
provider: .claude,
settings: self,
override: tokenOverride),
TokenAccountSupportCatalog.isClaudeOAuthToken(account.token)
{
if routing.isOAuth {
return .off
}
if self.tokenAccounts(for: .claude).isEmpty { return fallback }
return .manual
}

private func claudeCredentialRouting(account: ProviderTokenAccount?) -> ClaudeCredentialRouting {
let manualCookieHeader = account == nil ? self.claudeCookieHeader : nil
return ClaudeCredentialRouting.resolve(
tokenAccountToken: account?.token,
manualCookieHeader: manualCookieHeader)
}

private func selectedClaudeTokenAccount(tokenOverride: TokenAccountOverride?) -> ProviderTokenAccount? {
ProviderTokenAccountSelection.selectedAccount(
provider: .claude,
settings: self,
override: tokenOverride)
}
}
168 changes: 168 additions & 0 deletions Sources/CodexBar/UsageStore+ClaudeDebug.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import CodexBarCore
import Foundation
import SweetCookieKit

@MainActor
extension UsageStore {
func debugClaudeDump() async -> String {
await ClaudeStatusProbe.latestDumps()
}
}

extension UsageStore {
struct ClaudeDebugLogConfiguration: Sendable {
let runtime: CodexBarCore.ProviderRuntime
let sourceMode: ProviderSourceMode
let environment: [String: String]
let webExtrasEnabled: Bool
let usageDataSource: ClaudeUsageDataSource
let cookieSource: ProviderCookieSource
let cookieHeader: String
let keepCLISessionsAlive: Bool
}

static func debugClaudeLog(
browserDetection: BrowserDetection,
configuration: ClaudeDebugLogConfiguration) async -> String
{
struct OAuthDebugProbe: Sendable {
let hasCredentials: Bool
let ownerRawValue: String
let sourceRawValue: String
let isExpired: Bool
}

return await runWithTimeout(seconds: 15) {
var lines: [String] = []
let manualHeader = configuration.cookieSource == .manual
? CookieHeaderNormalizer.normalize(configuration.cookieHeader)
: nil
let hasKey = if configuration.cookieSource == .off {
false
} else if let manualHeader {
ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: manualHeader)
} else {
ClaudeWebAPIFetcher.hasSessionKey(browserDetection: browserDetection) { msg in lines.append(msg) }
}
let oauthProbe = await withTaskGroup(of: OAuthDebugProbe.self) { group in
// Preserve task-local test overrides while keeping the keychain read off the calling task.
group.addTask(priority: .utility) {
let oauthRecord = try? ClaudeOAuthCredentialsStore.loadRecord(
environment: configuration.environment,
allowKeychainPrompt: false,
respectKeychainPromptCooldown: true,
allowClaudeKeychainRepairWithoutPrompt: false)
return OAuthDebugProbe(
hasCredentials: oauthRecord?.credentials.scopes.contains("user:profile") == true,
ownerRawValue: oauthRecord?.owner.rawValue ?? "none",
sourceRawValue: oauthRecord?.source.rawValue ?? "none",
isExpired: oauthRecord?.credentials.isExpired ?? false)
}
return await group.next() ?? OAuthDebugProbe(
hasCredentials: false,
ownerRawValue: "none",
sourceRawValue: "none",
isExpired: false)
}
let hasOAuthCredentials = ClaudeOAuthPlanningAvailability.isAvailable(
runtime: configuration.runtime,
sourceMode: configuration.sourceMode,
environment: configuration.environment)
let hasClaudeBinary = ClaudeCLIResolver.isAvailable(environment: configuration.environment)
let delegatedCooldownSeconds = ClaudeOAuthDelegatedRefreshCoordinator.cooldownRemainingSeconds()
let planningInput = ClaudeSourcePlanningInput(
runtime: configuration.runtime,
selectedDataSource: configuration.usageDataSource,
webExtrasEnabled: configuration.webExtrasEnabled,
hasWebSession: hasKey,
hasCLI: hasClaudeBinary,
hasOAuthCredentials: hasOAuthCredentials)
let plan = ClaudeSourcePlanner.resolve(input: planningInput)
let strategy = plan.compatibilityStrategy

lines.append(contentsOf: plan.debugLines())
lines.append("hasSessionKey=\(hasKey)")
lines.append("hasOAuthCredentials=\(hasOAuthCredentials)")
lines.append("oauthCredentialOwner=\(oauthProbe.ownerRawValue)")
lines.append("oauthCredentialSource=\(oauthProbe.sourceRawValue)")
lines.append("oauthCredentialExpired=\(oauthProbe.isExpired)")
lines.append("delegatedRefreshCLIAvailable=\(hasClaudeBinary)")
lines.append("delegatedRefreshCooldownActive=\(delegatedCooldownSeconds != nil)")
if let delegatedCooldownSeconds {
lines.append("delegatedRefreshCooldownSeconds=\(delegatedCooldownSeconds)")
}
lines.append("hasClaudeBinary=\(hasClaudeBinary)")
if strategy?.useWebExtras == true {
lines.append("web_extras=enabled")
}
lines.append("")

guard let strategy else {
lines.append("No planner-selected Claude source.")
return lines.joined(separator: "\n")
}

switch strategy.dataSource {
case .auto:
lines.append("Auto source selected.")
return lines.joined(separator: "\n")
case .web:
do {
let web: ClaudeWebAPIFetcher.WebUsageData =
if let manualHeader {
try await ClaudeWebAPIFetcher.fetchUsage(cookieHeader: manualHeader) { msg in
lines.append(msg)
}
} else {
try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: browserDetection) { msg in
lines.append(msg)
}
}
lines.append("")
lines.append("Web API summary:")

let sessionReset = web.sessionResetsAt?.description ?? "nil"
lines.append("session_used=\(web.sessionPercentUsed)% resetsAt=\(sessionReset)")

if let weekly = web.weeklyPercentUsed {
let weeklyReset = web.weeklyResetsAt?.description ?? "nil"
lines.append("weekly_used=\(weekly)% resetsAt=\(weeklyReset)")
} else {
lines.append("weekly_used=nil")
}

lines.append("opus_used=\(web.opusPercentUsed?.description ?? "nil")")

if let extra = web.extraUsageCost {
let resetsAt = extra.resetsAt?.description ?? "nil"
let period = extra.period ?? "nil"
let line =
"extra_usage used=\(extra.used) limit=\(extra.limit) " +
"currency=\(extra.currencyCode) period=\(period) resetsAt=\(resetsAt)"
lines.append(line)
} else {
lines.append("extra_usage=nil")
}

return lines.joined(separator: "\n")
} catch {
lines.append("Web API failed: \(error.localizedDescription)")
return lines.joined(separator: "\n")
}
case .cli:
let fetcher = ClaudeUsageFetcher(
browserDetection: browserDetection,
environment: configuration.environment,
runtime: configuration.runtime,
dataSource: configuration.usageDataSource,
keepCLISessionsAlive: configuration.keepCLISessionsAlive)
let cli = await fetcher.debugRawProbe(model: "sonnet")
lines.append(cli)
return lines.joined(separator: "\n")
case .oauth:
lines.append("OAuth source selected.")
return lines.joined(separator: "\n")
}
}
}
}
19 changes: 19 additions & 0 deletions Sources/CodexBar/UsageStore+Timeout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

extension UsageStore {
nonisolated static func runWithTimeout(
seconds: Double,
operation: @escaping @Sendable () async -> String) async -> String
{
await withTaskGroup(of: String?.self) { group -> String in
group.addTask { await operation() }
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
return nil
}
let result = await group.next()?.flatMap(\.self)
group.cancelAll()
return result ?? "Probe timed out after \(Int(seconds))s"
}
}
}
Loading
Loading