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

236 lines
8.1 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public enum GroqUsageError: LocalizedError, Sendable {
case missingCredentials
case invalidURL
case accessDenied(String)
case apiError(String)
case parseFailed(String)
public var errorDescription: String? {
switch self {
case .missingCredentials:
"Missing Groq API key. Set apiKey in ~/.codexbar/config.json or GROQ_API_KEY."
case .invalidURL:
"Groq metrics URL is invalid."
case let .accessDenied(message):
"Groq metrics access denied: \(message)"
case let .apiError(message):
"Groq metrics API error: \(message)"
case let .parseFailed(message):
"Groq metrics parse error: \(message)"
}
}
}
public struct GroqUsageSnapshot: Codable, Sendable, Equatable {
public let requestRatePerSecond: Double
public let inputTokenRatePerSecond: Double
public let outputTokenRatePerSecond: Double
public let promptCacheHitRatePerSecond: Double
public let updatedAt: Date
public init(
requestRatePerSecond: Double,
inputTokenRatePerSecond: Double,
outputTokenRatePerSecond: Double,
promptCacheHitRatePerSecond: Double = 0,
updatedAt: Date)
{
self.requestRatePerSecond = requestRatePerSecond
self.inputTokenRatePerSecond = inputTokenRatePerSecond
self.outputTokenRatePerSecond = outputTokenRatePerSecond
self.promptCacheHitRatePerSecond = promptCacheHitRatePerSecond
self.updatedAt = updatedAt
}
public var requestsPerMinute: Double {
self.requestRatePerSecond * 60
}
public var tokensPerMinute: Double {
(self.inputTokenRatePerSecond + self.outputTokenRatePerSecond) * 60
}
public var cacheHitsPerMinute: Double {
self.promptCacheHitRatePerSecond * 60
}
public func toUsageSnapshot() -> UsageSnapshot {
UsageSnapshot(
primary: RateWindow(
usedPercent: 0,
windowMinutes: 5,
resetsAt: nil,
resetDescription: "\(Self.formatDecimal(self.requestsPerMinute)) req/min"),
secondary: RateWindow(
usedPercent: 0,
windowMinutes: 5,
resetsAt: nil,
resetDescription: "\(Self.formatDecimal(self.tokensPerMinute)) tok/min"),
tertiary: self.promptCacheHitRatePerSecond > 0 ? RateWindow(
usedPercent: 0,
windowMinutes: 5,
resetsAt: nil,
resetDescription: "\(Self.formatDecimal(self.cacheHitsPerMinute)) cache/min") : nil,
updatedAt: self.updatedAt,
identity: ProviderIdentitySnapshot(
providerID: .groq,
accountEmail: nil,
accountOrganization: nil,
loginMethod: "Prometheus metrics"))
}
static func formatDecimal(_ value: Double) -> String {
if value >= 100 {
return String(format: "%.0f", value)
}
if value >= 10 {
return String(format: "%.1f", value)
}
return String(format: "%.2f", value)
}
}
private struct GroqPrometheusResponse: Decodable {
struct Payload: Decodable {
let result: [Series]
}
struct Series: Decodable {
let value: [PrometheusValue]?
}
enum PrometheusValue: Decodable {
case number(Double)
case string(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let number = try? container.decode(Double.self) {
self = .number(number)
return
}
self = try .string(container.decode(String.self))
}
var doubleValue: Double? {
switch self {
case let .number(number):
number
case let .string(text):
Double(text)
}
}
}
let status: String
let data: Payload?
let error: String?
}
public struct GroqUsageFetcher: Sendable {
public init() {}
public static func fetchUsage(
apiKey: String,
environment: [String: String] = ProcessInfo.processInfo.environment,
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
updatedAt: Date = Date()) async throws -> GroqUsageSnapshot
{
guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw GroqUsageError.missingCredentials
}
try GroqSettingsReader.validateEndpointOverrides(environment: environment)
let baseURL = GroqSettingsReader.apiURL(environment: environment)
.appendingPathComponent("metrics")
.appendingPathComponent("prometheus")
async let requests = Self.queryScalar(
query: "sum(model_project_id_status_code:requests:rate5m)",
apiKey: apiKey,
baseURL: baseURL,
transport: transport)
async let inputTokens = Self.queryScalar(
query: "sum(model_project_id:tokens_in:rate5m)",
apiKey: apiKey,
baseURL: baseURL,
transport: transport)
async let outputTokens = Self.queryScalar(
query: "sum(model_project_id:tokens_out:rate5m)",
apiKey: apiKey,
baseURL: baseURL,
transport: transport)
async let cacheHits = Self.queryScalar(
query: "sum(model_project_id:prompt_cache_hits:rate5m)",
apiKey: apiKey,
baseURL: baseURL,
transport: transport)
return try await GroqUsageSnapshot(
requestRatePerSecond: requests,
inputTokenRatePerSecond: inputTokens,
outputTokenRatePerSecond: outputTokens,
promptCacheHitRatePerSecond: cacheHits,
updatedAt: updatedAt)
}
public static func _parseScalarForTesting(_ data: Data) throws -> Double {
try self.parseScalar(data: data)
}
private static func queryScalar(
query: String,
apiKey: String,
baseURL: URL,
transport: any ProviderHTTPTransport) async throws -> Double
{
guard var components = URLComponents(
url: baseURL.appendingPathComponent("api/v1/query"),
resolvingAgainstBaseURL: false)
else { throw GroqUsageError.invalidURL }
components.queryItems = [URLQueryItem(name: "query", value: query)]
guard let url = components.url else { throw GroqUsageError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
let response = try await transport.response(for: request)
guard (200..<300).contains(response.statusCode) else {
let summary = Self.responseSummary(response.data)
if response.statusCode == 401 || response.statusCode == 403 {
throw GroqUsageError.accessDenied(summary)
}
throw GroqUsageError.apiError("HTTP \(response.statusCode): \(summary)")
}
return try self.parseScalar(data: response.data)
}
private static func parseScalar(data: Data) throws -> Double {
do {
let decoded = try JSONDecoder().decode(GroqPrometheusResponse.self, from: data)
guard decoded.status == "success" else {
throw GroqUsageError.apiError(decoded.error ?? "query failed")
}
return decoded.data?.result.compactMap { series in
series.value?.last?.doubleValue
}.reduce(0, +) ?? 0
} catch let error as GroqUsageError {
throw error
} catch {
throw GroqUsageError.parseFailed(error.localizedDescription)
}
}
private static func responseSummary(_ data: Data) -> String {
String(bytes: data.prefix(500), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
?? ""
}
}