194 lines
6.6 KiB
Swift
194 lines
6.6 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
public struct OpenAIAPICreditGrant: Decodable, Sendable {
|
|
public let grantAmount: Double?
|
|
public let usedAmount: Double?
|
|
public let expiresAt: Date?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case grantAmount = "grant_amount"
|
|
case usedAmount = "used_amount"
|
|
case expiresAt = "expires_at"
|
|
}
|
|
}
|
|
|
|
public struct OpenAIAPICreditGrantsList: Decodable, Sendable {
|
|
public let data: [OpenAIAPICreditGrant]
|
|
}
|
|
|
|
public struct OpenAIAPICreditGrantsResponse: Decodable, Sendable {
|
|
public let totalGranted: Double
|
|
public let totalUsed: Double
|
|
public let totalAvailable: Double
|
|
public let grants: OpenAIAPICreditGrantsList?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case totalGranted = "total_granted"
|
|
case totalUsed = "total_used"
|
|
case totalAvailable = "total_available"
|
|
case grants
|
|
}
|
|
}
|
|
|
|
public enum OpenAIAPICreditBalanceError: LocalizedError, Sendable, Equatable {
|
|
case missingCredentials
|
|
case networkError(String)
|
|
case apiError(Int)
|
|
case unauthorized
|
|
case forbidden
|
|
case parseFailed(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .missingCredentials:
|
|
"Missing OpenAI API key."
|
|
case let .networkError(message):
|
|
"OpenAI API credit balance network error: \(message)"
|
|
case let .apiError(statusCode):
|
|
"OpenAI API credit balance error: HTTP \(statusCode)"
|
|
case .unauthorized:
|
|
"OpenAI rejected this key for credit balance access (HTTP 401). Use an organization Admin API key " +
|
|
"for usage; project and service-account keys do not provide organization usage access."
|
|
case .forbidden:
|
|
"OpenAI API credit balance endpoint returned HTTP 403. Use a legacy/user API key with billing access; " +
|
|
"project keys may not expose credit grants."
|
|
case let .parseFailed(message):
|
|
"Failed to parse OpenAI API credit balance: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct OpenAIAPICreditBalanceSnapshot: Sendable {
|
|
public let totalGranted: Double
|
|
public let totalUsed: Double
|
|
public let totalAvailable: Double
|
|
public let nextGrantExpiry: Date?
|
|
public let updatedAt: Date
|
|
|
|
public init(
|
|
totalGranted: Double,
|
|
totalUsed: Double,
|
|
totalAvailable: Double,
|
|
nextGrantExpiry: Date?,
|
|
updatedAt: Date = Date())
|
|
{
|
|
self.totalGranted = totalGranted
|
|
self.totalUsed = totalUsed
|
|
self.totalAvailable = totalAvailable
|
|
self.nextGrantExpiry = nextGrantExpiry
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
let usedPercent: Double = if self.totalGranted > 0 {
|
|
min(100, max(0, (self.totalUsed / self.totalGranted) * 100))
|
|
} else {
|
|
self.totalAvailable > 0 ? 0 : 100
|
|
}
|
|
|
|
let primary = RateWindow(
|
|
usedPercent: usedPercent,
|
|
windowMinutes: nil,
|
|
resetsAt: self.nextGrantExpiry,
|
|
resetDescription: "\(Self.formatUSD(self.totalAvailable)) available")
|
|
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: nil,
|
|
providerCost: ProviderCostSnapshot(
|
|
used: max(0, self.totalUsed),
|
|
limit: max(0, self.totalGranted),
|
|
currencyCode: "USD",
|
|
period: "API credits",
|
|
resetsAt: self.nextGrantExpiry,
|
|
updatedAt: self.updatedAt),
|
|
updatedAt: self.updatedAt,
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .openai,
|
|
accountEmail: nil,
|
|
accountOrganization: nil,
|
|
loginMethod: "API balance: \(Self.formatUSD(self.totalAvailable))"))
|
|
}
|
|
|
|
private static func formatUSD(_ value: Double) -> String {
|
|
UsageFormatter.currencyString(max(0, value), currencyCode: "USD")
|
|
}
|
|
}
|
|
|
|
public enum OpenAIAPICreditBalanceFetcher {
|
|
public static let creditGrantsURL = URL(string: "https://api.openai.com/v1/dashboard/billing/credit_grants")!
|
|
private static let timeoutSeconds: TimeInterval = 15
|
|
|
|
public static func fetchBalance(
|
|
apiKey: String,
|
|
url: URL = Self.creditGrantsURL,
|
|
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
|
|
now: Date = Date()) async throws -> OpenAIAPICreditBalanceSnapshot
|
|
{
|
|
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
throw OpenAIAPICreditBalanceError.missingCredentials
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = Self.timeoutSeconds
|
|
request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let response: ProviderHTTPResponse
|
|
do {
|
|
response = try await transport.response(for: request)
|
|
} catch {
|
|
throw OpenAIAPICreditBalanceError.networkError(error.localizedDescription)
|
|
}
|
|
|
|
switch response.statusCode {
|
|
case 200:
|
|
break
|
|
case 401:
|
|
throw OpenAIAPICreditBalanceError.unauthorized
|
|
case 403:
|
|
throw OpenAIAPICreditBalanceError.forbidden
|
|
default:
|
|
throw OpenAIAPICreditBalanceError.apiError(response.statusCode)
|
|
}
|
|
|
|
return try self.parseSnapshot(response.data, now: now)
|
|
}
|
|
|
|
public static func _parseSnapshotForTesting(
|
|
_ data: Data,
|
|
now: Date = Date()) throws -> OpenAIAPICreditBalanceSnapshot
|
|
{
|
|
try self.parseSnapshot(data, now: now)
|
|
}
|
|
|
|
private static func parseSnapshot(_ data: Data, now: Date) throws -> OpenAIAPICreditBalanceSnapshot {
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .secondsSince1970
|
|
|
|
let decoded: OpenAIAPICreditGrantsResponse
|
|
do {
|
|
decoded = try decoder.decode(OpenAIAPICreditGrantsResponse.self, from: data)
|
|
} catch {
|
|
throw OpenAIAPICreditBalanceError.parseFailed(error.localizedDescription)
|
|
}
|
|
|
|
let nextExpiry = decoded.grants?.data
|
|
.compactMap(\.expiresAt)
|
|
.filter { $0 > now }
|
|
.min()
|
|
|
|
return OpenAIAPICreditBalanceSnapshot(
|
|
totalGranted: decoded.totalGranted,
|
|
totalUsed: decoded.totalUsed,
|
|
totalAvailable: decoded.totalAvailable,
|
|
nextGrantExpiry: nextExpiry,
|
|
updatedAt: now)
|
|
}
|
|
}
|