import Foundation /// The quota/provider state changes CodexBar can run external commands for. /// /// Raw values are the stable event names used in config, env vars, and the JSON /// payload. `cost_threshold_reached` is intentionally not part of v1. public enum HookEventType: String, Codable, Sendable, CaseIterable { case quotaLow = "quota_low" case quotaReached = "quota_reached" case quotaReset = "quota_reset" case providerUnavailable = "provider_unavailable" case providerRecovered = "provider_recovered" case refreshFailed = "refresh_failed" /// Events that can repeat on every refresh while a condition persists, so they /// get the rate-limiter backstop. Quota events dedupe upstream and must not be /// throttled here, or a lower remaining-quota warning crossing within the /// window would be dropped. var isRateLimited: Bool { switch self { case .providerUnavailable, .refreshFailed: true case .quotaLow, .quotaReached, .quotaReset, .providerRecovered: false } } } /// A single quota/provider event, with the metadata handed to an external hook /// command via environment variables and a JSON stdin payload. /// /// `usagePercent` is a 0...1 fraction (0.92 == 92% used) to match the spec. It /// carries only non-secret observability data; `account` is already redacted by /// the caller when the user hides personal info. public struct HookEvent: Codable, Sendable, Equatable { public let event: HookEventType public let provider: String public let account: String? public let window: String? public let usagePercent: Double? public let used: Double? public let limit: Double? public let resetAt: Date? public let status: String? public let timestamp: Date public init( event: HookEventType, provider: String, account: String? = nil, window: String? = nil, usagePercent: Double? = nil, used: Double? = nil, limit: Double? = nil, resetAt: Date? = nil, status: String? = nil, timestamp: Date) { self.event = event self.provider = provider self.account = account self.window = window self.usagePercent = usagePercent self.used = used self.limit = limit self.resetAt = resetAt self.status = status self.timestamp = timestamp } /// Environment variables passed to the hook command. Nil fields are omitted so /// a script can distinguish "absent" from "zero". public func environmentVariables() -> [String: String] { var env: [String: String] = [ "CODEXBAR_EVENT": self.event.rawValue, "CODEXBAR_PROVIDER": self.provider, "CODEXBAR_TIMESTAMP": Self.iso8601String(self.timestamp), ] if let account { env["CODEXBAR_ACCOUNT"] = account } if let window { env["CODEXBAR_WINDOW"] = window } if let usagePercent { env["CODEXBAR_USAGE_PERCENT"] = Self.number(usagePercent) } if let used { env["CODEXBAR_USED"] = Self.number(used) } if let limit { env["CODEXBAR_LIMIT"] = Self.number(limit) } if let resetAt { env["CODEXBAR_RESET_AT"] = Self.iso8601String(resetAt) } if let status { env["CODEXBAR_STATUS"] = status } return env } /// The JSON written to the hook command's stdin. public func jsonPayload() throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] encoder.dateEncodingStrategy = .iso8601 return try encoder.encode(self) } private static func iso8601String(_ date: Date) -> String { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] return formatter.string(from: date) } private static func number(_ value: Double) -> String { // Trim a trailing ".0" so integers read cleanly, keep fractions intact. if value == value.rounded(), abs(value) < 1e15 { return String(Int64(value)) } return String(value) } }