Files
steipete--codexbar/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift
T
2026-07-13 12:22:33 +08:00

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)
}
}