366 lines
14 KiB
Swift
366 lines
14 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
/// Fetches live credit balance and subscription details from the Codebuff API.
|
|
/// Uses Bearer token auth against the public www.codebuff.com endpoints used by
|
|
/// the dashboard + the `codebuff` CLI.
|
|
public enum CodebuffUsageFetcher {
|
|
private static let requestTimeoutSeconds: TimeInterval = 15
|
|
/// Extra grace period to wait for the optional subscription endpoint after the
|
|
/// primary usage call returns. Keeps the menu responsive when `/api/user/subscription`
|
|
/// is slow or hangs while `/api/v1/usage` succeeds quickly.
|
|
private static let subscriptionGraceSeconds: TimeInterval = 2
|
|
|
|
public static func fetchUsage(
|
|
apiKey: String,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
includeSubscription: Bool = true,
|
|
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> CodebuffUsageSnapshot
|
|
{
|
|
try await self.fetchUsage(
|
|
apiKey: apiKey,
|
|
environment: environment,
|
|
includeSubscription: includeSubscription,
|
|
transport: transport,
|
|
subscriptionGrace: .seconds(self.subscriptionGraceSeconds))
|
|
}
|
|
|
|
static func _fetchUsageForTesting(
|
|
apiKey: String,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
includeSubscription: Bool = true,
|
|
transport: any ProviderHTTPTransport,
|
|
subscriptionGrace: Duration) async throws -> CodebuffUsageSnapshot
|
|
{
|
|
try await self.fetchUsage(
|
|
apiKey: apiKey,
|
|
environment: environment,
|
|
includeSubscription: includeSubscription,
|
|
transport: transport,
|
|
subscriptionGrace: subscriptionGrace)
|
|
}
|
|
|
|
private static func fetchUsage(
|
|
apiKey: String,
|
|
environment: [String: String],
|
|
includeSubscription: Bool,
|
|
transport: any ProviderHTTPTransport,
|
|
subscriptionGrace: Duration) async throws -> CodebuffUsageSnapshot
|
|
{
|
|
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
throw CodebuffUsageError.missingCredentials
|
|
}
|
|
try CodebuffSettingsReader.validateEndpointOverrides(environment: environment)
|
|
|
|
let baseURL = CodebuffSettingsReader.apiURL(environment: environment)
|
|
|
|
let (usageValues, subscriptionValues) = try await self.fetchPayloads(
|
|
apiKey: trimmed,
|
|
baseURL: baseURL,
|
|
includeSubscription: includeSubscription,
|
|
transport: transport,
|
|
subscriptionGrace: subscriptionGrace)
|
|
|
|
return CodebuffUsageSnapshot(
|
|
creditsUsed: usageValues.used,
|
|
creditsTotal: usageValues.total,
|
|
creditsRemaining: usageValues.remaining,
|
|
weeklyUsed: subscriptionValues?.weeklyUsed,
|
|
weeklyLimit: subscriptionValues?.weeklyLimit,
|
|
weeklyResetsAt: subscriptionValues?.weeklyResetsAt,
|
|
billingPeriodEnd: subscriptionValues?.billingPeriodEnd,
|
|
nextQuotaReset: usageValues.nextQuotaReset,
|
|
tier: subscriptionValues?.tier,
|
|
subscriptionStatus: subscriptionValues?.status,
|
|
autoTopUpEnabled: usageValues.autoTopupEnabled,
|
|
accountEmail: subscriptionValues?.email,
|
|
updatedAt: Date())
|
|
}
|
|
|
|
private static func fetchPayloads(
|
|
apiKey: String,
|
|
baseURL: URL,
|
|
includeSubscription: Bool,
|
|
transport: any ProviderHTTPTransport,
|
|
subscriptionGrace: Duration) async throws -> (UsagePayload, SubscriptionPayload?)
|
|
{
|
|
guard includeSubscription else {
|
|
return try await (
|
|
self.fetchUsagePayload(apiKey: apiKey, baseURL: baseURL, transport: transport),
|
|
nil)
|
|
}
|
|
|
|
let subscriptionTask = Task<SubscriptionPayload?, Error> {
|
|
try await self.fetchSubscriptionPayload(
|
|
apiKey: apiKey,
|
|
baseURL: baseURL,
|
|
transport: transport)
|
|
}
|
|
let usageValues: UsagePayload
|
|
do {
|
|
usageValues = try await withTaskCancellationHandler {
|
|
try await self.fetchUsagePayload(
|
|
apiKey: apiKey,
|
|
baseURL: baseURL,
|
|
transport: transport)
|
|
} onCancel: {
|
|
subscriptionTask.cancel()
|
|
}
|
|
} catch {
|
|
subscriptionTask.cancel()
|
|
throw error
|
|
}
|
|
|
|
do {
|
|
try Task.checkCancellation()
|
|
} catch {
|
|
subscriptionTask.cancel()
|
|
throw error
|
|
}
|
|
let race = BoundedTaskJoin(sourceTask: subscriptionTask)
|
|
switch await race.value(joinGrace: subscriptionGrace) {
|
|
case let .value(subscriptionValues):
|
|
try Task.checkCancellation()
|
|
return (usageValues, subscriptionValues)
|
|
case .timedOut:
|
|
try Task.checkCancellation()
|
|
return (usageValues, nil)
|
|
case .failure:
|
|
subscriptionTask.cancel()
|
|
try Task.checkCancellation()
|
|
return (usageValues, nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Endpoint helpers
|
|
|
|
struct UsagePayload {
|
|
let used: Double?
|
|
let total: Double?
|
|
let remaining: Double?
|
|
let nextQuotaReset: Date?
|
|
let autoTopupEnabled: Bool?
|
|
}
|
|
|
|
struct SubscriptionPayload {
|
|
let status: String?
|
|
let tier: String?
|
|
let billingPeriodEnd: Date?
|
|
let weeklyUsed: Double?
|
|
let weeklyLimit: Double?
|
|
let weeklyResetsAt: Date?
|
|
let email: String?
|
|
}
|
|
|
|
static func usageURL(baseURL: URL) -> URL {
|
|
baseURL.appendingPathComponent("/api/v1/usage")
|
|
}
|
|
|
|
static func subscriptionURL(baseURL: URL) -> URL {
|
|
baseURL.appendingPathComponent("/api/user/subscription")
|
|
}
|
|
|
|
static func statusError(for statusCode: Int) -> CodebuffUsageError? {
|
|
switch statusCode {
|
|
case 401, 403: .unauthorized
|
|
case 404: .endpointNotFound
|
|
case 500...599: .serviceUnavailable(statusCode)
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
static func parseUsagePayload(_ data: Data) throws -> UsagePayload {
|
|
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw CodebuffUsageError.parseFailed("Invalid JSON")
|
|
}
|
|
|
|
let used = self.double(from: root["usage"]) ?? self.double(from: root["used"])
|
|
let total = self.double(from: root["quota"]) ?? self.double(from: root["limit"])
|
|
let remaining = self.double(from: root["remainingBalance"]) ?? self.double(from: root["remaining"])
|
|
let reset = self.date(from: root["next_quota_reset"])
|
|
let autoTopUp = root["autoTopupEnabled"] as? Bool ?? root["auto_topup_enabled"] as? Bool
|
|
|
|
return UsagePayload(
|
|
used: used,
|
|
total: total,
|
|
remaining: remaining,
|
|
nextQuotaReset: reset,
|
|
autoTopupEnabled: autoTopUp)
|
|
}
|
|
|
|
static func parseSubscriptionPayload(_ data: Data) throws -> SubscriptionPayload {
|
|
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw CodebuffUsageError.parseFailed("Invalid JSON")
|
|
}
|
|
|
|
let subscription = root["subscription"] as? [String: Any]
|
|
let rateLimit = root["rateLimit"] as? [String: Any]
|
|
|
|
let tier = self.string(from: subscription?["displayName"])
|
|
?? self.string(from: root["displayName"])
|
|
?? self.string(from: subscription?["tier"])
|
|
?? self.string(from: root["tier"])
|
|
?? self.string(from: subscription?["scheduledTier"])
|
|
let status = subscription?["status"] as? String
|
|
let email = root["email"] as? String ?? (root["user"] as? [String: Any])?["email"] as? String
|
|
let billingPeriodEnd = self.date(from: subscription?["billingPeriodEnd"])
|
|
?? self.date(from: subscription?["currentPeriodEnd"])
|
|
let weeklyUsed = self.double(from: rateLimit?["weeklyUsed"])
|
|
?? self.double(from: rateLimit?["used"])
|
|
let weeklyLimit = self.double(from: rateLimit?["weeklyLimit"])
|
|
?? self.double(from: rateLimit?["limit"])
|
|
let weeklyResetsAt = self.date(from: rateLimit?["weeklyResetsAt"])
|
|
|
|
return SubscriptionPayload(
|
|
status: status,
|
|
tier: tier,
|
|
billingPeriodEnd: billingPeriodEnd,
|
|
weeklyUsed: weeklyUsed,
|
|
weeklyLimit: weeklyLimit,
|
|
weeklyResetsAt: weeklyResetsAt,
|
|
email: email)
|
|
}
|
|
|
|
// MARK: - Test hooks
|
|
|
|
static func _parseUsagePayloadForTesting(_ data: Data) throws -> UsagePayload {
|
|
try self.parseUsagePayload(data)
|
|
}
|
|
|
|
static func _parseSubscriptionPayloadForTesting(_ data: Data) throws -> SubscriptionPayload {
|
|
try self.parseSubscriptionPayload(data)
|
|
}
|
|
|
|
static func _statusErrorForTesting(_ statusCode: Int) -> CodebuffUsageError? {
|
|
self.statusError(for: statusCode)
|
|
}
|
|
|
|
// MARK: - Networking
|
|
|
|
private static func fetchUsagePayload(
|
|
apiKey: String,
|
|
baseURL: URL,
|
|
transport: any ProviderHTTPTransport) async throws -> UsagePayload
|
|
{
|
|
var request = URLRequest(url: self.usageURL(baseURL: baseURL))
|
|
request.httpMethod = "POST"
|
|
request.timeoutInterval = self.requestTimeoutSeconds
|
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
request.httpBody = try? JSONSerialization.data(withJSONObject: ["fingerprintId": "codexbar-usage"])
|
|
|
|
let response = try await self.send(request: request, transport: transport)
|
|
if let err = self.statusError(for: response.statusCode) {
|
|
throw err
|
|
}
|
|
guard response.statusCode == 200 else {
|
|
throw CodebuffUsageError.apiError(response.statusCode)
|
|
}
|
|
return try self.parseUsagePayload(response.data)
|
|
}
|
|
|
|
private static func fetchSubscriptionPayload(
|
|
apiKey: String,
|
|
baseURL: URL,
|
|
transport: any ProviderHTTPTransport) async throws -> SubscriptionPayload
|
|
{
|
|
var request = URLRequest(url: self.subscriptionURL(baseURL: baseURL))
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = self.requestTimeoutSeconds
|
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let response = try await self.send(request: request, transport: transport)
|
|
if let err = self.statusError(for: response.statusCode) {
|
|
throw err
|
|
}
|
|
guard response.statusCode == 200 else {
|
|
throw CodebuffUsageError.apiError(response.statusCode)
|
|
}
|
|
return try self.parseSubscriptionPayload(response.data)
|
|
}
|
|
|
|
private static func send(
|
|
request: URLRequest,
|
|
transport: any ProviderHTTPTransport) async throws -> ProviderHTTPResponse
|
|
{
|
|
do {
|
|
return try await transport.response(for: request)
|
|
} catch let error as CodebuffUsageError {
|
|
throw error
|
|
} catch let error as URLError where error.code == .badServerResponse {
|
|
throw CodebuffUsageError.networkError("Invalid response")
|
|
} catch {
|
|
throw CodebuffUsageError.networkError(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// MARK: - Value parsing
|
|
|
|
private static func double(from value: Any?) -> Double? {
|
|
switch value {
|
|
case let number as NSNumber:
|
|
let raw = number.doubleValue
|
|
return raw.isFinite ? raw : nil
|
|
case let string as String:
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty, let raw = Double(trimmed), raw.isFinite else { return nil }
|
|
return raw
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func string(from value: Any?) -> String? {
|
|
switch value {
|
|
case let string as String:
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
case let number as NSNumber:
|
|
let raw = number.doubleValue
|
|
guard raw.isFinite else { return nil }
|
|
return number.stringValue
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func date(from value: Any?) -> Date? {
|
|
switch value {
|
|
case let string as String:
|
|
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
let fractional = ISO8601DateFormatter()
|
|
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = fractional.date(from: trimmed) {
|
|
return date
|
|
}
|
|
let plain = ISO8601DateFormatter()
|
|
plain.formatOptions = [.withInternetDateTime]
|
|
if let date = plain.date(from: trimmed) {
|
|
return date
|
|
}
|
|
if let interval = Double(trimmed), interval.isFinite {
|
|
return Self.dateFromNumeric(interval)
|
|
}
|
|
return nil
|
|
case let number as NSNumber:
|
|
let raw = number.doubleValue
|
|
return raw.isFinite ? Self.dateFromNumeric(raw) : nil
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func dateFromNumeric(_ value: Double) -> Date? {
|
|
if value > 10_000_000_000 {
|
|
return Date(timeIntervalSince1970: value / 1000)
|
|
}
|
|
return Date(timeIntervalSince1970: value)
|
|
}
|
|
}
|