// // UsageMeter.swift // Zyquo Router // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Tokens + estimated cost per request, aggregated per model/provider/key. // Phase 2 records in memory; persistence and dashboard aggregation land with // the observability work. // import Foundation /// One completed (or failed) routed request. struct UsageRecord: Codable, Identifiable, Sendable { let id: UUID var date: Date var namespacedModelID: String var provider: ProviderID var localKeyName: String? var usage: TokenUsage var usageEstimated: Bool var estimatedCost: Double? var latency: TimeInterval var streamed: Bool var status: Int init( id: UUID = UUID(), date: Date = Date(), namespacedModelID: String, provider: ProviderID, localKeyName: String? = nil, usage: TokenUsage = TokenUsage(), usageEstimated: Bool = false, estimatedCost: Double? = nil, latency: TimeInterval = 0, streamed: Bool = false, status: Int = 200 ) { self.id = id self.date = date self.namespacedModelID = namespacedModelID self.provider = provider self.localKeyName = localKeyName self.usage = usage self.usageEstimated = usageEstimated self.estimatedCost = estimatedCost self.latency = latency self.streamed = streamed self.status = status } } actor UsageMeter { private(set) var records: [UsageRecord] = [] /// Streams currently being forwarded to clients (dashboard tile). private(set) var activeStreams = 0 func record(_ entry: UsageRecord) { records.append(entry) } func streamBegan() { activeStreams += 1 } func streamEnded() { activeStreams = max(0, activeStreams - 1) } /// Requests per minute over the trailing window (sparkline buckets). func requestsPerMinute(minutes: Int) -> [Int] { let now = Date() var buckets = [Int](repeating: 0, count: minutes) for record in records { let age = now.timeIntervalSince(record.date) guard age >= 0, age < Double(minutes * 60) else { continue } buckets[minutes - 1 - Int(age / 60)] += 1 } return buckets } /// Share of today's requests per provider (breakdown bar). func providerBreakdown(since cutoff: Date) -> [(provider: ProviderID, count: Int)] { var counts: [ProviderID: Int] = [:] for record in records where record.date >= cutoff { counts[record.provider, default: 0] += 1 } return counts.sorted { $0.value > $1.value }.map { ($0.key, $0.value) } } /// Totals since a cutoff (today's tiles on the dashboard). func totals(since cutoff: Date) -> (requests: Int, usage: TokenUsage, cost: Double, errors: Int) { var usage = TokenUsage() var cost = 0.0 var requests = 0 var errors = 0 for record in records where record.date >= cutoff { requests += 1 usage = usage + record.usage cost += record.estimatedCost ?? 0 if record.status >= 400 { errors += 1 } } return (requests, usage, cost, errors) } }