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

433 lines
16 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public enum ClaudeAdminAPIUsageError: 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 Anthropic Admin API key."
case let .networkError(message):
"Claude API usage network error: \(message)"
case let .apiError(endpoint, statusCode):
"Claude API usage \(endpoint) error: HTTP \(statusCode)"
case let .parseFailed(endpoint, message):
"Failed to parse Claude API usage \(endpoint): \(message)"
}
}
}
public enum ClaudeAdminAPIUsageFetcher {
public static let costReportURL = URL(string: "https://api.anthropic.com/v1/organizations/cost_report")!
public static let messagesUsageURL =
URL(string: "https://api.anthropic.com/v1/organizations/usage_report/messages")!
private static let anthropicVersion = "2023-06-01"
private static let timeoutSeconds: TimeInterval = 20
private static let maxDailyBuckets = 31
public static func fetchUsage(
apiKey: String,
costURL: URL = Self.costReportURL,
messagesURL: URL = Self.messagesUsageURL,
session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
now: Date = Date()) async throws -> ClaudeAdminAPIUsageSnapshot
{
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw ClaudeAdminAPIUsageError.missingCredentials
}
let calendar = Self.utcCalendar
let range = Self.dailyRange(now: now, calendar: calendar)
let costs = try await Self.fetchCostReport(
apiKey: trimmed,
baseURL: costURL,
range: range,
transport: transport)
let messages = try await Self.fetchMessagesUsage(
apiKey: trimmed,
baseURL: messagesURL,
range: range,
transport: transport)
return Self.makeSnapshot(costs: costs, messages: messages, now: now, calendar: calendar)
}
static func _parseSnapshotForTesting(
costs: Data,
messages: Data,
now: Date,
calendar: Calendar = Self.utcCalendar) throws -> ClaudeAdminAPIUsageSnapshot
{
let costs = try Self.decodeCosts(costs)
let messages = try Self.decodeMessages(messages)
return Self.makeSnapshot(costs: costs, messages: messages, now: now, calendar: calendar)
}
private static func fetchCostReport(
apiKey: String,
baseURL: URL,
range: DateRange,
transport: any ProviderHTTPTransport) async throws -> CostReportResponse
{
let url = Self.url(
baseURL: baseURL,
range: range,
queryItems: [
URLQueryItem(name: "group_by[]", value: "description"),
])
let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "cost_report", transport: transport)
return try Self.decodeCosts(data)
}
private static func fetchMessagesUsage(
apiKey: String,
baseURL: URL,
range: DateRange,
transport: any ProviderHTTPTransport) async throws -> MessagesUsageResponse
{
let url = Self.url(
baseURL: baseURL,
range: range,
queryItems: [
URLQueryItem(name: "group_by[]", value: "model"),
])
let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "messages", transport: transport)
return try Self.decodeMessages(data)
}
private static func fetchData(
url: URL,
apiKey: String,
endpoint: String,
transport: any ProviderHTTPTransport) async throws -> Data
{
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = Self.timeoutSeconds
request.setValue(Self.anthropicVersion, forHTTPHeaderField: "anthropic-version")
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("CodexBar/1.0", forHTTPHeaderField: "User-Agent")
let response: ProviderHTTPResponse
do {
response = try await transport.response(for: request)
} catch {
throw ClaudeAdminAPIUsageError.networkError(error.localizedDescription)
}
guard response.statusCode == 200 else {
throw ClaudeAdminAPIUsageError.apiError(endpoint: endpoint, statusCode: response.statusCode)
}
return response.data
}
private static func decodeCosts(_ data: Data) throws -> CostReportResponse {
do {
return try JSONDecoder().decode(CostReportResponse.self, from: data)
} catch {
throw ClaudeAdminAPIUsageError.parseFailed(endpoint: "cost_report", message: error.localizedDescription)
}
}
private static func decodeMessages(_ data: Data) throws -> MessagesUsageResponse {
do {
return try JSONDecoder().decode(MessagesUsageResponse.self, from: data)
} catch {
throw ClaudeAdminAPIUsageError.parseFailed(endpoint: "messages", message: error.localizedDescription)
}
}
private static func makeSnapshot(
costs: CostReportResponse,
messages: MessagesUsageResponse,
now: Date,
calendar: Calendar) -> ClaudeAdminAPIUsageSnapshot
{
var accumulators: [String: DailyAccumulator] = [:]
for bucket in costs.data {
var accumulator = accumulators[bucket.startingAt] ?? DailyAccumulator(
startingAt: bucket.startingAt,
endingAt: bucket.endingAt)
for result in bucket.results {
// Anthropic Usage & Cost API docs define `amount` as a decimal string in lowest USD units.
let value = Self.usdFromAnthropicLowestUnitAmount(result.amount)
accumulator.costUSD += value
let item = Self.displayName(result.description ?? result.costType, fallback: "Claude API")
accumulator.costItems[item, default: 0] += value
}
accumulators[bucket.startingAt] = accumulator
}
for bucket in messages.data {
var accumulator = accumulators[bucket.startingAt] ?? DailyAccumulator(
startingAt: bucket.startingAt,
endingAt: bucket.endingAt)
for result in bucket.results {
let input = result.uncachedInputTokens ?? 0
let cacheCreation = result.cacheCreation?.totalInputTokens ?? 0
let cacheRead = result.cacheReadInputTokens ?? 0
let output = result.outputTokens ?? 0
let total = input + cacheCreation + cacheRead + output
accumulator.inputTokens += input
accumulator.cacheCreationInputTokens += cacheCreation
accumulator.cacheReadInputTokens += cacheRead
accumulator.outputTokens += output
accumulator.totalTokens += total
let modelName = Self.displayName(result.model, fallback: "Claude API")
accumulator.models[modelName, default: ModelAccumulator()].add(
inputTokens: input,
cacheCreationInputTokens: cacheCreation,
cacheReadInputTokens: cacheRead,
outputTokens: output,
totalTokens: total)
}
accumulators[bucket.startingAt] = accumulator
}
let daily = accumulators.values
.compactMap { $0.makeBucket(calendar: calendar) }
.filter { $0.startTime <= now }
.sorted { $0.startTime < $1.startTime }
return ClaudeAdminAPIUsageSnapshot(daily: daily, updatedAt: now)
}
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 usdFromAnthropicLowestUnitAmount(_ raw: String) -> Double {
(Double(raw) ?? 0) / 100
}
private static func url(baseURL: URL, range: DateRange, queryItems extraItems: [URLQueryItem]) -> URL {
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "starting_at", value: Self.rfc3339String(from: range.start)),
URLQueryItem(name: "ending_at", value: Self.rfc3339String(from: range.end)),
URLQueryItem(name: "bucket_width", value: "1d"),
URLQueryItem(name: "limit", value: String(Self.maxDailyBuckets)),
] + extraItems
return components.url!
}
private static func dailyRange(now: Date, calendar: Calendar) -> DateRange {
let today = calendar.startOfDay(for: now)
let start = calendar.date(byAdding: .day, value: -(Self.maxDailyBuckets - 1), to: today) ?? today
let end = calendar.date(byAdding: .day, value: 1, to: today) ?? now
return DateRange(start: start, end: end)
}
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 static func rfc3339Formatter() -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter
}
private static func rfc3339String(from date: Date) -> String {
self.rfc3339Formatter().string(from: date)
}
fileprivate static func dayKey(from date: Date, calendar: Calendar) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
fileprivate static func parseDate(_ raw: String) -> Date? {
self.rfc3339Formatter().date(from: raw)
}
}
private struct DateRange {
let start: Date
let end: Date
}
private struct DailyAccumulator {
let startingAt: String
let endingAt: String
var costUSD: Double = 0
var inputTokens: Int = 0
var cacheCreationInputTokens: Int = 0
var cacheReadInputTokens: Int = 0
var outputTokens: Int = 0
var totalTokens: Int = 0
var costItems: [String: Double] = [:]
var models: [String: ModelAccumulator] = [:]
func makeBucket(calendar: Calendar) -> ClaudeAdminAPIUsageSnapshot.DailyBucket? {
guard let start = ClaudeAdminAPIUsageFetcher.parseDate(self.startingAt),
let end = ClaudeAdminAPIUsageFetcher.parseDate(self.endingAt)
else { return nil }
return ClaudeAdminAPIUsageSnapshot.DailyBucket(
day: ClaudeAdminAPIUsageFetcher.dayKey(from: start, calendar: calendar),
startTime: start,
endTime: end,
costUSD: self.costUSD,
inputTokens: self.inputTokens,
cacheCreationInputTokens: self.cacheCreationInputTokens,
cacheReadInputTokens: self.cacheReadInputTokens,
outputTokens: self.outputTokens,
totalTokens: self.totalTokens,
costItems: self.costItems
.map { ClaudeAdminAPIUsageSnapshot.CostBreakdown(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 { name, total in total.makeModel(name: name) }
.sorted {
if $0.totalTokens == $1.totalTokens { return $0.name < $1.name }
return $0.totalTokens > $1.totalTokens
})
}
}
private struct ModelAccumulator {
var inputTokens = 0
var cacheCreationInputTokens = 0
var cacheReadInputTokens = 0
var outputTokens = 0
var totalTokens = 0
mutating func add(
inputTokens: Int,
cacheCreationInputTokens: Int,
cacheReadInputTokens: Int,
outputTokens: Int,
totalTokens: Int)
{
self.inputTokens += inputTokens
self.cacheCreationInputTokens += cacheCreationInputTokens
self.cacheReadInputTokens += cacheReadInputTokens
self.outputTokens += outputTokens
self.totalTokens += totalTokens
}
func makeModel(name: String) -> ClaudeAdminAPIUsageSnapshot.ModelBreakdown {
ClaudeAdminAPIUsageSnapshot.ModelBreakdown(
name: name,
inputTokens: self.inputTokens,
cacheCreationInputTokens: self.cacheCreationInputTokens,
cacheReadInputTokens: self.cacheReadInputTokens,
outputTokens: self.outputTokens,
totalTokens: self.totalTokens)
}
}
private struct CostReportResponse: Decodable {
let data: [CostBucket]
let hasMore: Bool?
let nextPage: String?
private enum CodingKeys: String, CodingKey {
case data
case hasMore = "has_more"
case nextPage = "next_page"
}
}
private struct CostBucket: Decodable {
let startingAt: String
let endingAt: String
let results: [CostResult]
private enum CodingKeys: String, CodingKey {
case startingAt = "starting_at"
case endingAt = "ending_at"
case results
}
}
private struct CostResult: Decodable {
let currency: String?
let amount: String
let description: String?
let costType: String?
private enum CodingKeys: String, CodingKey {
case currency
case amount
case description
case costType = "cost_type"
}
}
private struct MessagesUsageResponse: Decodable {
let data: [MessagesBucket]
let hasMore: Bool?
let nextPage: String?
private enum CodingKeys: String, CodingKey {
case data
case hasMore = "has_more"
case nextPage = "next_page"
}
}
private struct MessagesBucket: Decodable {
let startingAt: String
let endingAt: String
let results: [MessagesResult]
private enum CodingKeys: String, CodingKey {
case startingAt = "starting_at"
case endingAt = "ending_at"
case results
}
}
private struct MessagesResult: Decodable {
let uncachedInputTokens: Int?
let cacheCreation: CacheCreation?
let cacheReadInputTokens: Int?
let outputTokens: Int?
let model: String?
private enum CodingKeys: String, CodingKey {
case uncachedInputTokens = "uncached_input_tokens"
case cacheCreation = "cache_creation"
case cacheReadInputTokens = "cache_read_input_tokens"
case outputTokens = "output_tokens"
case model
}
}
private struct CacheCreation: Decodable {
let ephemeral1HInputTokens: Int?
let ephemeral5MInputTokens: Int?
var totalInputTokens: Int {
(self.ephemeral1HInputTokens ?? 0) + (self.ephemeral5MInputTokens ?? 0)
}
private enum CodingKeys: String, CodingKey {
case ephemeral1HInputTokens = "ephemeral_1h_input_tokens"
case ephemeral5MInputTokens = "ephemeral_5m_input_tokens"
}
}