From 4cfa6f54c615b4037a8f8618a2fae335ae1fb5c2 Mon Sep 17 00:00:00 2001 From: Alexander Falk Date: Sun, 5 Apr 2026 19:36:37 -0400 Subject: [PATCH 1/5] Initial implementation of new AWS Bedrock provider --- .gitignore | 3 + .../BedrockProviderImplementation.swift | 77 +++++ .../Bedrock/BedrockSettingsStore.swift | 37 ++ .../ProviderImplementationRegistry.swift | 1 + Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../Config/ProviderConfigEnvironment.swift | 2 + .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Providers/Bedrock/BedrockAWSSigner.swift | 178 ++++++++++ .../Bedrock/BedrockProviderDescriptor.swift | 79 +++++ .../Bedrock/BedrockSettingsReader.swift | 85 +++++ .../Providers/Bedrock/BedrockUsageStats.swift | 282 +++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderTokenResolver.swift | 12 + .../CodexBarCore/Providers/Providers.swift | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 3 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../BedrockUsageStatsTests.swift | 322 ++++++++++++++++++ 19 files changed, 1090 insertions(+), 3 deletions(-) create mode 100644 Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift create mode 100644 Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift create mode 100644 Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift create mode 100644 Tests/CodexBarTests/BedrockUsageStatsTests.swift diff --git a/.gitignore b/.gitignore index d44f986f6..115b4d1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ docs/.astro/ # Swift Package Manager metadata (leave sources tracked) # Packages/ # Package.resolved + +# Claude +.claude \ No newline at end of file diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift new file mode 100644 index 000000000..8d16f0cd1 --- /dev/null +++ b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift @@ -0,0 +1,77 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct BedrockProviderImplementation: ProviderImplementation { + let id: UsageProvider = .bedrock + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.bedrockAccessKeyID + _ = settings.bedrockSecretAccessKey + _ = settings.bedrockRegion + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return nil + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if BedrockSettingsReader.hasCredentials(environment: context.environment) { + return true + } + return !context.settings.bedrockAccessKeyID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsPickers(context _: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "bedrock-access-key-id", + title: "Access key ID", + subtitle: "AWS access key ID. Can also be set via AWS_ACCESS_KEY_ID environment variable.", + kind: .secure, + placeholder: "AKIA...", + binding: context.stringBinding(\.bedrockAccessKeyID), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "bedrock-secret-access-key", + title: "Secret access key", + subtitle: "AWS secret access key. Can also be set via AWS_SECRET_ACCESS_KEY environment variable.", + kind: .secure, + placeholder: "", + binding: context.stringBinding(\.bedrockSecretAccessKey), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "bedrock-region", + title: "Region", + subtitle: "AWS region (e.g. us-east-1). Can also be set via AWS_REGION environment variable.", + kind: .plain, + placeholder: "us-east-1", + binding: context.stringBinding(\.bedrockRegion), + actions: [], + isVisible: nil, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift b/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift new file mode 100644 index 000000000..dbf2c80c6 --- /dev/null +++ b/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift @@ -0,0 +1,37 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var bedrockAccessKeyID: String { + get { self.configSnapshot.providerConfig(for: .bedrock)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .bedrock) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .bedrock, field: "apiKey", value: newValue) + } + } + + var bedrockSecretAccessKey: String { + get { + let raw = self.configSnapshot.providerConfig(for: .bedrock)?.sanitizedCookieHeader ?? "" + return raw + } + set { + self.updateProviderConfig(provider: .bedrock) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .bedrock, field: "secretAccessKey", value: newValue) + } + } + + var bedrockRegion: String { + get { self.configSnapshot.providerConfig(for: .bedrock)?.region ?? "" } + set { + self.updateProviderConfig(provider: .bedrock) { entry in + entry.region = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .bedrock, field: "region", value: newValue) + } + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 6fb94b479..041828ce7 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -37,6 +37,7 @@ enum ProviderImplementationRegistry { case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() case .perplexity: PerplexityProviderImplementation() + case .bedrock: BedrockProviderImplementation() } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5a4e13a61..f5ca00a26 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -825,7 +825,7 @@ extension UsageStore { let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains, .perplexity: + .kimik2, .jetbrains, .perplexity, .bedrock: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index ee7ab005f..eb2b42dfe 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -178,7 +178,7 @@ struct TokenAccountCLIContext { perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: + case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, .bedrock: return nil } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 6620ae879..4ab725858 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -31,6 +31,8 @@ public enum ProviderConfigEnvironment { } case .openrouter: env[OpenRouterSettingsReader.envKey] = apiKey + case .bedrock: + env[BedrockSettingsReader.apiKeyEnvKey] = apiKey default: break } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 0f2a6b0f9..290f6d24b 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -1,5 +1,6 @@ public enum LogCategories { public static let amp = "amp" + public static let bedrockUsage = "bedrock-usage" public static let antigravity = "antigravity" public static let app = "app" public static let auggieCLI = "auggie-cli" diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift new file mode 100644 index 000000000..60775ae09 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift @@ -0,0 +1,178 @@ +import CryptoKit +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Lightweight AWS Signature Version 4 request signer for Bedrock-related AWS API calls. +enum BedrockAWSSigner { + struct Credentials: Sendable { + let accessKeyID: String + let secretAccessKey: String + let sessionToken: String? + } + + /// Signs a `URLRequest` using AWS Signature Version 4. + static func sign( + request: inout URLRequest, + credentials: Credentials, + region: String, + service: String, + date: Date = Date()) + { + let dateFormatter = Self.dateFormatter() + let dateStamp = Self.dateStamp(date: date) + let amzDate = dateFormatter.string(from: date) + + request.setValue(amzDate, forHTTPHeaderField: "X-Amz-Date") + if let sessionToken = credentials.sessionToken { + request.setValue(sessionToken, forHTTPHeaderField: "X-Amz-Security-Token") + } + + let host = request.url?.host ?? "" + request.setValue(host, forHTTPHeaderField: "Host") + + let bodyHash = Self.sha256Hex(request.httpBody ?? Data()) + request.setValue(bodyHash, forHTTPHeaderField: "x-amz-content-sha256") + + let signedHeaders = Self.signedHeaders(request: request) + let canonicalRequest = Self.canonicalRequest( + request: request, + signedHeaders: signedHeaders, + bodyHash: bodyHash) + + let credentialScope = "\(dateStamp)/\(region)/\(service)/aws4_request" + let stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + Self.sha256Hex(Data(canonicalRequest.utf8)), + ].joined(separator: "\n") + + let signature = Self.calculateSignature( + secretKey: credentials.secretAccessKey, + dateStamp: dateStamp, + region: region, + service: service, + stringToSign: stringToSign) + + let authorization = "AWS4-HMAC-SHA256 " + + "Credential=\(credentials.accessKeyID)/\(credentialScope), " + + "SignedHeaders=\(signedHeaders.keys), " + + "Signature=\(signature)" + + request.setValue(authorization, forHTTPHeaderField: "Authorization") + } + + // MARK: - Private helpers + + private struct SignedHeadersInfo { + let keys: String + let canonical: String + } + + private static func signedHeaders(request: URLRequest) -> SignedHeadersInfo { + var headers: [(String, String)] = [] + if let allHeaders = request.allHTTPHeaderFields { + for (key, value) in allHeaders { + headers.append((key.lowercased(), value.trimmingCharacters(in: .whitespaces))) + } + } + headers.sort { $0.0 < $1.0 } + + let keys = headers.map(\.0).joined(separator: ";") + let canonical = headers.map { "\($0.0):\($0.1)" }.joined(separator: "\n") + return SignedHeadersInfo(keys: keys, canonical: canonical) + } + + private static func canonicalRequest( + request: URLRequest, + signedHeaders: SignedHeadersInfo, + bodyHash: String) -> String + { + let method = request.httpMethod ?? "GET" + let url = request.url! + let path = url.path.isEmpty ? "/" : url.path + let query = Self.canonicalQueryString(url: url) + + return [ + method, + Self.uriEncodePath(path), + query, + signedHeaders.canonical + "\n", + signedHeaders.keys, + bodyHash, + ].joined(separator: "\n") + } + + private static func canonicalQueryString(url: URL) -> String { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems, !queryItems.isEmpty + else { + return "" + } + + return queryItems + .map { item in + let key = Self.uriEncode(item.name) + let value = Self.uriEncode(item.value ?? "") + return "\(key)=\(value)" + } + .sorted() + .joined(separator: "&") + } + + private static func calculateSignature( + secretKey: String, + dateStamp: String, + region: String, + service: String, + stringToSign: String) -> String + { + let kDate = Self.hmacSHA256(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8)) + let kRegion = Self.hmacSHA256(key: kDate, data: Data(region.utf8)) + let kService = Self.hmacSHA256(key: kRegion, data: Data(service.utf8)) + let kSigning = Self.hmacSHA256(key: kService, data: Data("aws4_request".utf8)) + let signature = Self.hmacSHA256(key: kSigning, data: Data(stringToSign.utf8)) + return signature.map { String(format: "%02x", $0) }.joined() + } + + private static func hmacSHA256(key: Data, data: Data) -> Data { + let symmetricKey = SymmetricKey(data: key) + let mac = HMAC.authenticationCode(for: data, using: symmetricKey) + return Data(mac) + } + + private static func sha256Hex(_ data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func dateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + } + + private static func dateStamp(date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: date) + } + + private static func uriEncode(_ string: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~") + return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string + } + + private static func uriEncodePath(_ path: String) -> String { + path.split(separator: "/", omittingEmptySubsequences: false) + .map { Self.uriEncode(String($0)) } + .joined(separator: "/") + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift new file mode 100644 index 000000000..9c704a19e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift @@ -0,0 +1,79 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum BedrockProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .bedrock, + metadata: ProviderMetadata( + id: .bedrock, + displayName: "AWS Bedrock", + sessionLabel: "Budget", + weeklyLabel: "Cost", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show AWS Bedrock usage", + cliName: "bedrock", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://console.aws.amazon.com/bedrock", + statusPageURL: nil, + statusLinkURL: "https://health.aws.amazon.com/health/status"), + branding: ProviderBranding( + iconStyle: .bedrock, + iconResourceName: "ProviderIcon-bedrock", + color: ProviderColor(red: 255 / 255, green: 153 / 255, blue: 0 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "No AWS Bedrock cost data available. Check your AWS credentials." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [BedrockAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "bedrock", + aliases: ["aws-bedrock"], + versionDetector: nil)) + } +} + +struct BedrockAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "bedrock.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + BedrockSettingsReader.hasCredentials(environment: context.env) + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: context.env), + let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: context.env) + else { + throw BedrockUsageError.missingCredentials + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: accessKeyID, + secretAccessKey: secretAccessKey, + sessionToken: BedrockSettingsReader.sessionToken(environment: context.env)) + let region = BedrockSettingsReader.region(environment: context.env) + let budget = BedrockSettingsReader.budget(environment: context.env) + + let usage = try await BedrockUsageFetcher.fetchUsage( + credentials: credentials, + region: region, + budget: budget, + environment: context.env) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift new file mode 100644 index 000000000..ce2e91dc4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Reads AWS Bedrock settings from environment variables and config. +public enum BedrockSettingsReader { + /// Environment variable key for AWS access key ID. + public static let accessKeyIDKey = "AWS_ACCESS_KEY_ID" + /// Environment variable key for AWS secret access key. + public static let secretAccessKeyKey = "AWS_SECRET_ACCESS_KEY" + /// Environment variable key for optional session token (temporary credentials). + public static let sessionTokenKey = "AWS_SESSION_TOKEN" + /// Environment variable keys for AWS region (checked in order). + public static let regionKeys = ["AWS_REGION", "AWS_DEFAULT_REGION"] + /// Environment variable key for a user-defined monthly Bedrock budget (USD). + public static let budgetKey = "CODEXBAR_BEDROCK_BUDGET" + /// Environment variable key for overriding the Cost Explorer API endpoint. + public static let apiURLKey = "CODEXBAR_BEDROCK_API_URL" + + /// The config-file API key env var used by `ProviderConfigEnvironment`. + public static let apiKeyEnvKey = "AWS_ACCESS_KEY_ID" + + public static let defaultRegion = "us-east-1" + + /// Returns the AWS access key ID from environment if present and non-empty. + public static func accessKeyID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.accessKeyIDKey]) + } + + /// Returns the AWS secret access key from environment if present and non-empty. + public static func secretAccessKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.secretAccessKeyKey]) + } + + /// Returns the optional session token from environment. + public static func sessionToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.sessionTokenKey]) + } + + /// Returns the AWS region, checking `AWS_REGION` then `AWS_DEFAULT_REGION`, falling back to us-east-1. + public static func region(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + for key in self.regionKeys { + if let value = self.cleaned(environment[key]) { + return value + } + } + return self.defaultRegion + } + + /// Returns the user-defined monthly Bedrock budget in USD, if set via environment. + public static func budget(environment: [String: String] = ProcessInfo.processInfo.environment) -> Double? { + guard let raw = self.cleaned(environment[self.budgetKey]), + let value = Double(raw), value > 0 + else { + return nil + } + return value + } + + /// Returns true if valid AWS credentials are available in the environment. + public static func hasCredentials( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + self.accessKeyID(environment: environment) != nil + && self.secretAccessKey(environment: environment) != nil + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift new file mode 100644 index 000000000..a18f61dde --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift @@ -0,0 +1,282 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// AWS Bedrock usage snapshot combining cost data and optional budget info. +public struct BedrockUsageSnapshot: Codable, Sendable { + /// Total Bedrock spend for the current month (USD). + public let monthlySpend: Double + /// User-defined monthly budget (USD), if configured. + public let monthlyBudget: Double? + /// Total input tokens consumed this month (from CloudWatch), if available. + public let inputTokens: Int? + /// Total output tokens consumed this month (from CloudWatch), if available. + public let outputTokens: Int? + /// AWS region used for the query. + 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 + } + + /// Budget usage percentage (0-100), only available when a budget is set. + public var budgetUsedPercent: Double? { + guard let budget = self.monthlyBudget, budget > 0 else { return nil } + return min(100, max(0, (self.monthlySpend / budget) * 100)) + } + + /// Total tokens consumed (input + output), if both are available. + 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)" + } +} + +// MARK: - Fetcher + +/// Fetches Bedrock usage data from the AWS Cost Explorer API. +struct BedrockUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.bedrockUsage) + private static let requestTimeoutSeconds: TimeInterval = 15 + + /// Fetches current-month Bedrock costs via the AWS Cost Explorer API. + 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, + region: region, + environment: environment) + + return BedrockUsageSnapshot( + monthlySpend: spend, + monthlyBudget: budget, + inputTokens: nil, + outputTokens: nil, + region: region, + updatedAt: Date()) + } + + // MARK: - Cost Explorer + + private static func fetchMonthlyCost( + credentials: BedrockAWSSigner.Credentials, + region: String, + environment: [String: String]) async throws -> Double + { + let baseURL: URL + if let override = environment[BedrockSettingsReader.apiURLKey], + let url = URL(string: BedrockSettingsReader.cleaned(override) ?? "") + { + baseURL = url + } else { + baseURL = URL(string: "https://ce.\(region).amazonaws.com")! + } + + let (startDate, endDate) = Self.currentMonthRange() + + let requestBody: [String: Any] = [ + "TimePeriod": [ + "Start": startDate, + "End": endDate, + ], + "Granularity": "MONTHLY", + "Metrics": ["UnblendedCost"], + "Filter": [ + "Dimensions": [ + "Key": "SERVICE", + "Values": ["Amazon Bedrock", "Amazon Bedrock Runtime"], + ], + ], + ] + + 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: region, + service: "ce") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw BedrockUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let summary = Self.sanitizedResponseBody(data) + Self.log.error("AWS Cost Explorer returned \(httpResponse.statusCode): \(summary)") + throw BedrockUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + return try Self.parseCostResponse(data) + } + + private static func parseCostResponse(_ data: Data) throws -> Double { + 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 totalCost = 0.0 + for result in results { + if let total = result["Total"] as? [String: Any], + let unblended = total["UnblendedCost"] as? [String: Any], + let amountStr = unblended["Amount"] as? String, + let amount = Double(amountStr) + { + totalCost += amount + } + } + + return totalCost + } + + // MARK: - Helpers + + private static func currentMonthRange() -> (start: String, end: String) { + let calendar = Calendar.current + let now = Date() + let components = calendar.dateComponents([.year, .month], from: now) + let startOfMonth = calendar.date(from: components)! + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + + let tomorrow = calendar.date(byAdding: .day, value: 1, to: now)! + return (formatter.string(from: startOfMonth), formatter.string(from: tomorrow)) + } + + 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[.. String? + { + self.bedrockResolution(environment: environment)?.token + } + + public static func bedrockResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(BedrockSettingsReader.accessKeyID(environment: environment)) + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index ff0f8eeb4..4c02d69ec 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -27,6 +27,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case warp case openrouter case perplexity + case bedrock } // swiftformat:enable sortDeclarations @@ -56,6 +57,7 @@ public enum IconStyle: Sendable, CaseIterable { case warp case openrouter case perplexity + case bedrock case combined } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index f4a3ba8bb..84ce02fbc 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -72,7 +72,8 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, + .bedrock: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index c0e600fa9..6b5bed94a 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -72,6 +72,7 @@ enum ProviderChoice: String, AppEnum { case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets case .perplexity: return nil // Perplexity not yet supported in widgets + case .bedrock: return nil // Bedrock not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index a00794752..b5cf70980 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -281,6 +281,7 @@ private struct ProviderSwitchChip: View { case .openrouter: "OpenRouter" case .warp: "Warp" case .perplexity: "Pplx" + case .bedrock: "Bedrock" } } } @@ -638,6 +639,8 @@ enum WidgetColors { Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) case .perplexity: Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal + case .bedrock: + Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange } } } diff --git a/Tests/CodexBarTests/BedrockUsageStatsTests.swift b/Tests/CodexBarTests/BedrockUsageStatsTests.swift new file mode 100644 index 000000000..72d78e507 --- /dev/null +++ b/Tests/CodexBarTests/BedrockUsageStatsTests.swift @@ -0,0 +1,322 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct BedrockUsageStatsTests { + @Test + func `to usage snapshot with budget shows primary window`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 50, + monthlyBudget: 200, + inputTokens: 1_500_000, + outputTokens: 500_000, + region: "us-east-1", + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetDescription == "Monthly budget") + #expect(usage.primary?.resetsAt != nil) + #expect(usage.providerCost?.used == 50) + #expect(usage.providerCost?.limit == 200) + #expect(usage.providerCost?.currencyCode == "USD") + #expect(usage.providerCost?.period == "Monthly") + } + + @Test + func `to usage snapshot without budget omits primary window`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 75.5, + monthlyBudget: nil, + region: "us-west-2", + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.providerCost?.used == 75.5) + #expect(usage.providerCost?.limit == 0) + } + + @Test + func `budget used percent is clamped to 100`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 250, + monthlyBudget: 200, + region: "us-east-1", + updatedAt: Date()) + + #expect(snapshot.budgetUsedPercent == 100) + } + + @Test + func `budget used percent is nil when no budget`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 50, + monthlyBudget: nil, + region: "us-east-1", + updatedAt: Date()) + + #expect(snapshot.budgetUsedPercent == nil) + } + + @Test + func `budget used percent is nil when budget is zero`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 50, + monthlyBudget: 0, + region: "us-east-1", + updatedAt: Date()) + + #expect(snapshot.budgetUsedPercent == nil) + } + + @Test + func `total tokens combines input and output`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 10, + monthlyBudget: nil, + inputTokens: 1_000_000, + outputTokens: 500_000, + region: "us-east-1", + updatedAt: Date()) + + #expect(snapshot.totalTokens == 1_500_000) + } + + @Test + func `total tokens is nil when tokens not available`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 10, + monthlyBudget: nil, + inputTokens: nil, + outputTokens: nil, + region: "us-east-1", + updatedAt: Date()) + + #expect(snapshot.totalTokens == nil) + } + + @Test + func `identity shows spend and budget info`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 42.5, + monthlyBudget: 100, + inputTokens: 2_000_000, + outputTokens: 800_000, + region: "us-east-1", + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + let loginMethod = usage.identity?.loginMethod + + #expect(loginMethod?.contains("Spend: $42.50") == true) + #expect(loginMethod?.contains("Budget: $100.00") == true) + #expect(loginMethod?.contains("Tokens: 2.8M") == true) + #expect(usage.identity?.providerID == .bedrock) + } + + @Test + func `identity shows only spend when no budget or tokens`() { + let snapshot = BedrockUsageSnapshot( + monthlySpend: 15.75, + monthlyBudget: nil, + region: "us-east-1", + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.identity?.loginMethod == "Spend: $15.75") + } + + @Test + func `formatted token count uses appropriate units`() { + #expect(BedrockUsageSnapshot.formattedTokenCount(500) == "500") + #expect(BedrockUsageSnapshot.formattedTokenCount(1500) == "1.5K") + #expect(BedrockUsageSnapshot.formattedTokenCount(1_500_000) == "1.5M") + } + + @Test + func `snapshot round trip preserves data`() throws { + let original = BedrockUsageSnapshot( + monthlySpend: 99.99, + monthlyBudget: 500, + inputTokens: 3_000_000, + outputTokens: 1_000_000, + region: "eu-west-1", + updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) + + let encoder = JSONEncoder() + let data = try encoder.encode(original) + let decoded = try JSONDecoder().decode(BedrockUsageSnapshot.self, from: data) + + #expect(decoded.monthlySpend == 99.99) + #expect(decoded.monthlyBudget == 500) + #expect(decoded.inputTokens == 3_000_000) + #expect(decoded.outputTokens == 1_000_000) + #expect(decoded.region == "eu-west-1") + } + + @Test + func `settings reader parses credentials from environment`() { + let env = [ + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "AWS_REGION": "eu-west-1", + "CODEXBAR_BEDROCK_BUDGET": "500", + ] + + #expect(BedrockSettingsReader.accessKeyID(environment: env) == "AKIAIOSFODNN7EXAMPLE") + #expect(BedrockSettingsReader.secretAccessKey(environment: env) == "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + #expect(BedrockSettingsReader.region(environment: env) == "eu-west-1") + #expect(BedrockSettingsReader.budget(environment: env) == 500) + #expect(BedrockSettingsReader.hasCredentials(environment: env) == true) + } + + @Test + func `settings reader falls back to default region`() { + let env: [String: String] = [:] + #expect(BedrockSettingsReader.region(environment: env) == "us-east-1") + } + + @Test + func `settings reader detects missing credentials`() { + let env: [String: String] = [:] + #expect(BedrockSettingsReader.hasCredentials(environment: env) == false) + } + + @Test + func `settings reader ignores empty budget`() { + let env = ["CODEXBAR_BEDROCK_BUDGET": ""] + #expect(BedrockSettingsReader.budget(environment: env) == nil) + } + + @Test + func `settings reader ignores negative budget`() { + let env = ["CODEXBAR_BEDROCK_BUDGET": "-100"] + #expect(BedrockSettingsReader.budget(environment: env) == nil) + } + + @Test + func `cost explorer response parsing extracts total`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + + BedrockStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = """ + { + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2026-04-01", "End": "2026-04-06"}, + "Total": { + "UnblendedCost": {"Amount": "42.50", "Unit": "USD"} + } + } + ] + } + """ + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: "AKIATEST", + secretAccessKey: "testSecret", + sessionToken: nil) + + let usage = try await BedrockUsageFetcher.fetchUsage( + credentials: credentials, + region: "us-east-1", + budget: 100, + environment: ["CODEXBAR_BEDROCK_API_URL": "https://bedrock.test"]) + + #expect(usage.monthlySpend == 42.50) + #expect(usage.monthlyBudget == 100) + #expect(usage.region == "us-east-1") + } + + @Test + func `non200 response throws api error`() async throws { + let registered = URLProtocol.registerClass(BedrockStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(BedrockStubURLProtocol.self) + } + BedrockStubURLProtocol.handler = nil + } + + BedrockStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"message":"Access Denied"}"#, statusCode: 403) + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: "AKIATEST", + secretAccessKey: "testSecret", + sessionToken: nil) + + do { + _ = try await BedrockUsageFetcher.fetchUsage( + credentials: credentials, + region: "us-east-1", + budget: nil, + environment: ["CODEXBAR_BEDROCK_API_URL": "https://bedrock.test"]) + Issue.record("Expected BedrockUsageError.apiError") + } catch let error as BedrockUsageError { + guard case let .apiError(message) = error else { + Issue.record("Expected apiError, got: \(error)") + return + } + #expect(message == "HTTP 403") + } + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } +} + +final class BedrockStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "bedrock.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} From fb4a21efde772e4201b16772c1185dd0ed1dca29 Mon Sep 17 00:00:00 2001 From: Alexander Falk Date: Sun, 5 Apr 2026 20:24:31 -0400 Subject: [PATCH 2/5] Added icon, refined configs --- Sources/CodexBar/Resources/ProviderIcon-bedrock.svg | 8 ++++++++ .../CodexBarCore/Config/ProviderConfigEnvironment.swift | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 Sources/CodexBar/Resources/ProviderIcon-bedrock.svg diff --git a/Sources/CodexBar/Resources/ProviderIcon-bedrock.svg b/Sources/CodexBar/Resources/ProviderIcon-bedrock.svg new file mode 100644 index 000000000..64935a73a --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-bedrock.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 4ab725858..1a00d9ad8 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -32,7 +32,13 @@ public enum ProviderConfigEnvironment { case .openrouter: env[OpenRouterSettingsReader.envKey] = apiKey case .bedrock: - env[BedrockSettingsReader.apiKeyEnvKey] = apiKey + env[BedrockSettingsReader.accessKeyIDKey] = apiKey + if let secret = config?.sanitizedCookieHeader, !secret.isEmpty { + env[BedrockSettingsReader.secretAccessKeyKey] = secret + } + if let region = config?.region, !region.isEmpty { + env[BedrockSettingsReader.regionKeys[0]] = region + } default: break } From de2f9a3df7735d74e4937193081c42ba9e802d3a Mon Sep 17 00:00:00 2001 From: Alexander Falk Date: Sun, 5 Apr 2026 20:44:47 -0400 Subject: [PATCH 3/5] Correctly extract Bedrock usage --- .../Providers/Bedrock/BedrockUsageStats.swift | 37 ++++++++++++------- .../BedrockUsageStatsTests.swift | 17 +++++++-- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift index a18f61dde..d6498fe98 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift @@ -144,17 +144,23 @@ struct BedrockUsageFetcher: Sendable { region: String, environment: [String: String]) async throws -> Double { + // Cost Explorer is a global service; always use us-east-1 regardless of the + // user's Bedrock region. + let ceRegion = "us-east-1" let baseURL: URL if let override = environment[BedrockSettingsReader.apiURLKey], let url = URL(string: BedrockSettingsReader.cleaned(override) ?? "") { baseURL = url } else { - baseURL = URL(string: "https://ce.\(region).amazonaws.com")! + baseURL = URL(string: "https://ce.\(ceRegion).amazonaws.com")! } let (startDate, endDate) = Self.currentMonthRange() + // Use GroupBy to get per-service costs, then filter client-side for Bedrock + // services. AWS names them per-model (e.g. "Claude Opus 4.6 (Bedrock Edition)") + // so exact-match filters don't work reliably. let requestBody: [String: Any] = [ "TimePeriod": [ "Start": startDate, @@ -162,11 +168,8 @@ struct BedrockUsageFetcher: Sendable { ], "Granularity": "MONTHLY", "Metrics": ["UnblendedCost"], - "Filter": [ - "Dimensions": [ - "Key": "SERVICE", - "Values": ["Amazon Bedrock", "Amazon Bedrock Runtime"], - ], + "GroupBy": [ + ["Type": "DIMENSION", "Key": "SERVICE"], ], ] @@ -184,7 +187,7 @@ struct BedrockUsageFetcher: Sendable { BedrockAWSSigner.sign( request: &request, credentials: credentials, - region: region, + region: ceRegion, service: "ce") let (data, response) = try await URLSession.shared.data(for: request) @@ -211,12 +214,20 @@ struct BedrockUsageFetcher: Sendable { var totalCost = 0.0 for result in results { - if let total = result["Total"] as? [String: Any], - let unblended = total["UnblendedCost"] as? [String: Any], - let amountStr = unblended["Amount"] as? String, - let amount = Double(amountStr) - { - totalCost += amount + 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) + { + totalCost += amount + } } } diff --git a/Tests/CodexBarTests/BedrockUsageStatsTests.swift b/Tests/CodexBarTests/BedrockUsageStatsTests.swift index 72d78e507..cf0d94a02 100644 --- a/Tests/CodexBarTests/BedrockUsageStatsTests.swift +++ b/Tests/CodexBarTests/BedrockUsageStatsTests.swift @@ -216,9 +216,20 @@ struct BedrockUsageStatsTests { "ResultsByTime": [ { "TimePeriod": {"Start": "2026-04-01", "End": "2026-04-06"}, - "Total": { - "UnblendedCost": {"Amount": "42.50", "Unit": "USD"} - } + "Groups": [ + { + "Keys": ["Claude Opus 4.6 (Bedrock Edition)"], + "Metrics": {"UnblendedCost": {"Amount": "30.00", "Unit": "USD"}} + }, + { + "Keys": ["Claude Sonnet 4.6 (Bedrock Edition)"], + "Metrics": {"UnblendedCost": {"Amount": "12.50", "Unit": "USD"}} + }, + { + "Keys": ["Amazon EC2"], + "Metrics": {"UnblendedCost": {"Amount": "5.00", "Unit": "USD"}} + } + ] } ] } From 5cbc6f7745dd16784fffa16e3b10847598013749 Mon Sep 17 00:00:00 2001 From: Alexander Falk Date: Sun, 5 Apr 2026 21:09:49 -0400 Subject: [PATCH 4/5] =?UTF-8?q?Added=20Bedrock=20support=20to=20the=20cost?= =?UTF-8?q?=20usage=20fetcher:=20=20=20-=20BedrockUsageFetcher.fetchDailyR?= =?UTF-8?q?eport=20=E2=80=94=20calls=20Cost=20Explorer=20with=20DAILY=20gr?= =?UTF-8?q?anularity=20over=20the=20last=2030=20days,=20groups=20by=20serv?= =?UTF-8?q?ice,=20filters=20for=20=20=20"Bedrock"=20in=20service=20names,?= =?UTF-8?q?=20and=20produces=20CostUsageDailyReport.Entry=20items=20with?= =?UTF-8?q?=20per-model=20breakdowns=20=20=20-=20CostUsageFetcher.loadBedr?= =?UTF-8?q?ockTokenSnapshot=20=E2=80=94=20Bedrock-specific=20path=20that?= =?UTF-8?q?=20bypasses=20the=20local=20file=20scanner=20and=20calls=20the?= =?UTF-8?q?=20AWS=20API=20directly=20=20=20-=204=20UI=20guards=20updated?= =?UTF-8?q?=20=E2=80=94=20UsageStore.refreshTokenUsage,=20MenuCardView.tok?= =?UTF-8?q?enUsageSection,=20StatusItemController+Menu.makeCostHistorySubm?= =?UTF-8?q?enu,=20=20=20and=20menuCardModel=20tokenSnapshot=20wiring=20all?= =?UTF-8?q?=20now=20include=20.bedrock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bedrock cost section should now show "Today" and "Last 30 days" values, plus the bar chart submenu with daily cost breakdowns by model (e.g. "Claude Opus 4.6 (Bedrock Edition)"). --- Sources/CodexBar/MenuCardView.swift | 2 +- .../CodexBar/StatusItemController+Menu.swift | 4 +- Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCore/CostUsageFetcher.swift | 38 ++++- .../Providers/Bedrock/BedrockUsageStats.swift | 141 ++++++++++++++++-- 5 files changed, 163 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 14703fae9..df8903127 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1308,7 +1308,7 @@ extension UsageMenuCardView.Model { snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { return nil } guard enabled else { return nil } guard let snapshot else { return nil } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 332ba5ab4..052fc1b2f 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1278,7 +1278,7 @@ extension StatusItemController { } private func makeCostHistorySubmenu(provider: UsageProvider) -> NSMenu? { - guard provider == .codex || provider == .claude || provider == .vertexai else { return nil } + guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { return nil } let width = Self.menuCardBaseWidth guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return nil } guard !tokenSnapshot.daily.isEmpty else { return nil } @@ -1390,7 +1390,7 @@ extension StatusItemController { tokenSnapshot = nil tokenError = nil } - } else if target == .claude || target == .vertexai, snapshotOverride == nil { + } else if target == .claude || target == .vertexai || target == .bedrock, snapshotOverride == nil { credits = nil creditsError = nil dashboard = nil diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index f5ca00a26..cd427c157 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1117,7 +1117,7 @@ extension UsageStore { } private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { - guard provider == .codex || provider == .claude || provider == .vertexai else { + guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2243d5218..a5184f305 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -26,14 +26,18 @@ public struct CostUsageFetcher: Sendable { forceRefresh: Bool = false, allowVertexClaudeFallback: Bool = false) async throws -> CostUsageTokenSnapshot { - guard provider == .codex || provider == .claude || provider == .vertexai else { - throw CostUsageError.unsupportedProvider(provider) - } - let until = now // Rolling window: last 30 days (inclusive). Use -29 for inclusive boundaries. let since = Calendar.current.date(byAdding: .day, value: -29, to: now) ?? now + if provider == .bedrock { + return try await Self.loadBedrockTokenSnapshot(since: since, until: until, now: now) + } + + guard provider == .codex || provider == .claude || provider == .vertexai else { + throw CostUsageError.unsupportedProvider(provider) + } + var options = CostUsageScanner.Options() if provider == .vertexai { options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly @@ -69,6 +73,32 @@ public struct CostUsageFetcher: Sendable { return Self.tokenSnapshot(from: daily, now: now) } + private static func loadBedrockTokenSnapshot( + since: Date, + until: Date, + now: Date) async throws -> CostUsageTokenSnapshot + { + let env = ProcessInfo.processInfo.environment + guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: env), + let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: env) + else { + throw BedrockUsageError.missingCredentials + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: accessKeyID, + secretAccessKey: secretAccessKey, + sessionToken: BedrockSettingsReader.sessionToken(environment: env)) + + let daily = try await BedrockUsageFetcher.fetchDailyReport( + credentials: credentials, + since: since, + until: until, + environment: env) + + return Self.tokenSnapshot(from: daily, now: now) + } + static func tokenSnapshot(from daily: CostUsageDailyReport, now: Date) -> CostUsageTokenSnapshot { // Pick the most recent day; break ties by cost/tokens to keep a stable "session" row. let currentDay = daily.data.max { lhs, rhs in diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift index d6498fe98..daa9fd048 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift @@ -139,13 +139,55 @@ struct BedrockUsageFetcher: Sendable { // MARK: - Cost Explorer + /// Fetches a 30-day daily cost breakdown for the cost history chart. + 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 endDate = formatter.string(from: Calendar.current.date(byAdding: .day, value: 1, to: until) ?? until) + + let data = try await Self.callCostExplorer( + startDate: startDate, + endDate: endDate, + granularity: "DAILY", + credentials: credentials, + environment: environment) + + let entries = try Self.parseDailyResponse(data) + return CostUsageDailyReport(data: entries, summary: nil) + } + private static func fetchMonthlyCost( credentials: BedrockAWSSigner.Credentials, region: String, environment: [String: String]) async throws -> Double { - // Cost Explorer is a global service; always use us-east-1 regardless of the - // user's Bedrock region. + let (startDate, endDate) = Self.currentMonthRange() + + let data = try await Self.callCostExplorer( + startDate: startDate, + endDate: endDate, + granularity: "MONTHLY", + credentials: credentials, + environment: environment) + + return try Self.parseTotalCost(data) + } + + /// Sends a GetCostAndUsage request to the Cost Explorer API. + private static func callCostExplorer( + startDate: String, + endDate: String, + granularity: String, + credentials: BedrockAWSSigner.Credentials, + environment: [String: String]) async throws -> Data + { + // Cost Explorer is a global service; always use us-east-1. let ceRegion = "us-east-1" let baseURL: URL if let override = environment[BedrockSettingsReader.apiURLKey], @@ -156,8 +198,6 @@ struct BedrockUsageFetcher: Sendable { baseURL = URL(string: "https://ce.\(ceRegion).amazonaws.com")! } - let (startDate, endDate) = Self.currentMonthRange() - // Use GroupBy to get per-service costs, then filter client-side for Bedrock // services. AWS names them per-model (e.g. "Claude Opus 4.6 (Bedrock Edition)") // so exact-match filters don't work reliably. @@ -166,7 +206,7 @@ struct BedrockUsageFetcher: Sendable { "Start": startDate, "End": endDate, ], - "Granularity": "MONTHLY", + "Granularity": granularity, "Metrics": ["UnblendedCost"], "GroupBy": [ ["Type": "DIMENSION", "Key": "SERVICE"], @@ -202,18 +242,84 @@ struct BedrockUsageFetcher: Sendable { throw BedrockUsageError.apiError("HTTP \(httpResponse.statusCode)") } - return try Self.parseCostResponse(data) + return data } - private static func parseCostResponse(_ data: Data) throws -> Double { + // MARK: - Response parsing + + 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 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 totalCost = 0.0 + 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 + } + + /// Parses grouped Cost Explorer results, returning (serviceName, cost, dateStr) tuples + /// for Bedrock-related services only. + 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], @@ -226,27 +332,30 @@ struct BedrockUsageFetcher: Sendable { let amountStr = unblended["Amount"] as? String, let amount = Double(amountStr) { - totalCost += amount + items.append((serviceName, amount, dateStr)) } } } - - return totalCost + return items } // MARK: - Helpers + 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 currentMonthRange() -> (start: String, end: String) { let calendar = Calendar.current let now = Date() let components = calendar.dateComponents([.year, .month], from: now) let startOfMonth = calendar.date(from: components)! - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = TimeZone(identifier: "UTC") - formatter.locale = Locale(identifier: "en_US_POSIX") - + let formatter = Self.dateFormatter() let tomorrow = calendar.date(byAdding: .day, value: 1, to: now)! return (formatter.string(from: startOfMonth), formatter.string(from: tomorrow)) } From f1704f47029e5c4cad615caf919ef0cc072999c6 Mon Sep 17 00:00:00 2001 From: Alexander Falk Date: Mon, 6 Apr 2026 09:41:00 -0400 Subject: [PATCH 5/5] Fix Bedrock credential resolution for cost history and mixed config setups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two Codex review comments on PR #652: - ProviderConfigEnvironment: move Bedrock handling before the apiKey guard so secret/region overrides apply even when the access key comes from the shell environment (P2 fix) - CostUsageFetcher: thread provider-specific environment through loadTokenSnapshot → loadBedrockTokenSnapshot so Bedrock keys saved in Settings are respected during cost-history fetches (P1 fix) - UsageStore: build the merged environment via applyAPIKeyOverride before calling the cost fetcher Also add docs/bedrock.md documenting the new AWS Bedrock provider. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/CodexBar/UsageStore.swift | 5 ++ .../Config/ProviderConfigEnvironment.swift | 24 ++++-- Sources/CodexBarCore/CostUsageFetcher.swift | 14 ++-- docs/bedrock.md | 83 +++++++++++++++++++ 4 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 docs/bedrock.md diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index cd427c157..6cdcfb7be 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1162,6 +1162,10 @@ extension UsageStore { do { let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout + let providerEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: ProcessInfo.processInfo.environment, + provider: provider, + config: self.settings.providerConfig(for: provider)) // CostUsageFetcher scans local Codex session logs from this machine. That data is // intentionally presented as provider-level local telemetry rather than managed-account // remote state, so managed Codex account selection does not retarget this fetch. @@ -1171,6 +1175,7 @@ extension UsageStore { group.addTask(priority: .utility) { try await fetcher.loadTokenSnapshot( provider: provider, + environment: providerEnvironment, now: now, forceRefresh: force, allowVertexClaudeFallback: !self.isEnabled(.claude)) diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 1a00d9ad8..5434f58b0 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -6,6 +6,22 @@ public enum ProviderConfigEnvironment { provider: UsageProvider, config: ProviderConfig?) -> [String: String] { + // Bedrock uses multiple independent credential fields, not just a single API key. + // Apply each field from config when present, regardless of the others. + if provider == .bedrock { + var env = base + if let accessKey = config?.sanitizedAPIKey, !accessKey.isEmpty { + env[BedrockSettingsReader.accessKeyIDKey] = accessKey + } + if let secret = config?.sanitizedCookieHeader, !secret.isEmpty { + env[BedrockSettingsReader.secretAccessKeyKey] = secret + } + if let region = config?.region, !region.isEmpty { + env[BedrockSettingsReader.regionKeys[0]] = region + } + return env + } + guard let apiKey = config?.sanitizedAPIKey, !apiKey.isEmpty else { return base } var env = base switch provider { @@ -31,14 +47,6 @@ public enum ProviderConfigEnvironment { } case .openrouter: env[OpenRouterSettingsReader.envKey] = apiKey - case .bedrock: - env[BedrockSettingsReader.accessKeyIDKey] = apiKey - if let secret = config?.sanitizedCookieHeader, !secret.isEmpty { - env[BedrockSettingsReader.secretAccessKeyKey] = secret - } - if let region = config?.region, !region.isEmpty { - env[BedrockSettingsReader.regionKeys[0]] = region - } default: break } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index a5184f305..78c077dff 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -22,6 +22,7 @@ public struct CostUsageFetcher: Sendable { public func loadTokenSnapshot( provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, now: Date = Date(), forceRefresh: Bool = false, allowVertexClaudeFallback: Bool = false) async throws -> CostUsageTokenSnapshot @@ -31,7 +32,8 @@ public struct CostUsageFetcher: Sendable { let since = Calendar.current.date(byAdding: .day, value: -29, to: now) ?? now if provider == .bedrock { - return try await Self.loadBedrockTokenSnapshot(since: since, until: until, now: now) + return try await Self.loadBedrockTokenSnapshot( + environment: environment, since: since, until: until, now: now) } guard provider == .codex || provider == .claude || provider == .vertexai else { @@ -74,13 +76,13 @@ public struct CostUsageFetcher: Sendable { } private static func loadBedrockTokenSnapshot( + environment: [String: String], since: Date, until: Date, now: Date) async throws -> CostUsageTokenSnapshot { - let env = ProcessInfo.processInfo.environment - guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: env), - let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: env) + guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: environment), + let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: environment) else { throw BedrockUsageError.missingCredentials } @@ -88,13 +90,13 @@ public struct CostUsageFetcher: Sendable { let credentials = BedrockAWSSigner.Credentials( accessKeyID: accessKeyID, secretAccessKey: secretAccessKey, - sessionToken: BedrockSettingsReader.sessionToken(environment: env)) + sessionToken: BedrockSettingsReader.sessionToken(environment: environment)) let daily = try await BedrockUsageFetcher.fetchDailyReport( credentials: credentials, since: since, until: until, - environment: env) + environment: environment) return Self.tokenSnapshot(from: daily, now: now) } diff --git a/docs/bedrock.md b/docs/bedrock.md new file mode 100644 index 000000000..afa895885 --- /dev/null +++ b/docs/bedrock.md @@ -0,0 +1,83 @@ +--- +summary: "AWS Bedrock provider: IAM credentials, Cost Explorer API, budget tracking, and cost history." +read_when: + - Debugging Bedrock auth or cost fetch + - Updating Bedrock credential resolution or API calls +--- + +# AWS Bedrock provider + +AWS Bedrock is API-token based using IAM credentials. No browser cookies or OAuth. + +## Credential sources (fallback order) + +Each credential field is resolved independently, allowing mixed configuration +(e.g. access key from environment, secret from Settings): + +1) **Settings UI** (Preferences -> Providers -> AWS Bedrock): + - Access key ID, Secret access key, Region. + - Stored in `~/.codexbar/config.json` -> `providers[]` (apiKey, cookieHeader, region). +2) **Environment variables**: + - `AWS_ACCESS_KEY_ID` (required) + - `AWS_SECRET_ACCESS_KEY` (required) + - `AWS_SESSION_TOKEN` (optional, for temporary credentials) + - `AWS_REGION` or `AWS_DEFAULT_REGION` (defaults to `us-east-1`) + - `CODEXBAR_BEDROCK_BUDGET` (optional monthly budget in USD) + +Settings overrides are merged into the environment per-field by +`ProviderConfigEnvironment.applyAPIKeyOverride`, so a field set in Settings +wins over the same field in the shell environment. + +## API endpoints + +### Usage (monthly spend) +- AWS Cost Explorer `GetCostAndUsage` (always routed to `us-east-1`). +- Groups by SERVICE dimension, filters client-side for services containing "Bedrock". +- Returns current-month unblended cost in USD. + +### Cost history (30-day chart) +- Same Cost Explorer API with DAILY granularity over the last 30 days. +- Produces `CostUsageDailyReport.Entry` items with per-service breakdowns. + +Override the Cost Explorer endpoint via `CODEXBAR_BEDROCK_API_URL`. + +## Display + +- **Primary meter**: Budget usage percentage (only shown when `CODEXBAR_BEDROCK_BUDGET` is set). +- **Identity line**: Monthly spend, budget (if set), and total tokens (if available). +- **Cost history**: 30-day daily cost chart in the token/cost submenu. + +## CLI usage + +```bash +codexbar --provider bedrock +codexbar -p aws-bedrock # alias +``` + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `AWS_ACCESS_KEY_ID` | AWS access key ID (required) | +| `AWS_SECRET_ACCESS_KEY` | AWS secret access key (required) | +| `AWS_SESSION_TOKEN` | Session token for temporary credentials (optional) | +| `AWS_REGION` | AWS region (optional, default `us-east-1`) | +| `AWS_DEFAULT_REGION` | Fallback region variable (optional) | +| `CODEXBAR_BEDROCK_BUDGET` | Monthly budget in USD for the progress meter (optional) | +| `CODEXBAR_BEDROCK_API_URL` | Override the Cost Explorer API endpoint (optional) | + +## Request signing + +All AWS requests are signed with Signature Version 4 using `BedrockAWSSigner`. +Cost Explorer calls always target `us-east-1` regardless of the configured region. + +## Key files + +- Descriptor: `Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift` +- Settings reader: `Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift` +- Usage fetcher: `Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift` +- AWS signer: `Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift` +- Settings UI: `Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift` +- Settings store: `Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift` +- Cost history: `Sources/CodexBarCore/CostUsageFetcher.swift` (Bedrock path) +- Config environment: `Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift` (Bedrock overrides)