chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenAIAPIProviderDescriptor {
|
||||
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
|
||||
|
||||
static func makeDescriptor() -> ProviderDescriptor {
|
||||
ProviderDescriptor(
|
||||
id: .openai,
|
||||
metadata: ProviderMetadata(
|
||||
id: .openai,
|
||||
displayName: "OpenAI",
|
||||
sessionLabel: "Spend",
|
||||
weeklyLabel: "Requests",
|
||||
opusLabel: nil,
|
||||
supportsOpus: false,
|
||||
supportsCredits: false,
|
||||
creditsHint: "",
|
||||
toggleTitle: "Show OpenAI usage",
|
||||
cliName: "openai",
|
||||
defaultEnabled: false,
|
||||
isPrimaryProvider: false,
|
||||
usesAccountFallback: false,
|
||||
dashboardURL: "https://platform.openai.com/usage",
|
||||
statusPageURL: "https://status.openai.com"),
|
||||
branding: ProviderBranding(
|
||||
iconStyle: .openai,
|
||||
iconResourceName: "ProviderIcon-codex",
|
||||
color: ProviderColor(red: 0.06, green: 0.51, blue: 0.43)),
|
||||
tokenCost: ProviderTokenCostConfig(
|
||||
supportsTokenCost: true,
|
||||
noDataMessage: { "OpenAI usage needs an Admin API key for organization usage." }),
|
||||
fetchPlan: ProviderFetchPlan(
|
||||
sourceModes: [.auto, .api],
|
||||
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenAIAPIBalanceFetchStrategy()] })),
|
||||
cli: ProviderCLIConfig(
|
||||
name: "openai",
|
||||
aliases: ["openai-api"],
|
||||
versionDetector: nil))
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy {
|
||||
let id: String = "openai.api.balance"
|
||||
let kind: ProviderFetchKind = .apiToken
|
||||
let usageFetcher: @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot
|
||||
let balanceFetcher: @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot
|
||||
|
||||
init(
|
||||
usageFetcher: @escaping @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot =
|
||||
OpenAIAPIBalanceFetchStrategy.fetchUsage(credential:days:),
|
||||
balanceFetcher: @escaping @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot = { apiKey in
|
||||
try await OpenAIAPICreditBalanceFetcher.fetchBalance(apiKey: apiKey)
|
||||
})
|
||||
{
|
||||
self.usageFetcher = usageFetcher
|
||||
self.balanceFetcher = balanceFetcher
|
||||
}
|
||||
|
||||
func isAvailable(_ context: ProviderFetchContext) async -> Bool {
|
||||
OpenAIAPIUsageCredential(environment: context.env) != nil
|
||||
}
|
||||
|
||||
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
|
||||
guard let credential = OpenAIAPIUsageCredential(environment: context.env) else {
|
||||
throw OpenAIAPISettingsError.missingToken
|
||||
}
|
||||
|
||||
do {
|
||||
let usage = try await self.usageFetcher(credential, context.costUsageHistoryDays)
|
||||
return self.makeResult(
|
||||
usage: usage.toUsageSnapshot(),
|
||||
sourceLabel: credential.sourceLabel)
|
||||
} catch {
|
||||
let usageError = error
|
||||
if !credential.allowsLegacyBalanceFallback {
|
||||
throw usageError
|
||||
}
|
||||
// Preserve the older balance-only path for unscoped keys and Admin API outages.
|
||||
do {
|
||||
let balance = try await self.balanceFetcher(credential.apiKey)
|
||||
return self.makeResult(
|
||||
usage: balance.toUsageSnapshot(),
|
||||
sourceLabel: "billing-api")
|
||||
} catch {
|
||||
if (usageError as? OpenAIAPIUsageError)?.isCredentialRejected != true {
|
||||
throw usageError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
private static func fetchUsage(
|
||||
credential: OpenAIAPIUsageCredential,
|
||||
days: Int) async throws -> OpenAIAPIUsageSnapshot
|
||||
{
|
||||
try await OpenAIAPIUsageFetcher.fetchUsage(
|
||||
apiKey: credential.apiKey,
|
||||
projectID: credential.projectID,
|
||||
historyDays: days)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAIAPIUsageCredential: Equatable {
|
||||
let apiKey: String
|
||||
let projectID: String?
|
||||
let usesAdminKey: Bool
|
||||
|
||||
init?(environment: [String: String]) {
|
||||
if let adminKey = OpenAIAPISettingsReader.adminAPIKey(environment: environment) {
|
||||
self.apiKey = adminKey
|
||||
self.usesAdminKey = true
|
||||
} else if let apiKey = OpenAIAPISettingsReader.apiKey(environment: environment) {
|
||||
self.apiKey = apiKey
|
||||
self.usesAdminKey = false
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
self.projectID = OpenAIAPISettingsReader.projectID(environment: environment)
|
||||
}
|
||||
|
||||
var sourceLabel: String {
|
||||
self.projectID == nil ? "admin-api" : "admin-api:project"
|
||||
}
|
||||
|
||||
var allowsLegacyBalanceFallback: Bool {
|
||||
self.projectID == nil || !self.usesAdminKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenAIAPISettingsReader {
|
||||
public static let adminAPIKeyEnvironmentKey = "OPENAI_ADMIN_KEY"
|
||||
public static let apiKeyEnvironmentKey = "OPENAI_API_KEY"
|
||||
public static let projectIDEnvironmentKey = "OPENAI_PROJECT_ID"
|
||||
public static let apiKeyEnvironmentKeys = [
|
||||
Self.adminAPIKeyEnvironmentKey,
|
||||
Self.apiKeyEnvironmentKey,
|
||||
]
|
||||
|
||||
public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
||||
for key in self.apiKeyEnvironmentKeys {
|
||||
if let token = self.cleaned(environment[key]) { return token }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func adminAPIKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
||||
self.cleaned(environment[self.adminAPIKeyEnvironmentKey])
|
||||
}
|
||||
|
||||
public static func projectID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
|
||||
self.cleaned(environment[self.projectIDEnvironmentKey])
|
||||
}
|
||||
|
||||
static func cleaned(_ raw: String?) -> String? {
|
||||
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
|
||||
(value.hasPrefix("'") && value.hasSuffix("'"))
|
||||
{
|
||||
value = String(value.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenAIAPISettingsError: LocalizedError, Sendable {
|
||||
case missingToken
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingToken:
|
||||
"OpenAI API key not configured. Set OPENAI_API_KEY or configure an API key in Settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import Foundation
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public enum OpenAIAPIUsageError: LocalizedError, Sendable, Equatable {
|
||||
case missingCredentials
|
||||
case networkError(String)
|
||||
case apiError(endpoint: String, statusCode: Int)
|
||||
case parseFailed(endpoint: String, message: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingCredentials:
|
||||
"Missing OpenAI Admin API key."
|
||||
case let .networkError(message):
|
||||
"OpenAI API usage network error: \(message)"
|
||||
case let .apiError(endpoint, statusCode):
|
||||
"OpenAI API usage \(endpoint) error: HTTP \(statusCode)"
|
||||
case let .parseFailed(endpoint, message):
|
||||
"Failed to parse OpenAI API usage \(endpoint): \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
var isCredentialRejected: Bool {
|
||||
switch self {
|
||||
case let .apiError(_, statusCode):
|
||||
statusCode == 401 || statusCode == 403
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum OpenAIAPIUsageFetcher {
|
||||
public static let organizationCostsURL = URL(string: "https://api.openai.com/v1/organization/costs")!
|
||||
public static let organizationCompletionsUsageURL =
|
||||
URL(string: "https://api.openai.com/v1/organization/usage/completions")!
|
||||
|
||||
private static let maxDailyBucketLimit = 31
|
||||
private static let maxPaginationPages = 100
|
||||
private static let timeoutSeconds: TimeInterval = 20
|
||||
|
||||
private struct EndpointRequestContext {
|
||||
let apiKey: String
|
||||
let projectID: String?
|
||||
let transport: any ProviderHTTPTransport
|
||||
let retryPolicy: ProviderHTTPRetryPolicy
|
||||
}
|
||||
|
||||
private struct UsageEndpoint<Bucket> {
|
||||
let name: String
|
||||
let baseURL: URL
|
||||
let queryItems: [URLQueryItem]
|
||||
let decodePage: (Data) throws -> UsagePage<Bucket>
|
||||
}
|
||||
|
||||
private struct UsagePage<Bucket> {
|
||||
let data: [Bucket]
|
||||
let hasMore: Bool
|
||||
let nextPage: String?
|
||||
}
|
||||
|
||||
private typealias SnapshotMetadata = (now: Date, calendar: Calendar, historyDays: Int, projectID: String?)
|
||||
|
||||
public static func fetchUsage(
|
||||
apiKey: String,
|
||||
projectID: String? = nil,
|
||||
costsURL: URL = Self.organizationCostsURL,
|
||||
completionsURL: URL = Self.organizationCompletionsUsageURL,
|
||||
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
|
||||
now: Date = Date(),
|
||||
historyDays: Int = 30,
|
||||
retryPolicy: ProviderHTTPRetryPolicy = .transientIdempotent) async throws -> OpenAIAPIUsageSnapshot
|
||||
{
|
||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw OpenAIAPIUsageError.missingCredentials
|
||||
}
|
||||
let normalizedProjectID = OpenAIAPISettingsReader.cleaned(projectID)
|
||||
|
||||
let calendar = Self.utcCalendar
|
||||
let clampedHistoryDays = max(1, min(365, historyDays))
|
||||
let ranges = Self.dailyRanges(now: now, calendar: calendar, historyDays: clampedHistoryDays)
|
||||
let requestContext = EndpointRequestContext(
|
||||
apiKey: trimmed,
|
||||
projectID: normalizedProjectID,
|
||||
transport: transport,
|
||||
retryPolicy: retryPolicy)
|
||||
let costs = try await Self.fetchBuckets(
|
||||
endpoint: Self.costsEndpoint(baseURL: costsURL),
|
||||
context: requestContext,
|
||||
ranges: ranges)
|
||||
let completions = try await Self.fetchBuckets(
|
||||
endpoint: Self.completionsEndpoint(baseURL: completionsURL),
|
||||
context: requestContext,
|
||||
ranges: ranges)
|
||||
|
||||
return Self.makeSnapshot(
|
||||
costs: costs,
|
||||
completions: completions,
|
||||
metadata: SnapshotMetadata(
|
||||
now: now,
|
||||
calendar: calendar,
|
||||
historyDays: clampedHistoryDays,
|
||||
projectID: normalizedProjectID))
|
||||
}
|
||||
|
||||
static func _parseSnapshotForTesting(
|
||||
costs: Data,
|
||||
completions: Data,
|
||||
now: Date,
|
||||
calendar: Calendar = Self.utcCalendar,
|
||||
historyDays: Int = 30,
|
||||
projectID: String? = nil) throws -> OpenAIAPIUsageSnapshot
|
||||
{
|
||||
try self.makeSnapshot(
|
||||
costs: self.decodeCosts(costs).data,
|
||||
completions: self.decodeCompletions(completions).data,
|
||||
metadata: SnapshotMetadata(
|
||||
now: now,
|
||||
calendar: calendar,
|
||||
historyDays: historyDays,
|
||||
projectID: OpenAIAPISettingsReader.cleaned(projectID)))
|
||||
}
|
||||
|
||||
private static func costsEndpoint(baseURL: URL) -> UsageEndpoint<OpenAICostBucket> {
|
||||
UsageEndpoint(
|
||||
name: "costs",
|
||||
baseURL: baseURL,
|
||||
queryItems: [URLQueryItem(name: "group_by", value: "line_item")],
|
||||
decodePage: {
|
||||
let response = try self.decodeCosts($0)
|
||||
return UsagePage(
|
||||
data: response.data,
|
||||
hasMore: response.hasMore,
|
||||
nextPage: response.nextPage)
|
||||
})
|
||||
}
|
||||
|
||||
private static func completionsEndpoint(
|
||||
baseURL: URL) -> UsageEndpoint<OpenAICompletionsUsageBucket>
|
||||
{
|
||||
UsageEndpoint(
|
||||
name: "completions",
|
||||
baseURL: baseURL,
|
||||
queryItems: [URLQueryItem(name: "group_by", value: "model")],
|
||||
decodePage: {
|
||||
let response = try self.decodeCompletions($0)
|
||||
return UsagePage(
|
||||
data: response.data,
|
||||
hasMore: response.hasMore,
|
||||
nextPage: response.nextPage)
|
||||
})
|
||||
}
|
||||
|
||||
private static func fetchBuckets<Bucket>(
|
||||
endpoint: UsageEndpoint<Bucket>,
|
||||
context: EndpointRequestContext,
|
||||
ranges: [DateRange]) async throws -> [Bucket]
|
||||
{
|
||||
var buckets: [Bucket] = []
|
||||
for range in ranges {
|
||||
var nextPage: String?
|
||||
var seenPages: Set<String> = []
|
||||
var pageCount = 0
|
||||
|
||||
repeat {
|
||||
pageCount += 1
|
||||
guard pageCount <= Self.maxPaginationPages else {
|
||||
throw OpenAIAPIUsageError.parseFailed(
|
||||
endpoint: endpoint.name,
|
||||
message: "Pagination exceeded \(Self.maxPaginationPages) pages.")
|
||||
}
|
||||
|
||||
let url = Self.url(
|
||||
baseURL: endpoint.baseURL,
|
||||
range: range,
|
||||
queryItems: endpoint.queryItems + Self.projectQueryItems(projectID: context.projectID),
|
||||
page: nextPage)
|
||||
let data = try await Self.fetchData(
|
||||
url: url,
|
||||
apiKey: context.apiKey,
|
||||
endpoint: endpoint.name,
|
||||
transport: context.transport,
|
||||
retryPolicy: context.retryPolicy)
|
||||
let page = try endpoint.decodePage(data)
|
||||
buckets.append(contentsOf: page.data)
|
||||
|
||||
guard page.hasMore else {
|
||||
nextPage = nil
|
||||
continue
|
||||
}
|
||||
guard let cursor = Self.cleanedPageCursor(page.nextPage) else {
|
||||
throw OpenAIAPIUsageError.parseFailed(
|
||||
endpoint: endpoint.name,
|
||||
message: "Pagination cursor missing.")
|
||||
}
|
||||
guard seenPages.insert(cursor).inserted else {
|
||||
throw OpenAIAPIUsageError.parseFailed(
|
||||
endpoint: endpoint.name,
|
||||
message: "Pagination cursor repeated.")
|
||||
}
|
||||
nextPage = cursor
|
||||
} while nextPage != nil
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
private static func projectQueryItems(projectID: String?) -> [URLQueryItem] {
|
||||
guard let projectID else { return [] }
|
||||
return [URLQueryItem(name: "project_ids", value: projectID)]
|
||||
}
|
||||
|
||||
private static func fetchData(
|
||||
url: URL,
|
||||
apiKey: String,
|
||||
endpoint: String,
|
||||
transport: any ProviderHTTPTransport,
|
||||
retryPolicy: ProviderHTTPRetryPolicy) async throws -> Data
|
||||
{
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = Self.timeoutSeconds
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let response: ProviderHTTPResponse
|
||||
do {
|
||||
response = try await transport.response(for: request, retryPolicy: retryPolicy)
|
||||
} catch {
|
||||
throw OpenAIAPIUsageError.networkError(error.localizedDescription)
|
||||
}
|
||||
|
||||
guard response.statusCode == 200 else {
|
||||
throw OpenAIAPIUsageError.apiError(endpoint: endpoint, statusCode: response.statusCode)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
private static func decodeCosts(_ data: Data) throws -> CostsResponse {
|
||||
do {
|
||||
return try JSONDecoder().decode(CostsResponse.self, from: data)
|
||||
} catch {
|
||||
throw OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeCompletions(_ data: Data) throws -> CompletionsUsageResponse {
|
||||
do {
|
||||
return try JSONDecoder().decode(CompletionsUsageResponse.self, from: data)
|
||||
} catch {
|
||||
throw OpenAIAPIUsageError.parseFailed(endpoint: "completions", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeSnapshot(
|
||||
costs: [OpenAICostBucket],
|
||||
completions: [OpenAICompletionsUsageBucket],
|
||||
metadata: SnapshotMetadata) -> OpenAIAPIUsageSnapshot
|
||||
{
|
||||
var accumulators: [Int: DailyAccumulator] = [:]
|
||||
|
||||
for bucket in costs {
|
||||
var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator(
|
||||
startTime: bucket.startTime,
|
||||
endTime: bucket.endTime)
|
||||
for result in bucket.results {
|
||||
let value = result.amount?.value ?? 0
|
||||
accumulator.costUSD += value
|
||||
let lineItem = Self.displayName(result.lineItem, fallback: "API")
|
||||
accumulator.lineItems[lineItem, default: 0] += value
|
||||
}
|
||||
accumulators[bucket.startTime] = accumulator
|
||||
}
|
||||
|
||||
for bucket in completions {
|
||||
var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator(
|
||||
startTime: bucket.startTime,
|
||||
endTime: bucket.endTime)
|
||||
for result in bucket.results {
|
||||
let input = result.inputTokens ?? 0
|
||||
let cached = result.inputCachedTokens ?? 0
|
||||
let output = result.outputTokens ?? 0
|
||||
let audioInput = result.inputAudioTokens ?? 0
|
||||
let audioOutput = result.outputAudioTokens ?? 0
|
||||
let requests = result.numModelRequests ?? 0
|
||||
let totalTokens = input + output + audioInput + audioOutput
|
||||
accumulator.requests += requests
|
||||
accumulator.inputTokens += input + audioInput
|
||||
accumulator.cachedInputTokens += cached
|
||||
accumulator.outputTokens += output + audioOutput
|
||||
accumulator.totalTokens += totalTokens
|
||||
let modelName = Self.displayName(result.model, fallback: "Responses and Chat Completions")
|
||||
accumulator.models[modelName, default: ModelAccumulator()].add(
|
||||
requests: requests,
|
||||
inputTokens: input + audioInput,
|
||||
cachedInputTokens: cached,
|
||||
outputTokens: output + audioOutput,
|
||||
totalTokens: totalTokens)
|
||||
}
|
||||
accumulators[bucket.startTime] = accumulator
|
||||
}
|
||||
|
||||
let daily = accumulators.values
|
||||
.filter { $0.startDate <= metadata.now }
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
.map { $0.makeBucket(calendar: metadata.calendar) }
|
||||
return OpenAIAPIUsageSnapshot(
|
||||
daily: daily,
|
||||
updatedAt: metadata.now,
|
||||
historyDays: metadata.historyDays,
|
||||
projectID: metadata.projectID)
|
||||
}
|
||||
|
||||
private static func displayName(_ raw: String?, fallback: String) -> String {
|
||||
guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
|
||||
return fallback
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func cleanedPageCursor(_ raw: String?) -> String? {
|
||||
guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func url(
|
||||
baseURL: URL,
|
||||
range: DateRange,
|
||||
queryItems extraItems: [URLQueryItem],
|
||||
page: String? = nil) -> URL
|
||||
{
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "start_time", value: String(range.startTime)),
|
||||
URLQueryItem(name: "end_time", value: String(range.endTime)),
|
||||
URLQueryItem(name: "bucket_width", value: "1d"),
|
||||
URLQueryItem(name: "limit", value: String(range.limit)),
|
||||
] + extraItems
|
||||
if let page {
|
||||
queryItems.append(URLQueryItem(name: "page", value: page))
|
||||
}
|
||||
components.queryItems = queryItems
|
||||
return components.url!
|
||||
}
|
||||
|
||||
private static func dailyRanges(now: Date, calendar: Calendar, historyDays: Int) -> [DateRange] {
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let clampedHistoryDays = max(1, min(365, historyDays))
|
||||
var cursor = calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: today) ?? today
|
||||
var remainingDays = clampedHistoryDays
|
||||
var ranges: [DateRange] = []
|
||||
ranges.reserveCapacity(Int(ceil(Double(clampedHistoryDays) / Double(Self.maxDailyBucketLimit))))
|
||||
|
||||
while remainingDays > 0 {
|
||||
let chunkDays = min(Self.maxDailyBucketLimit, remainingDays)
|
||||
let end = calendar.date(byAdding: .day, value: chunkDays, to: cursor) ?? cursor
|
||||
ranges.append(DateRange(
|
||||
startTime: Int(cursor.timeIntervalSince1970),
|
||||
endTime: Int(end.timeIntervalSince1970),
|
||||
limit: chunkDays))
|
||||
cursor = end
|
||||
remainingDays -= chunkDays
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
private static var utcCalendar: Calendar {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.locale = Locale(identifier: "en_US_POSIX")
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
return calendar
|
||||
}
|
||||
}
|
||||
|
||||
private struct DateRange {
|
||||
let startTime: Int
|
||||
let endTime: Int
|
||||
let limit: Int
|
||||
}
|
||||
|
||||
private struct DailyAccumulator {
|
||||
let startTime: Int
|
||||
let endTime: Int
|
||||
var costUSD: Double = 0
|
||||
var requests: Int = 0
|
||||
var inputTokens: Int = 0
|
||||
var cachedInputTokens: Int = 0
|
||||
var outputTokens: Int = 0
|
||||
var totalTokens: Int = 0
|
||||
var lineItems: [String: Double] = [:]
|
||||
var models: [String: ModelAccumulator] = [:]
|
||||
|
||||
var startDate: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(self.startTime))
|
||||
}
|
||||
|
||||
func makeBucket(calendar: Calendar) -> OpenAIAPIUsageSnapshot.DailyBucket {
|
||||
OpenAIAPIUsageSnapshot.DailyBucket(
|
||||
day: Self.dayKey(from: self.startDate, calendar: calendar),
|
||||
startTime: self.startDate,
|
||||
endTime: Date(timeIntervalSince1970: TimeInterval(self.endTime)),
|
||||
costUSD: self.costUSD,
|
||||
requests: self.requests,
|
||||
inputTokens: self.inputTokens,
|
||||
cachedInputTokens: self.cachedInputTokens,
|
||||
outputTokens: self.outputTokens,
|
||||
totalTokens: self.totalTokens,
|
||||
lineItems: self.lineItems
|
||||
.map { OpenAIAPIUsageSnapshot.LineItemBreakdown(name: $0.key, costUSD: $0.value) }
|
||||
.sorted {
|
||||
if $0.costUSD == $1.costUSD { return $0.name < $1.name }
|
||||
return $0.costUSD > $1.costUSD
|
||||
},
|
||||
models: self.models
|
||||
.map { $0.value.makeModel(name: $0.key) }
|
||||
.sorted {
|
||||
if $0.totalTokens == $1.totalTokens { return $0.name < $1.name }
|
||||
return $0.totalTokens > $1.totalTokens
|
||||
})
|
||||
}
|
||||
|
||||
private static func dayKey(from date: Date, calendar: Calendar) -> String {
|
||||
let comps = calendar.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModelAccumulator {
|
||||
var requests = 0
|
||||
var inputTokens = 0
|
||||
var cachedInputTokens = 0
|
||||
var outputTokens = 0
|
||||
var totalTokens = 0
|
||||
|
||||
mutating func add(
|
||||
requests: Int,
|
||||
inputTokens: Int,
|
||||
cachedInputTokens: Int,
|
||||
outputTokens: Int,
|
||||
totalTokens: Int)
|
||||
{
|
||||
self.requests += requests
|
||||
self.inputTokens += inputTokens
|
||||
self.cachedInputTokens += cachedInputTokens
|
||||
self.outputTokens += outputTokens
|
||||
self.totalTokens += totalTokens
|
||||
}
|
||||
|
||||
func makeModel(name: String) -> OpenAIAPIUsageSnapshot.ModelBreakdown {
|
||||
OpenAIAPIUsageSnapshot.ModelBreakdown(
|
||||
name: name,
|
||||
requests: self.requests,
|
||||
inputTokens: self.inputTokens,
|
||||
cachedInputTokens: self.cachedInputTokens,
|
||||
outputTokens: self.outputTokens,
|
||||
totalTokens: self.totalTokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
|
||||
struct CostsResponse: Decodable {
|
||||
let data: [OpenAICostBucket]
|
||||
let hasMore: Bool
|
||||
let nextPage: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case data
|
||||
case hasMore = "has_more"
|
||||
case nextPage = "next_page"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.data = try container.decode([OpenAICostBucket].self, forKey: .data)
|
||||
self.hasMore = try container.decode(Bool.self, forKey: .hasMore)
|
||||
self.nextPage = try container.decodeIfPresent(String.self, forKey: .nextPage)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAICostBucket: Decodable {
|
||||
let startTime: Int
|
||||
let endTime: Int
|
||||
let results: [OpenAICostResult]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case startTime = "start_time"
|
||||
case endTime = "end_time"
|
||||
case results
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAICostResult: Decodable {
|
||||
struct Amount: Decodable {
|
||||
let value: Double?
|
||||
let currency: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case value
|
||||
case currency
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.value = try container.decodeFlexibleDoubleIfPresent(forKey: .value)
|
||||
self.currency = try container.decodeIfPresent(String.self, forKey: .currency)
|
||||
}
|
||||
}
|
||||
|
||||
let amount: Amount?
|
||||
let lineItem: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case amount
|
||||
case lineItem = "line_item"
|
||||
}
|
||||
}
|
||||
|
||||
struct CompletionsUsageResponse: Decodable {
|
||||
let data: [OpenAICompletionsUsageBucket]
|
||||
let hasMore: Bool
|
||||
let nextPage: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case data
|
||||
case hasMore = "has_more"
|
||||
case nextPage = "next_page"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.data = try container.decode([OpenAICompletionsUsageBucket].self, forKey: .data)
|
||||
self.hasMore = try container.decode(Bool.self, forKey: .hasMore)
|
||||
self.nextPage = try container.decodeIfPresent(String.self, forKey: .nextPage)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAICompletionsUsageBucket: Decodable {
|
||||
let startTime: Int
|
||||
let endTime: Int
|
||||
let results: [OpenAICompletionsUsageResult]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case startTime = "start_time"
|
||||
case endTime = "end_time"
|
||||
case results
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenAICompletionsUsageResult: Decodable {
|
||||
let inputTokens: Int?
|
||||
let inputCachedTokens: Int?
|
||||
let inputAudioTokens: Int?
|
||||
let outputTokens: Int?
|
||||
let outputAudioTokens: Int?
|
||||
let numModelRequests: Int?
|
||||
let model: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case inputTokens = "input_tokens"
|
||||
case inputCachedTokens = "input_cached_tokens"
|
||||
case inputAudioTokens = "input_audio_tokens"
|
||||
case outputTokens = "output_tokens"
|
||||
case outputAudioTokens = "output_audio_tokens"
|
||||
case numModelRequests = "num_model_requests"
|
||||
case model
|
||||
}
|
||||
}
|
||||
|
||||
extension KeyedDecodingContainer {
|
||||
fileprivate func decodeFlexibleDoubleIfPresent(forKey key: Key) throws -> Double? {
|
||||
guard self.contains(key), try !self.decodeNil(forKey: key) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let value = try? self.decode(Double.self, forKey: key), value.isFinite {
|
||||
return value
|
||||
}
|
||||
|
||||
if let rawValue = try? self.decode(String.self, forKey: key) {
|
||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
if let value = Double(trimmed), value.isFinite {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: key,
|
||||
in: self,
|
||||
debugDescription: "Expected a number or numeric string for \(key.stringValue)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import Foundation
|
||||
|
||||
public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable {
|
||||
public struct DailyBucket: Codable, Equatable, Sendable, Identifiable {
|
||||
public let day: String
|
||||
public let startTime: Date
|
||||
public let endTime: Date
|
||||
public let costUSD: Double
|
||||
public let requests: Int
|
||||
public let inputTokens: Int
|
||||
public let cachedInputTokens: Int
|
||||
public let outputTokens: Int
|
||||
public let totalTokens: Int
|
||||
public let lineItems: [LineItemBreakdown]
|
||||
public let models: [ModelBreakdown]
|
||||
|
||||
public var id: String {
|
||||
self.day
|
||||
}
|
||||
|
||||
public init(
|
||||
day: String,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
costUSD: Double,
|
||||
requests: Int,
|
||||
inputTokens: Int,
|
||||
cachedInputTokens: Int,
|
||||
outputTokens: Int,
|
||||
totalTokens: Int,
|
||||
lineItems: [LineItemBreakdown],
|
||||
models: [ModelBreakdown])
|
||||
{
|
||||
self.day = day
|
||||
self.startTime = startTime
|
||||
self.endTime = endTime
|
||||
self.costUSD = costUSD
|
||||
self.requests = requests
|
||||
self.inputTokens = inputTokens
|
||||
self.cachedInputTokens = cachedInputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.totalTokens = totalTokens
|
||||
self.lineItems = lineItems
|
||||
self.models = models
|
||||
}
|
||||
}
|
||||
|
||||
public struct LineItemBreakdown: Codable, Equatable, Sendable, Identifiable {
|
||||
public let name: String
|
||||
public let costUSD: Double
|
||||
|
||||
public var id: String {
|
||||
self.name
|
||||
}
|
||||
|
||||
public init(name: String, costUSD: Double) {
|
||||
self.name = name
|
||||
self.costUSD = costUSD
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable {
|
||||
public let name: String
|
||||
public let requests: Int
|
||||
public let inputTokens: Int
|
||||
public let cachedInputTokens: Int
|
||||
public let outputTokens: Int
|
||||
public let totalTokens: Int
|
||||
|
||||
public var id: String {
|
||||
self.name
|
||||
}
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
requests: Int,
|
||||
inputTokens: Int,
|
||||
cachedInputTokens: Int,
|
||||
outputTokens: Int,
|
||||
totalTokens: Int)
|
||||
{
|
||||
self.name = name
|
||||
self.requests = requests
|
||||
self.inputTokens = inputTokens
|
||||
self.cachedInputTokens = cachedInputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.totalTokens = totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
public struct Summary: Equatable, Sendable {
|
||||
public let costUSD: Double
|
||||
public let requests: Int
|
||||
public let inputTokens: Int
|
||||
public let cachedInputTokens: Int
|
||||
public let outputTokens: Int
|
||||
public let totalTokens: Int
|
||||
|
||||
public init(
|
||||
costUSD: Double,
|
||||
requests: Int,
|
||||
inputTokens: Int,
|
||||
cachedInputTokens: Int,
|
||||
outputTokens: Int,
|
||||
totalTokens: Int)
|
||||
{
|
||||
self.costUSD = costUSD
|
||||
self.requests = requests
|
||||
self.inputTokens = inputTokens
|
||||
self.cachedInputTokens = cachedInputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.totalTokens = totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
public let daily: [DailyBucket]
|
||||
public let updatedAt: Date
|
||||
public let historyDays: Int
|
||||
public let projectID: String?
|
||||
|
||||
public init(daily: [DailyBucket], updatedAt: Date, historyDays: Int = 30, projectID: String? = nil) {
|
||||
self.daily = daily.sorted { $0.startTime < $1.startTime }
|
||||
self.updatedAt = updatedAt
|
||||
self.historyDays = max(1, min(365, historyDays))
|
||||
self.projectID = OpenAIAPISettingsReader.cleaned(projectID)
|
||||
}
|
||||
|
||||
public var last30Days: Summary {
|
||||
self.historyDays == 1 ? self.currentDay : self.summary(days: self.historyDays)
|
||||
}
|
||||
|
||||
public var historyWindowLabel: String {
|
||||
self.historyDays == 1 ? "Today" : "\(self.historyDays)d"
|
||||
}
|
||||
|
||||
public var historyWindowPeriodLabel: String {
|
||||
self.historyDays == 1 ? "Today" : "Last \(self.historyDays) days"
|
||||
}
|
||||
|
||||
public var last7Days: Summary {
|
||||
self.summary(days: 7)
|
||||
}
|
||||
|
||||
public var currentDay: Summary {
|
||||
self.summary(forLocalDayContaining: self.updatedAt)
|
||||
}
|
||||
|
||||
public var latestDay: Summary {
|
||||
self.summary(days: 1)
|
||||
}
|
||||
|
||||
public func summary(forLocalDayContaining date: Date, calendar _: Calendar = .current) -> Summary {
|
||||
let selected = self.daily.filter { bucket in
|
||||
CostUsageBucketInterval.contains(
|
||||
date,
|
||||
startTime: bucket.startTime,
|
||||
endTime: bucket.endTime)
|
||||
}
|
||||
return Summary(
|
||||
costUSD: selected.reduce(0) { $0 + $1.costUSD },
|
||||
requests: selected.reduce(0) { $0 + $1.requests },
|
||||
inputTokens: selected.reduce(0) { $0 + $1.inputTokens },
|
||||
cachedInputTokens: selected.reduce(0) { $0 + $1.cachedInputTokens },
|
||||
outputTokens: selected.reduce(0) { $0 + $1.outputTokens },
|
||||
totalTokens: selected.reduce(0) { $0 + $1.totalTokens })
|
||||
}
|
||||
|
||||
public func summary(days: Int) -> Summary {
|
||||
let selected = self.daily.suffix(max(1, days))
|
||||
return Summary(
|
||||
costUSD: selected.reduce(0) { $0 + $1.costUSD },
|
||||
requests: selected.reduce(0) { $0 + $1.requests },
|
||||
inputTokens: selected.reduce(0) { $0 + $1.inputTokens },
|
||||
cachedInputTokens: selected.reduce(0) { $0 + $1.cachedInputTokens },
|
||||
outputTokens: selected.reduce(0) { $0 + $1.outputTokens },
|
||||
totalTokens: selected.reduce(0) { $0 + $1.totalTokens })
|
||||
}
|
||||
|
||||
public var topModels: [ModelBreakdown] {
|
||||
var totals: [String: ModelAccumulator] = [:]
|
||||
for day in self.daily {
|
||||
for model in day.models {
|
||||
totals[model.name, default: ModelAccumulator()].add(model)
|
||||
}
|
||||
}
|
||||
return totals
|
||||
.map { name, total in total.makeModel(name: name) }
|
||||
.sorted {
|
||||
if $0.totalTokens == $1.totalTokens { return $0.name < $1.name }
|
||||
return $0.totalTokens > $1.totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
public var topLineItems: [LineItemBreakdown] {
|
||||
var totals: [String: Double] = [:]
|
||||
for day in self.daily {
|
||||
for item in day.lineItems {
|
||||
totals[item.name, default: 0] += item.costUSD
|
||||
}
|
||||
}
|
||||
return totals
|
||||
.map { LineItemBreakdown(name: $0.key, costUSD: $0.value) }
|
||||
.sorted {
|
||||
if $0.costUSD == $1.costUSD { return $0.name < $1.name }
|
||||
return $0.costUSD > $1.costUSD
|
||||
}
|
||||
}
|
||||
|
||||
public func toUsageSnapshot() -> UsageSnapshot {
|
||||
let total = self.last30Days
|
||||
return UsageSnapshot(
|
||||
primary: nil,
|
||||
secondary: nil,
|
||||
providerCost: ProviderCostSnapshot(
|
||||
used: total.costUSD,
|
||||
limit: 0,
|
||||
currencyCode: "USD",
|
||||
period: self.historyWindowPeriodLabel,
|
||||
updatedAt: self.updatedAt),
|
||||
openAIAPIUsage: self,
|
||||
updatedAt: self.updatedAt,
|
||||
identity: ProviderIdentitySnapshot(
|
||||
providerID: .openai,
|
||||
accountEmail: nil,
|
||||
accountOrganization: self.identityAccountOrganization,
|
||||
loginMethod: self.identityLoginMethod))
|
||||
}
|
||||
|
||||
private var identityLoginMethod: String {
|
||||
guard let projectID else { return "Admin API" }
|
||||
return "Admin API: \(projectID)"
|
||||
}
|
||||
|
||||
private var identityAccountOrganization: String? {
|
||||
guard let projectID else { return nil }
|
||||
return "Project: \(projectID)"
|
||||
}
|
||||
|
||||
public func toCostUsageTokenSnapshot() -> CostUsageTokenSnapshot {
|
||||
let daily = self.daily.map { bucket in
|
||||
let modelBreakdowns = bucket.models.map {
|
||||
CostUsageDailyReport.ModelBreakdown(
|
||||
modelName: $0.name,
|
||||
costUSD: nil,
|
||||
totalTokens: $0.totalTokens,
|
||||
requestCount: $0.requests)
|
||||
}
|
||||
let modelsUsed = bucket.models.map(\.name)
|
||||
return CostUsageDailyReport.Entry(
|
||||
date: bucket.day,
|
||||
inputTokens: bucket.inputTokens,
|
||||
outputTokens: bucket.outputTokens,
|
||||
cacheReadTokens: bucket.cachedInputTokens,
|
||||
cacheCreationTokens: nil,
|
||||
totalTokens: bucket.totalTokens,
|
||||
requestCount: bucket.requests,
|
||||
costUSD: bucket.costUSD,
|
||||
modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed,
|
||||
modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns)
|
||||
}
|
||||
let today = self.currentDay
|
||||
let total = self.last30Days
|
||||
return CostUsageTokenSnapshot(
|
||||
sessionTokens: today.totalTokens,
|
||||
sessionCostUSD: today.costUSD,
|
||||
sessionRequests: today.requests,
|
||||
last30DaysTokens: total.totalTokens,
|
||||
last30DaysCostUSD: total.costUSD,
|
||||
last30DaysRequests: total.requests,
|
||||
historyDays: self.historyDays,
|
||||
daily: daily,
|
||||
updatedAt: self.updatedAt)
|
||||
}
|
||||
|
||||
private struct ModelAccumulator {
|
||||
var requests = 0
|
||||
var inputTokens = 0
|
||||
var cachedInputTokens = 0
|
||||
var outputTokens = 0
|
||||
var totalTokens = 0
|
||||
|
||||
mutating func add(_ model: ModelBreakdown) {
|
||||
self.requests += model.requests
|
||||
self.inputTokens += model.inputTokens
|
||||
self.cachedInputTokens += model.cachedInputTokens
|
||||
self.outputTokens += model.outputTokens
|
||||
self.totalTokens += model.totalTokens
|
||||
}
|
||||
|
||||
func makeModel(name: String) -> ModelBreakdown {
|
||||
ModelBreakdown(
|
||||
name: name,
|
||||
requests: self.requests,
|
||||
inputTokens: self.inputTokens,
|
||||
cachedInputTokens: self.cachedInputTokens,
|
||||
outputTokens: self.outputTokens,
|
||||
totalTokens: self.totalTokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user