Files
2026-07-13 12:22:33 +08:00

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
}
}