433 lines
16 KiB
Swift
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"
|
|
}
|
|
}
|