292 lines
11 KiB
Swift
292 lines
11 KiB
Swift
import Foundation
|
|
|
|
public enum ProviderRuntime: Sendable {
|
|
case app
|
|
case cli
|
|
}
|
|
|
|
public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable {
|
|
case auto
|
|
case web
|
|
case cli
|
|
case oauth
|
|
case api
|
|
|
|
public var usesWeb: Bool {
|
|
self == .auto || self == .web
|
|
}
|
|
}
|
|
|
|
public struct ProviderFetchContext: Sendable {
|
|
public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void
|
|
public typealias ProviderManualTokenUpdater = @Sendable (UsageProvider, String) async -> Void
|
|
|
|
public let runtime: ProviderRuntime
|
|
public let sourceMode: ProviderSourceMode
|
|
public let includeCredits: Bool
|
|
public let includeOptionalUsage: Bool
|
|
public let webTimeout: TimeInterval
|
|
public let webDebugDumpHTML: Bool
|
|
public let verbose: Bool
|
|
public let env: [String: String]
|
|
public let settings: ProviderSettingsSnapshot?
|
|
public let fetcher: UsageFetcher
|
|
public let claudeFetcher: any ClaudeUsageFetching
|
|
public let browserDetection: BrowserDetection
|
|
public let selectedTokenAccountID: UUID?
|
|
public let tokenAccountTokenUpdater: TokenAccountTokenUpdater?
|
|
public let providerManualTokenUpdater: ProviderManualTokenUpdater?
|
|
public let costUsageHistoryDays: Int
|
|
/// Whether warm CLI helper sessions (such as the managed Antigravity `agy`
|
|
/// process) may outlive a single fetch. True for long-lived hosts (the app,
|
|
/// `codexbar serve`); false for one-shot CLI invocations that should reset
|
|
/// the session after each fetch.
|
|
public let persistsCLISessions: Bool
|
|
/// Minimum idle lifetime for persistent CLI helper sessions. Long-lived
|
|
/// hosts set this beyond their refresh cadence so a slow cold start can
|
|
/// recover on the next refresh.
|
|
public let persistentCLISessionIdleWindow: TimeInterval?
|
|
|
|
public init(
|
|
runtime: ProviderRuntime,
|
|
sourceMode: ProviderSourceMode,
|
|
includeCredits: Bool,
|
|
includeOptionalUsage: Bool = true,
|
|
webTimeout: TimeInterval,
|
|
webDebugDumpHTML: Bool,
|
|
verbose: Bool,
|
|
env: [String: String],
|
|
settings: ProviderSettingsSnapshot?,
|
|
fetcher: UsageFetcher,
|
|
claudeFetcher: any ClaudeUsageFetching,
|
|
browserDetection: BrowserDetection,
|
|
selectedTokenAccountID: UUID? = nil,
|
|
tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil,
|
|
providerManualTokenUpdater: ProviderManualTokenUpdater? = nil,
|
|
costUsageHistoryDays: Int = 30,
|
|
persistsCLISessions: Bool = false,
|
|
persistentCLISessionIdleWindow: TimeInterval? = nil)
|
|
{
|
|
self.runtime = runtime
|
|
self.sourceMode = sourceMode
|
|
self.includeCredits = includeCredits
|
|
self.includeOptionalUsage = includeOptionalUsage
|
|
self.webTimeout = webTimeout
|
|
self.webDebugDumpHTML = webDebugDumpHTML
|
|
self.verbose = verbose
|
|
self.env = env
|
|
self.settings = settings
|
|
self.fetcher = fetcher
|
|
self.claudeFetcher = claudeFetcher
|
|
self.browserDetection = browserDetection
|
|
self.selectedTokenAccountID = selectedTokenAccountID
|
|
self.tokenAccountTokenUpdater = tokenAccountTokenUpdater
|
|
self.providerManualTokenUpdater = providerManualTokenUpdater
|
|
self.costUsageHistoryDays = max(1, min(365, costUsageHistoryDays))
|
|
self.persistsCLISessions = persistsCLISessions
|
|
self.persistentCLISessionIdleWindow = persistentCLISessionIdleWindow
|
|
}
|
|
}
|
|
|
|
public enum ProviderCLISessionLifecycle {
|
|
public static func shutdownPersistentSessions() async {
|
|
await AntigravityCLISession.shared.reset()
|
|
}
|
|
}
|
|
|
|
public struct ProviderFetchResult: Sendable {
|
|
public let usage: UsageSnapshot
|
|
public let credits: CreditsSnapshot?
|
|
public let dashboard: OpenAIDashboardSnapshot?
|
|
public let sourceLabel: String
|
|
public let strategyID: String
|
|
public let strategyKind: ProviderFetchKind
|
|
/// Transient account ownership evidence for plan-utilization history.
|
|
/// The raw Keychain reference never enters the persisted usage snapshot.
|
|
public let claudeOAuthKeychainPersistentRefHash: String?
|
|
/// A one-way discriminator derived from the winning Claude OAuth credential.
|
|
/// Raw access and refresh tokens never enter the fetch result or persisted history.
|
|
public let claudeOAuthHistoryOwnerIdentifier: String?
|
|
/// Whether a prompt-free comparison proved the winning credential differs from Claude Code's Keychain entry.
|
|
public let claudeOAuthKeychainCredentialMismatch: Bool
|
|
/// Whether a prompt-free probe proved Claude Code has no Keychain credential.
|
|
public let claudeOAuthKeychainCredentialAbsent: Bool
|
|
/// Whether the winning Claude CLI credential could not be compared with Keychain without prompting.
|
|
public let claudeOAuthKeychainCredentialUnavailable: Bool
|
|
|
|
public init(
|
|
usage: UsageSnapshot,
|
|
credits: CreditsSnapshot?,
|
|
dashboard: OpenAIDashboardSnapshot?,
|
|
sourceLabel: String,
|
|
strategyID: String,
|
|
strategyKind: ProviderFetchKind,
|
|
claudeOAuthKeychainPersistentRefHash: String? = nil,
|
|
claudeOAuthHistoryOwnerIdentifier: String? = nil,
|
|
claudeOAuthKeychainCredentialMismatch: Bool = false,
|
|
claudeOAuthKeychainCredentialAbsent: Bool = false,
|
|
claudeOAuthKeychainCredentialUnavailable: Bool = false)
|
|
{
|
|
self.usage = usage
|
|
self.credits = credits
|
|
self.dashboard = dashboard
|
|
self.sourceLabel = sourceLabel
|
|
self.strategyID = strategyID
|
|
self.strategyKind = strategyKind
|
|
self.claudeOAuthKeychainPersistentRefHash = claudeOAuthKeychainPersistentRefHash
|
|
self.claudeOAuthHistoryOwnerIdentifier = claudeOAuthHistoryOwnerIdentifier
|
|
self.claudeOAuthKeychainCredentialMismatch = claudeOAuthKeychainCredentialMismatch
|
|
self.claudeOAuthKeychainCredentialAbsent = claudeOAuthKeychainCredentialAbsent
|
|
self.claudeOAuthKeychainCredentialUnavailable = claudeOAuthKeychainCredentialUnavailable
|
|
}
|
|
}
|
|
|
|
public struct ProviderFetchAttempt: Sendable {
|
|
public let strategyID: String
|
|
public let kind: ProviderFetchKind
|
|
public let wasAvailable: Bool
|
|
public let errorDescription: String?
|
|
|
|
public init(strategyID: String, kind: ProviderFetchKind, wasAvailable: Bool, errorDescription: String?) {
|
|
self.strategyID = strategyID
|
|
self.kind = kind
|
|
self.wasAvailable = wasAvailable
|
|
self.errorDescription = errorDescription
|
|
}
|
|
}
|
|
|
|
public struct ProviderFetchOutcome: @unchecked Sendable {
|
|
public let result: Result<ProviderFetchResult, Error>
|
|
public let attempts: [ProviderFetchAttempt]
|
|
|
|
public init(result: Result<ProviderFetchResult, Error>, attempts: [ProviderFetchAttempt]) {
|
|
self.result = result
|
|
self.attempts = attempts
|
|
}
|
|
}
|
|
|
|
public enum ProviderFetchError: LocalizedError, Sendable {
|
|
case noAvailableStrategy(UsageProvider)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case let .noAvailableStrategy(provider):
|
|
"No available fetch strategy for \(provider.rawValue)."
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum ProviderFetchKind: Sendable {
|
|
case cli
|
|
case web
|
|
case oauth
|
|
case apiToken
|
|
case localProbe
|
|
case webDashboard
|
|
}
|
|
|
|
public protocol ProviderFetchStrategy: Sendable {
|
|
var id: String { get }
|
|
var kind: ProviderFetchKind { get }
|
|
func isAvailable(_ context: ProviderFetchContext) async -> Bool
|
|
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult
|
|
func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool
|
|
}
|
|
|
|
extension ProviderFetchStrategy {
|
|
public func makeResult(
|
|
usage: UsageSnapshot,
|
|
credits: CreditsSnapshot? = nil,
|
|
dashboard: OpenAIDashboardSnapshot? = nil,
|
|
sourceLabel: String) -> ProviderFetchResult
|
|
{
|
|
ProviderFetchResult(
|
|
usage: usage,
|
|
credits: credits,
|
|
dashboard: dashboard,
|
|
sourceLabel: sourceLabel,
|
|
strategyID: self.id,
|
|
strategyKind: self.kind)
|
|
}
|
|
}
|
|
|
|
public struct ProviderFetchPipeline: Sendable {
|
|
public let resolveStrategies: @Sendable (ProviderFetchContext) async -> [any ProviderFetchStrategy]
|
|
|
|
public init(resolveStrategies: @escaping @Sendable (ProviderFetchContext) async -> [any ProviderFetchStrategy]) {
|
|
self.resolveStrategies = resolveStrategies
|
|
}
|
|
|
|
public func fetch(context: ProviderFetchContext, provider: UsageProvider) async -> ProviderFetchOutcome {
|
|
let strategies = await self.resolveStrategies(context)
|
|
var attempts: [ProviderFetchAttempt] = []
|
|
attempts.reserveCapacity(strategies.count)
|
|
var lastAvailableError: Error?
|
|
|
|
guard !Task.isCancelled else {
|
|
return ProviderFetchOutcome(result: .failure(CancellationError()), attempts: attempts)
|
|
}
|
|
|
|
for strategy in strategies {
|
|
guard !Task.isCancelled else {
|
|
return ProviderFetchOutcome(result: .failure(CancellationError()), attempts: attempts)
|
|
}
|
|
let available = await strategy.isAvailable(context)
|
|
|
|
guard !Task.isCancelled else {
|
|
return ProviderFetchOutcome(result: .failure(CancellationError()), attempts: attempts)
|
|
}
|
|
guard available else {
|
|
attempts.append(ProviderFetchAttempt(
|
|
strategyID: strategy.id,
|
|
kind: strategy.kind,
|
|
wasAvailable: false,
|
|
errorDescription: nil))
|
|
continue
|
|
}
|
|
|
|
do {
|
|
let result = try await strategy.fetch(context)
|
|
try Task.checkCancellation()
|
|
attempts.append(ProviderFetchAttempt(
|
|
strategyID: strategy.id,
|
|
kind: strategy.kind,
|
|
wasAvailable: true,
|
|
errorDescription: nil))
|
|
return ProviderFetchOutcome(result: .success(result), attempts: attempts)
|
|
} catch {
|
|
lastAvailableError = error
|
|
attempts.append(ProviderFetchAttempt(
|
|
strategyID: strategy.id,
|
|
kind: strategy.kind,
|
|
wasAvailable: true,
|
|
errorDescription: error.localizedDescription))
|
|
if Task.isCancelled || error is CancellationError {
|
|
return ProviderFetchOutcome(result: .failure(CancellationError()), attempts: attempts)
|
|
}
|
|
if strategy.shouldFallback(on: error, context: context) {
|
|
continue
|
|
}
|
|
return ProviderFetchOutcome(result: .failure(error), attempts: attempts)
|
|
}
|
|
}
|
|
|
|
let error = lastAvailableError ?? ProviderFetchError.noAvailableStrategy(provider)
|
|
return ProviderFetchOutcome(result: .failure(error), attempts: attempts)
|
|
}
|
|
}
|
|
|
|
public struct ProviderFetchPlan: Sendable {
|
|
public let sourceModes: Set<ProviderSourceMode>
|
|
public let pipeline: ProviderFetchPipeline
|
|
|
|
public init(sourceModes: Set<ProviderSourceMode>, pipeline: ProviderFetchPipeline) {
|
|
self.sourceModes = sourceModes
|
|
self.pipeline = pipeline
|
|
}
|
|
|
|
public func fetchOutcome(context: ProviderFetchContext, provider: UsageProvider) async -> ProviderFetchOutcome {
|
|
await self.pipeline.fetch(context: context, provider: provider)
|
|
}
|
|
}
|