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)