chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user