256 lines
9.3 KiB
Swift
256 lines
9.3 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
public enum ClawRouterUsageError: LocalizedError, Equatable, Sendable {
|
|
case missingCredentials
|
|
case invalidCredentials
|
|
case apiError(Int)
|
|
case parseFailed(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .missingCredentials:
|
|
"Missing ClawRouter API key. Add one in Settings or set CLAWROUTER_API_KEY."
|
|
case .invalidCredentials:
|
|
"ClawRouter rejected the API key. Check the key and its policy status."
|
|
case let .apiError(statusCode):
|
|
"ClawRouter API returned HTTP \(statusCode)."
|
|
case let .parseFailed(message):
|
|
"Could not parse ClawRouter usage: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct ClawRouterUsageSnapshot: Codable, Sendable, Equatable {
|
|
public struct ProviderSummary: Codable, Sendable, Equatable {
|
|
public let provider: String
|
|
public let requestCount: Int
|
|
public let successCount: Int
|
|
public let errorCount: Int
|
|
public let totalTokens: Int
|
|
public let actualCostUSD: Double
|
|
}
|
|
|
|
public let budgetConfigured: Bool
|
|
public let budgetLedger: String
|
|
public let budgetLimitUSD: Double?
|
|
public let budgetSpentUSD: Double?
|
|
public let budgetRemainingUSD: Double?
|
|
public let budgetResetsAt: Date?
|
|
public let requestCount: Int
|
|
public let successCount: Int
|
|
public let errorCount: Int
|
|
public let inputTokens: Int
|
|
public let outputTokens: Int
|
|
public let totalTokens: Int
|
|
public let actualCostUSD: Double
|
|
public let providers: [ProviderSummary]
|
|
public let updatedAt: Date
|
|
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
let usedPercent: Double? = if let spent = self.budgetSpentUSD,
|
|
let limit = self.budgetLimitUSD,
|
|
limit > 0
|
|
{
|
|
min(100, max(0, spent / limit * 100))
|
|
} else {
|
|
nil
|
|
}
|
|
let providerCost: ProviderCostSnapshot? = if let spent = self.budgetSpentUSD,
|
|
let limit = self.budgetLimitUSD
|
|
{
|
|
ProviderCostSnapshot(
|
|
used: spent,
|
|
limit: limit,
|
|
currencyCode: "USD",
|
|
period: "This month",
|
|
resetsAt: self.budgetResetsAt,
|
|
updatedAt: self.updatedAt)
|
|
} else if self.actualCostUSD > 0 {
|
|
ProviderCostSnapshot(
|
|
used: self.actualCostUSD,
|
|
limit: 0,
|
|
currencyCode: "USD",
|
|
period: "This month",
|
|
resetsAt: self.budgetResetsAt,
|
|
updatedAt: self.updatedAt)
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
return UsageSnapshot(
|
|
primary: usedPercent.map {
|
|
RateWindow(
|
|
usedPercent: $0,
|
|
windowMinutes: nil,
|
|
resetsAt: self.budgetResetsAt,
|
|
resetDescription: nil)
|
|
},
|
|
secondary: nil,
|
|
providerCost: providerCost,
|
|
clawRouterUsage: self,
|
|
updatedAt: self.updatedAt,
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .clawrouter,
|
|
accountEmail: nil,
|
|
accountOrganization: "\(self.providers.count) routed providers",
|
|
loginMethod: self.budgetConfigured ? "Managed monthly budget" : "Unmetered"),
|
|
dataConfidence: .exact)
|
|
}
|
|
}
|
|
|
|
private struct ClawRouterUsageResponse: Decodable {
|
|
struct Budget: Decodable {
|
|
let configured: Bool
|
|
let ledger: String
|
|
let windowKey: String?
|
|
let limitMicros: Int64?
|
|
let spentMicros: Int64?
|
|
let remainingMicros: Int64?
|
|
}
|
|
|
|
struct Usage: Decodable {
|
|
struct Summary: Decodable {
|
|
let requestCount: Int
|
|
let successCount: Int
|
|
let errorCount: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let totalTokens: Int
|
|
let actualCostMicros: Int64
|
|
}
|
|
|
|
struct Provider: Decodable {
|
|
let provider: String
|
|
let requestCount: Int
|
|
let successCount: Int
|
|
let errorCount: Int
|
|
let totalTokens: Int
|
|
let actualCostMicros: Int64
|
|
}
|
|
|
|
let summary: Summary
|
|
let providers: [Provider]
|
|
}
|
|
|
|
let budget: Budget
|
|
let usage: Usage
|
|
}
|
|
|
|
public enum ClawRouterUsageFetcher {
|
|
public static func fetchUsage(
|
|
apiKey: String,
|
|
baseURL: URL,
|
|
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
|
|
updatedAt: Date = Date()) async throws -> ClawRouterUsageSnapshot
|
|
{
|
|
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
throw ClawRouterUsageError.missingCredentials
|
|
}
|
|
var request = URLRequest(url: self.usageURL(baseURL: baseURL))
|
|
request.httpMethod = "GET"
|
|
request.timeoutInterval = 15
|
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let response = try await transport.response(for: request)
|
|
if response.statusCode == 401 || response.statusCode == 403 {
|
|
throw ClawRouterUsageError.invalidCredentials
|
|
}
|
|
guard (200..<300).contains(response.statusCode) else {
|
|
throw ClawRouterUsageError.apiError(response.statusCode)
|
|
}
|
|
return try self.parseSnapshot(data: response.data, updatedAt: updatedAt)
|
|
}
|
|
|
|
public static func _parseSnapshotForTesting(
|
|
_ data: Data,
|
|
updatedAt: Date) throws -> ClawRouterUsageSnapshot
|
|
{
|
|
try self.parseSnapshot(data: data, updatedAt: updatedAt)
|
|
}
|
|
|
|
public static func _usageURLForTesting(baseURL: URL) -> URL {
|
|
self.usageURL(baseURL: baseURL)
|
|
}
|
|
|
|
private static func usageURL(baseURL: URL) -> URL {
|
|
let path = baseURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
let versionedBaseURL = path.split(separator: "/").last == "v1"
|
|
? baseURL
|
|
: baseURL.appendingPathComponent("v1")
|
|
return versionedBaseURL.appendingPathComponent("usage")
|
|
}
|
|
|
|
private static func parseSnapshot(data: Data, updatedAt: Date) throws -> ClawRouterUsageSnapshot {
|
|
do {
|
|
let response = try JSONDecoder().decode(ClawRouterUsageResponse.self, from: data)
|
|
return ClawRouterUsageSnapshot(
|
|
budgetConfigured: response.budget.configured,
|
|
budgetLedger: response.budget.ledger,
|
|
budgetLimitUSD: self.dollars(response.budget.limitMicros),
|
|
budgetSpentUSD: self.dollars(response.budget.spentMicros),
|
|
budgetRemainingUSD: self.dollars(response.budget.remainingMicros),
|
|
budgetResetsAt: self.nextMonthlyReset(windowKey: response.budget.windowKey),
|
|
requestCount: response.usage.summary.requestCount,
|
|
successCount: response.usage.summary.successCount,
|
|
errorCount: response.usage.summary.errorCount,
|
|
inputTokens: response.usage.summary.inputTokens,
|
|
outputTokens: response.usage.summary.outputTokens,
|
|
totalTokens: response.usage.summary.totalTokens,
|
|
actualCostUSD: self.dollars(response.usage.summary.actualCostMicros),
|
|
providers: response.usage.providers.map {
|
|
ClawRouterUsageSnapshot.ProviderSummary(
|
|
provider: $0.provider,
|
|
requestCount: $0.requestCount,
|
|
successCount: $0.successCount,
|
|
errorCount: $0.errorCount,
|
|
totalTokens: $0.totalTokens,
|
|
actualCostUSD: self.dollars($0.actualCostMicros))
|
|
}.sorted {
|
|
if $0.actualCostUSD != $1.actualCostUSD { return $0.actualCostUSD > $1.actualCostUSD }
|
|
if $0.requestCount != $1.requestCount { return $0.requestCount > $1.requestCount }
|
|
return $0.provider < $1.provider
|
|
},
|
|
updatedAt: updatedAt)
|
|
} catch let error as ClawRouterUsageError {
|
|
throw error
|
|
} catch {
|
|
throw ClawRouterUsageError.parseFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private static func dollars(_ micros: Int64?) -> Double? {
|
|
micros.map(self.dollars)
|
|
}
|
|
|
|
private static func dollars(_ micros: Int64) -> Double {
|
|
Double(micros) / 1_000_000
|
|
}
|
|
|
|
private static func nextMonthlyReset(windowKey: String?) -> Date? {
|
|
guard let suffix = windowKey?.split(separator: "/").last else { return nil }
|
|
let components = suffix.split(separator: "-")
|
|
guard components.count == 2,
|
|
let year = Int(components[0]),
|
|
let month = Int(components[1]),
|
|
(1...12).contains(month)
|
|
else { return nil }
|
|
|
|
var nextYear = year
|
|
var nextMonth = month + 1
|
|
if nextMonth == 13 {
|
|
nextMonth = 1
|
|
nextYear += 1
|
|
}
|
|
return DateComponents(
|
|
calendar: Calendar(identifier: .gregorian),
|
|
timeZone: TimeZone(secondsFromGMT: 0),
|
|
year: nextYear,
|
|
month: nextMonth,
|
|
day: 1).date
|
|
}
|
|
}
|