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/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/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/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/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 5a4e13a61..6cdcfb7be 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" } } @@ -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() @@ -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/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..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 { diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 2243d5218..78c077dff 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -22,18 +22,24 @@ 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 { - 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( + environment: environment, 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 +75,32 @@ public struct CostUsageFetcher: Sendable { return Self.tokenSnapshot(from: daily, now: now) } + private static func loadBedrockTokenSnapshot( + environment: [String: String], + since: Date, + until: Date, + now: Date) async throws -> CostUsageTokenSnapshot + { + guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: environment), + let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: environment) + else { + throw BedrockUsageError.missingCredentials + } + + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: accessKeyID, + secretAccessKey: secretAccessKey, + sessionToken: BedrockSettingsReader.sessionToken(environment: environment)) + + let daily = try await BedrockUsageFetcher.fetchDailyReport( + credentials: credentials, + since: since, + until: until, + environment: environment) + + 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/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..daa9fd048 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift @@ -0,0 +1,402 @@ +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 + + /// 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 + { + 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], + let url = URL(string: BedrockSettingsReader.cleaned(override) ?? "") + { + baseURL = url + } else { + baseURL = URL(string: "https://ce.\(ceRegion).amazonaws.com")! + } + + // 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, + "End": endDate, + ], + "Granularity": granularity, + "Metrics": ["UnblendedCost"], + "GroupBy": [ + ["Type": "DIMENSION", "Key": "SERVICE"], + ], + ] + + 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 (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 data + } + + // 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 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], + 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 + } + + // 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 = Self.dateFormatter() + 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..cf0d94a02 --- /dev/null +++ b/Tests/CodexBarTests/BedrockUsageStatsTests.swift @@ -0,0 +1,333 @@ +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"}, + "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"}} + } + ] + } + ] + } + """ + 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() {} +} 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)