import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public struct BedrockUsageSnapshot: Codable, Sendable { public let monthlySpend: Double public let monthlyBudget: Double? public let inputTokens: Int? public let outputTokens: Int? public let region: String public let updatedAt: Date public init( monthlySpend: Double, monthlyBudget: Double?, inputTokens: Int? = nil, outputTokens: Int? = nil, region: String, updatedAt: Date) { self.monthlySpend = monthlySpend self.monthlyBudget = monthlyBudget self.inputTokens = inputTokens self.outputTokens = outputTokens self.region = region self.updatedAt = updatedAt } public var budgetUsedPercent: Double? { guard let budget = self.monthlyBudget, budget > 0 else { return nil } return min(100, max(0, (self.monthlySpend / budget) * 100)) } public var totalTokens: Int? { guard let input = self.inputTokens, let output = self.outputTokens else { return nil } return input + output } } extension BedrockUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { let primary: RateWindow? = if let usedPercent = self.budgetUsedPercent { RateWindow( usedPercent: usedPercent, windowMinutes: nil, resetsAt: Self.endOfCurrentMonth(), resetDescription: "Monthly budget") } else { nil } let cost = ProviderCostSnapshot( used: self.monthlySpend, limit: self.monthlyBudget ?? 0, currencyCode: "USD", period: "Monthly", resetsAt: Self.endOfCurrentMonth(), updatedAt: self.updatedAt) var loginParts: [String] = [] loginParts.append(String(format: "Spend: $%.2f", self.monthlySpend)) if let budget = self.monthlyBudget { loginParts.append(String(format: "Budget: $%.2f", budget)) } if let total = self.totalTokens { loginParts.append("Tokens: \(Self.formattedTokenCount(total))") } let identity = ProviderIdentitySnapshot( providerID: .bedrock, accountEmail: nil, accountOrganization: nil, loginMethod: loginParts.joined(separator: " - ")) return UsageSnapshot( primary: primary, secondary: nil, tertiary: nil, providerCost: cost, updatedAt: self.updatedAt, identity: identity) } private static func endOfCurrentMonth() -> Date? { let calendar = Calendar.current guard let range = calendar.range(of: .day, in: .month, for: Date()) else { return nil } let components = calendar.dateComponents([.year, .month], from: Date()) guard let startOfMonth = calendar.date(from: components) else { return nil } return calendar.date(byAdding: .day, value: range.count, to: startOfMonth) } static func formattedTokenCount(_ count: Int) -> String { if count >= 1_000_000 { return String(format: "%.1fM", Double(count) / 1_000_000) } else if count >= 1000 { return String(format: "%.1fK", Double(count) / 1000) } return "\(count)" } } enum BedrockUsageFetcher { private static let log = CodexBarLog.logger(LogCategories.bedrockUsage) private static let requestTimeoutSeconds: TimeInterval = 15 private struct CostExplorerQuery { let startDate: String let endDate: String let granularity: String let nextPageToken: String? } static func fetchUsage( credentials: BedrockAWSSigner.Credentials, region: String, budget: Double?, environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> BedrockUsageSnapshot { let spend = try await Self.fetchMonthlyCost( credentials: credentials, environment: environment) return BedrockUsageSnapshot( monthlySpend: spend, monthlyBudget: budget, inputTokens: nil, outputTokens: nil, region: region, updatedAt: Date()) } static func fetchDailyReport( credentials: BedrockAWSSigner.Credentials, since: Date, until: Date, environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> CostUsageDailyReport { let formatter = Self.dateFormatter() let startDate = formatter.string(from: since) let inclusiveEnd = Self.utcCalendar().date(byAdding: .day, value: 1, to: until) ?? until let endDate = formatter.string(from: inclusiveEnd) let pages = try await Self.callCostExplorerPages( startDate: startDate, endDate: endDate, granularity: "DAILY", credentials: credentials, environment: environment) let entries = try Self.parseDailyResponses(pages) return CostUsageDailyReport(data: entries, summary: nil) } private static func fetchMonthlyCost( credentials: BedrockAWSSigner.Credentials, environment: [String: String]) async throws -> Double { let (startDate, endDate) = Self.currentMonthRange() let pages = try await Self.callCostExplorerPages( startDate: startDate, endDate: endDate, granularity: "MONTHLY", credentials: credentials, environment: environment) return try Self.parseTotalCost(pages) } private static func callCostExplorerPages( startDate: String, endDate: String, granularity: String, credentials: BedrockAWSSigner.Credentials, environment: [String: String]) async throws -> [Data] { var pages: [Data] = [] var nextPageToken: String? var seenPageTokens: Set = [] repeat { let page = try await Self.callCostExplorerPage( query: CostExplorerQuery( startDate: startDate, endDate: endDate, granularity: granularity, nextPageToken: nextPageToken), credentials: credentials, environment: environment) pages.append(page) nextPageToken = try Self.nextPageToken(from: page) if let nextPageToken, !seenPageTokens.insert(nextPageToken).inserted { throw BedrockUsageError.parseFailed("Cost Explorer returned repeated NextPageToken") } } while nextPageToken != nil return pages } private static func callCostExplorerPage( query: CostExplorerQuery, credentials: BedrockAWSSigner.Credentials, environment: [String: String]) async throws -> Data { let ceRegion = "us-east-1" let baseURL: URL = if let override = environment[BedrockSettingsReader.apiURLKey], let url = URL(string: BedrockSettingsReader.cleaned(override) ?? "") { url } else { URL(string: "https://ce.\(ceRegion).amazonaws.com")! } var requestBody: [String: Any] = [ "TimePeriod": [ "Start": query.startDate, "End": query.endDate, ], "Granularity": query.granularity, "Metrics": ["UnblendedCost"], "GroupBy": [ ["Type": "DIMENSION", "Key": "SERVICE"], ], ] if let nextPageToken = query.nextPageToken { requestBody["NextPageToken"] = nextPageToken } let bodyData = try JSONSerialization.data(withJSONObject: requestBody) var request = URLRequest(url: baseURL) request.httpMethod = "POST" request.httpBody = bodyData request.setValue("application/x-amz-json-1.1", forHTTPHeaderField: "Content-Type") request.setValue( "AWSInsightsIndexService.GetCostAndUsage", forHTTPHeaderField: "X-Amz-Target") request.timeoutInterval = Self.requestTimeoutSeconds BedrockAWSSigner.sign( request: &request, credentials: credentials, region: ceRegion, service: "ce") let response = try await ProviderHTTPClient.shared.response(for: request) guard response.statusCode == 200 else { if Self.isDataUnavailableResponse(statusCode: response.statusCode, data: response.data) { Self.log.info("AWS Cost Explorer data unavailable, assuming zero usage.") return Data(#"{"ResultsByTime":[]}"#.utf8) } let summary = Self.sanitizedResponseBody(response.data) Self.log.error("AWS Cost Explorer returned \(response.statusCode): \(summary)") throw BedrockUsageError.apiError("HTTP \(response.statusCode)") } return response.data } private static func nextPageToken(from data: Data) throws -> String? { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw BedrockUsageError.parseFailed("Invalid Cost Explorer response") } return BedrockSettingsReader.cleaned(json["NextPageToken"] as? String) } private static func parseTotalCost(_ pages: [Data]) throws -> Double { var total = 0.0 for page in pages { total += try Self.parseTotalCost(page) } return total } private static func parseTotalCost(_ data: Data) throws -> Double { var total = 0.0 for (_, cost, _) in try Self.parseGroupedResults(data) { total += cost } return total } private static func parseDailyResponses(_ pages: [Data]) throws -> [CostUsageDailyReport.Entry] { let reports = try pages.map { page in try CostUsageDailyReport(data: Self.parseDailyResponse(page), summary: nil) } return CostUsageDailyReport.merged(reports).data } private static func parseDailyResponse(_ data: Data) throws -> [CostUsageDailyReport.Entry] { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let results = json["ResultsByTime"] as? [[String: Any]] else { throw BedrockUsageError.parseFailed("Missing ResultsByTime in Cost Explorer response") } var entries: [CostUsageDailyReport.Entry] = [] for result in results { guard let timePeriod = result["TimePeriod"] as? [String: String], let dateStr = timePeriod["Start"] else { continue } var dayCost = 0.0 var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] if let groups = result["Groups"] as? [[String: Any]] { for group in groups { guard let keys = group["Keys"] as? [String], let serviceName = keys.first, serviceName.localizedCaseInsensitiveContains("Bedrock") else { continue } if let metrics = group["Metrics"] as? [String: Any], let unblended = metrics["UnblendedCost"] as? [String: Any], let amountStr = unblended["Amount"] as? String, let amount = Double(amountStr), amount > 0 { dayCost += amount breakdowns.append(CostUsageDailyReport.ModelBreakdown( modelName: serviceName, costUSD: amount)) } } } guard dayCost > 0 else { continue } entries.append(CostUsageDailyReport.Entry( date: dateStr, inputTokens: nil, outputTokens: nil, totalTokens: nil, costUSD: dayCost, modelsUsed: breakdowns.map(\.modelName), modelBreakdowns: breakdowns.isEmpty ? nil : breakdowns)) } return entries } private static func parseGroupedResults(_ data: Data) throws -> [(service: String, cost: Double, date: String)] { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let results = json["ResultsByTime"] as? [[String: Any]] else { throw BedrockUsageError.parseFailed("Missing ResultsByTime in Cost Explorer response") } var items: [(service: String, cost: Double, date: String)] = [] for result in results { let dateStr = (result["TimePeriod"] as? [String: String])?["Start"] ?? "" guard let groups = result["Groups"] as? [[String: Any]] else { continue } for group in groups { guard let keys = group["Keys"] as? [String], let serviceName = keys.first, serviceName.localizedCaseInsensitiveContains("Bedrock") else { continue } if let metrics = group["Metrics"] as? [String: Any], let unblended = metrics["UnblendedCost"] as? [String: Any], let amountStr = unblended["Amount"] as? String, let amount = Double(amountStr) { items.append((serviceName, amount, dateStr)) } } } return items } private static func dateFormatter() -> DateFormatter { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" formatter.timeZone = TimeZone(identifier: "UTC") formatter.locale = Locale(identifier: "en_US_POSIX") return formatter } private static func utcCalendar() -> Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! return calendar } static func currentMonthRange(now: Date = Date()) -> (start: String, end: String) { let calendar = Self.utcCalendar() let components = calendar.dateComponents([.year, .month], from: now) let startOfMonth = calendar.date(from: components)! let formatter = Self.dateFormatter() let startOfToday = calendar.startOfDay(for: now) let tomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday)! return (formatter.string(from: startOfMonth), formatter.string(from: tomorrow)) } private static func isDataUnavailableResponse(statusCode: Int, data: Data) -> Bool { guard statusCode == 400, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } let nestedError = json["Error"] as? [String: Any] let candidates = [ json["__type"], json["code"], json["Code"], nestedError?["Code"], ] return candidates.compactMap { $0 as? String }.contains { rawCode in rawCode.split(separator: "#").last == "DataUnavailableException" } } private static func sanitizedResponseBody(_ data: Data) -> String { guard !data.isEmpty, let body = String(bytes: data, encoding: .utf8) else { return "empty body" } let trimmed = body.replacingOccurrences( of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.count > 240 { let index = trimmed.index(trimmed.startIndex, offsetBy: 240) return "\(trimmed[..