diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a6715e76..c6b5d55bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Tests: document and audit that routine validation must not trigger macOS Keychain prompts. ### Added +- AWS Bedrock: support resolving usage and cost-history credentials from a named AWS profile via the AWS CLI (#1190). Thanks @oleksandr-soldatov! - Codex: show Codex Spark model-specific usage as an optional extra quota lane (#1195, fixes #1177). Thanks @LeoLin990405! ### Fixed diff --git a/README.md b/README.md index bc6e872f52..23fdb1a6f0 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Crof](docs/crof.md) — API key for dollar credit balance and request quota tracking. - [Command Code](docs/command-code.md) — Browser cookies for monthly USD credits from Command Code billing. - [StepFun](docs/stepfun.md) — Username + password login for Step Plan rate limits (5‑hour + weekly windows) and subscription plan name. -- [AWS Bedrock](docs/bedrock.md) — AWS credentials for Cost Explorer usage and monthly budget tracking. +- [AWS Bedrock](docs/bedrock.md) — AWS access keys or a named AWS profile (SSO/assume-role via the AWS CLI) for Cost Explorer usage and monthly budget tracking. - [Grok](docs/grok.md) — Grok CLI billing RPC plus grok.com browser-session fallback. - [GroqCloud](docs/groqcloud.md) — API key for Enterprise Prometheus request/token/cache-hit metrics. - [LLM Proxy](docs/llm-proxy.md) — API key + base URL for aggregate proxy quota stats and provider breakdowns. diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift index 9de353706e..ecca9e1102 100644 --- a/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift @@ -15,6 +15,8 @@ struct BedrockProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { + _ = settings.bedrockAuthMode + _ = settings.bedrockProfile _ = settings.bedrockAccessKeyID _ = settings.bedrockSecretAccessKey _ = settings.bedrockRegion @@ -25,9 +27,43 @@ struct BedrockProviderImplementation: ProviderImplementation { BedrockSettingsReader.hasCredentials(environment: context.environment) } + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let binding = Binding( + get: { context.settings.bedrockAuthMode }, + set: { context.settings.bedrockAuthMode = $0 }) + let options = [ + ProviderSettingsPickerOption(id: BedrockAuthMode.keys.rawValue, title: "Access keys"), + ProviderSettingsPickerOption(id: BedrockAuthMode.profile.rawValue, title: "AWS profile"), + ] + return [ + ProviderSettingsPickerDescriptor( + id: "bedrock-auth-mode", + title: "Authentication", + subtitle: "Use static access keys, or resolve credentials from a named AWS profile " + + "(supports SSO and assume-role via the AWS CLI).", + binding: binding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - [ + let isKeysMode = { context.settings.bedrockAuthMode != BedrockAuthMode.profile.rawValue } + let isProfileMode = { context.settings.bedrockAuthMode == BedrockAuthMode.profile.rawValue } + return [ + ProviderSettingsFieldDescriptor( + id: "bedrock-profile", + title: "Profile name", + subtitle: "Named AWS profile from ~/.aws/config. Can also be set with AWS_PROFILE.", + kind: .plain, + placeholder: "default", + binding: context.stringBinding(\.bedrockProfile), + actions: [], + isVisible: isProfileMode, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "bedrock-access-key-id", title: "Access key ID", @@ -36,7 +72,7 @@ struct BedrockProviderImplementation: ProviderImplementation { placeholder: "AKIA...", binding: context.stringBinding(\.bedrockAccessKeyID), actions: [], - isVisible: nil, + isVisible: isKeysMode, onActivate: nil), ProviderSettingsFieldDescriptor( id: "bedrock-secret-access-key", @@ -46,12 +82,13 @@ struct BedrockProviderImplementation: ProviderImplementation { placeholder: "", binding: context.stringBinding(\.bedrockSecretAccessKey), actions: [], - isVisible: nil, + isVisible: isKeysMode, onActivate: nil), ProviderSettingsFieldDescriptor( id: "bedrock-region", title: "Region", - subtitle: "AWS region. Can also be set with AWS_REGION.", + subtitle: "AWS region. Can also be set with AWS_REGION. " + + "In profile mode, leave blank to use the profile's region.", kind: .plain, placeholder: "us-east-1", binding: context.stringBinding(\.bedrockRegion), diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift b/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift index 9d51fc995d..f01a570685 100644 --- a/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift +++ b/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift @@ -31,4 +31,28 @@ extension SettingsStore { self.logProviderModeChange(provider: .bedrock, field: "region", value: newValue) } } + + var bedrockAuthMode: String { + get { + self.configSnapshot.providerConfig(for: .bedrock)?.sanitizedAWSAuthMode + ?? BedrockAuthMode.keys.rawValue + } + set { + let normalized = BedrockAuthMode(rawValue: newValue)?.rawValue ?? BedrockAuthMode.keys.rawValue + self.updateProviderConfig(provider: .bedrock) { entry in + entry.awsAuthMode = normalized + } + self.logProviderModeChange(provider: .bedrock, field: "authMode", value: normalized) + } + } + + var bedrockProfile: String { + get { self.configSnapshot.providerConfig(for: .bedrock)?.awsProfile ?? "" } + set { + self.updateProviderConfig(provider: .bedrock) { entry in + entry.awsProfile = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .bedrock, field: "profile", value: newValue) + } + } } diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 57ef8e2215..4c1910bd6e 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -89,6 +89,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var quotaWarnings: QuotaWarningConfig? public var kiloKnownOrganizations: [KiloOrganization]? public var kiloEnabledOrganizationIDs: [String]? + public var awsProfile: String? + public var awsAuthMode: String? public init( id: UsageProvider, @@ -106,7 +108,9 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { codexActiveSource: CodexActiveSource? = nil, quotaWarnings: QuotaWarningConfig? = nil, kiloKnownOrganizations: [KiloOrganization]? = nil, - kiloEnabledOrganizationIDs: [String]? = nil) + kiloEnabledOrganizationIDs: [String]? = nil, + awsProfile: String? = nil, + awsAuthMode: String? = nil) { self.id = id self.enabled = enabled @@ -124,6 +128,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.quotaWarnings = quotaWarnings self.kiloKnownOrganizations = kiloKnownOrganizations self.kiloEnabledOrganizationIDs = kiloEnabledOrganizationIDs + self.awsProfile = awsProfile + self.awsAuthMode = awsAuthMode } public var sanitizedAPIKey: String? { @@ -150,6 +156,14 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.enterpriseHost) } + public var sanitizedAWSProfile: String? { + Self.clean(self.awsProfile) + } + + public var sanitizedAWSAuthMode: String? { + Self.clean(self.awsAuthMode) + } + private static func clean(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 33d85f66cc..896a47ef43 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -134,15 +134,54 @@ public enum ProviderConfigEnvironment { { guard let config else { return base } var env = base - if let accessKeyID = config.sanitizedAPIKey { - env[BedrockSettingsReader.accessKeyIDKey] = accessKeyID + + // Only project an explicit auth-mode selection. When the config does not + // specify one, leave the base environment untouched so an env-driven setup + // (AWS_PROFILE or CODEXBAR_BEDROCK_AUTH_MODE from the launch environment) is + // still inferred by BedrockSettingsReader instead of being forced to `keys`. + let configMode = config.sanitizedAWSAuthMode.flatMap(BedrockAuthMode.init(rawValue:)) + if let configMode { + env[BedrockSettingsReader.authModeKey] = configMode.rawValue + } + let baseMode = BedrockSettingsReader + .cleaned(base[BedrockSettingsReader.authModeKey]) + .flatMap { BedrockAuthMode(rawValue: $0.lowercased()) } + + let mergedAccessKey = config.sanitizedAPIKey ?? BedrockSettingsReader.accessKeyID(environment: base) + let mergedSecretKey = config.sanitizedSecretKey ?? BedrockSettingsReader.secretAccessKey(environment: base) + let hasMergedStaticKeys = mergedAccessKey != nil && mergedSecretKey != nil + let effectiveMode: BedrockAuthMode = if let configMode { + configMode + } else if let baseMode { + baseMode + } else if hasMergedStaticKeys { + // Upgrade path: a config saved before auth modes existed keeps using + // static credentials (including env+config layering) even if AWS_PROFILE + // is present in the base environment, so existing users are never + // silently switched to a profile/account. + .keys + } else { + BedrockSettingsReader.authMode(environment: base) } - if let secretAccessKey = config.sanitizedSecretKey { - env[BedrockSettingsReader.secretAccessKeyKey] = secretAccessKey + + switch effectiveMode { + case .profile: + if let profile = config.sanitizedAWSProfile { + env[BedrockSettingsReader.profileKey] = profile + } + case .keys: + if let accessKeyID = config.sanitizedAPIKey { + env[BedrockSettingsReader.accessKeyIDKey] = accessKeyID + } + if let secretAccessKey = config.sanitizedSecretKey { + env[BedrockSettingsReader.secretAccessKeyKey] = secretAccessKey + } } + if let region = config.sanitizedRegion { env[BedrockSettingsReader.regionKeys[0]] = region } + return env } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f9413e6516..9a09e2d7d6 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -145,17 +145,9 @@ public struct CostUsageFetcher: Sendable { since: Date, until: Date) async throws -> CostUsageDailyReport { - 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 resolved = try await BedrockCredentialResolver.resolve(environment: environment) return try await BedrockUsageFetcher.fetchDailyReport( - credentials: credentials, + credentials: resolved.credentials, since: since, until: until, environment: environment) diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index b79d2d8404..05f99cb465 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -166,6 +166,37 @@ public enum BinaryLocator { ] } + public static func resolveAWSBinary( + env: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current, + commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, + aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator + .resolveAlias, + fileManager: FileManager = .default, + home: String = NSHomeDirectory()) -> String? + { + self.resolveBinary( + name: "aws", + overrideKey: "AWS_CLI_PATH", + env: env, + loginPATH: loginPATH, + commandV: commandV, + aliasResolver: aliasResolver, + wellKnownPaths: self.awsWellKnownPaths(home: home), + fileManager: fileManager, + home: home) + } + + /// Well-known install locations for the AWS CLI v2 (`aws`). + /// Covers Homebrew (Apple Silicon + Intel) and the per-user pip/uv install path. + static func awsWellKnownPaths(home: String) -> [String] { + [ + "/opt/homebrew/bin/aws", + "/usr/local/bin/aws", + "\(home)/.local/bin/aws", + ] + } + public static func resolveAuggieBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockCredentialResolver.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockCredentialResolver.swift new file mode 100644 index 0000000000..c5f3cac136 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockCredentialResolver.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Resolves Bedrock signing credentials + region for the configured auth mode. +/// +/// Shared by the main usage fetch (`BedrockAPIFetchStrategy`) and the daily +/// cost-history refresh (`CostUsageFetcher`) so both honor AWS-profile auth +/// identically instead of the history path silently requiring static keys. +enum BedrockCredentialResolver { + struct Resolved { + let credentials: BedrockAWSSigner.Credentials + let region: String + } + + static func resolve( + environment: [String: String], + resolveAWSBinary: ([String: String]) -> String? = { BinaryLocator.resolveAWSBinary(env: $0) }, + makeProvider: (String) -> BedrockProfileCredentialProvider = { + BedrockProfileCredentialProvider.live(awsBinaryPath: $0) + }) async throws -> Resolved + { + switch BedrockSettingsReader.authMode(environment: environment) { + case .keys: + 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)) + return Resolved( + credentials: credentials, + region: BedrockSettingsReader.region(environment: environment)) + + case .profile: + guard let profile = BedrockSettingsReader.profile(environment: environment) else { + throw BedrockUsageError.missingCredentials + } + guard let awsBinary = resolveAWSBinary(environment) else { + throw BedrockUsageError.awsCLINotFound + } + let cliEnvironment = Self.profileCLIEnvironment(environment) + let provider = makeProvider(awsBinary) + let credentials = try await provider.exportCredentials(profile: profile, environment: cliEnvironment) + let region = try await Self.resolveRegion( + provider: provider, + profile: profile, + environment: cliEnvironment) + return Resolved(credentials: credentials, region: region) + } + } + + private static func profileCLIEnvironment(_ environment: [String: String]) -> [String: String] { + var cliEnvironment = environment + // `--profile` selects the requested profile. Leaving AWS_PROFILE set can + // break assume-role profiles that use `credential_source = Environment`; + // keep the source credential variables themselves available. + cliEnvironment[BedrockSettingsReader.profileKey] = nil + return cliEnvironment + } + + private static func resolveRegion( + provider: BedrockProfileCredentialProvider, + profile: String, + environment: [String: String]) async throws -> String + { + if let explicit = BedrockSettingsReader.cleaned(environment[BedrockSettingsReader.regionKeys[0]]) + ?? BedrockSettingsReader.cleaned(environment[BedrockSettingsReader.regionKeys[1]]) + { + return explicit + } + if let derived = try await provider.resolveRegion(profile: profile, environment: environment) { + return derived + } + return BedrockSettingsReader.defaultRegion + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift new file mode 100644 index 0000000000..0e50f5cfe1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Resolves AWS credentials for a named profile by shelling out to the AWS CLI. +/// +/// Uses `aws configure export-credentials`, which transparently resolves static +/// credentials, SSO sessions, assume-role chains, and `credential_process` profiles. +/// The runner is injected so tests never invoke a real `aws` binary. +struct BedrockProfileCredentialProvider: Sendable { + typealias Runner = @Sendable ( + _ arguments: [String], + _ environment: [String: String]) async throws -> SubprocessResult + + let awsBinaryPath: String + let run: Runner + + /// Production provider that drives the real `aws` binary via `SubprocessRunner`. + static func live(awsBinaryPath: String) -> BedrockProfileCredentialProvider { + BedrockProfileCredentialProvider(awsBinaryPath: awsBinaryPath) { arguments, environment in + try await SubprocessRunner.run( + binary: awsBinaryPath, + arguments: arguments, + environment: environment, + timeout: 20, + label: "aws-bedrock-credentials") + } + } + + func exportCredentials( + profile: String, + environment: [String: String] = [:]) async throws -> BedrockAWSSigner.Credentials + { + let result: SubprocessResult + do { + result = try await self.run( + ["configure", "export-credentials", "--profile", profile, "--format", "process"], + environment) + } catch let SubprocessRunnerError.nonZeroExit(_, stderr) { + throw Self.mapExportError(stderr: stderr, profile: profile) + } + + guard let data = result.stdout.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessKeyID = Self.nonEmpty(json["AccessKeyId"] as? String), + let secretAccessKey = Self.nonEmpty(json["SecretAccessKey"] as? String) + else { + throw BedrockUsageError.parseFailed("Could not parse AWS CLI export-credentials output") + } + + return BedrockAWSSigner.Credentials( + accessKeyID: accessKeyID, + secretAccessKey: secretAccessKey, + sessionToken: Self.nonEmpty(json["SessionToken"] as? String)) + } + + /// Returns the profile's configured region, or `nil` when unset. + /// `aws configure get region` exits non-zero when the value is not configured, + /// which is a normal case rather than an error. + func resolveRegion( + profile: String, + environment: [String: String] = [:]) async throws -> String? + { + do { + let result = try await self.run( + ["configure", "get", "region", "--profile", profile], + environment) + return Self.nonEmpty(result.stdout) + } catch SubprocessRunnerError.nonZeroExit { + return nil + } + } + + static func mapExportError(stderr: String, profile: String) -> BedrockUsageError { + let lower = stderr.lowercased() + if lower.contains("sso login") || lower.contains("expired") || lower.contains("token has expired") { + return .profileSessionExpired(profile) + } + let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return .apiError(trimmed.isEmpty ? "AWS CLI failed to export credentials" : trimmed) + } + + private static func nonEmpty(_ raw: String?) -> String? { + guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift index ed65f00c1a..deaf600867 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift @@ -30,7 +30,9 @@ public enum BedrockProviderDescriptor { color: ProviderColor(red: 1, green: 0.6, blue: 0)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, - noDataMessage: { "No AWS Bedrock cost data available. Check your AWS credentials." }), + noDataMessage: { "No AWS Bedrock cost data available. Check your AWS access keys " + + "or profile, and that the AWS CLI is installed for profile auth." + }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [BedrockAPIFetchStrategy()] })), @@ -46,26 +48,20 @@ struct BedrockAPIFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .apiToken func isAvailable(_ context: ProviderFetchContext) async -> Bool { - BedrockSettingsReader.hasCredentials(environment: context.env) + switch BedrockSettingsReader.authMode(environment: context.env) { + case .keys: + BedrockSettingsReader.hasCredentials(environment: context.env) + case .profile: + BedrockSettingsReader.profile(environment: context.env) != nil + } } 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 resolved = try await BedrockCredentialResolver.resolve(environment: context.env) let budget = BedrockSettingsReader.budget(environment: context.env) - let usage = try await BedrockUsageFetcher.fetchUsage( - credentials: credentials, - region: region, + credentials: resolved.credentials, + region: resolved.region, budget: budget, environment: context.env) return self.makeResult( diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift index 4959d8b164..aa1a6447fe 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift @@ -1,5 +1,10 @@ import Foundation +public enum BedrockAuthMode: String, Codable, Sendable, CaseIterable { + case keys + case profile +} + public enum BedrockSettingsReader { public static let accessKeyIDKey = "AWS_ACCESS_KEY_ID" public static let secretAccessKeyKey = "AWS_SECRET_ACCESS_KEY" @@ -7,6 +12,8 @@ public enum BedrockSettingsReader { public static let regionKeys = ["AWS_REGION", "AWS_DEFAULT_REGION"] public static let budgetKey = "CODEXBAR_BEDROCK_BUDGET" public static let apiURLKey = "CODEXBAR_BEDROCK_API_URL" + public static let profileKey = "AWS_PROFILE" + public static let authModeKey = "CODEXBAR_BEDROCK_AUTH_MODE" public static let defaultRegion = "us-east-1" public static func accessKeyID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { @@ -44,13 +51,44 @@ public enum BedrockSettingsReader { return value } - public static func hasCredentials( - environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + public static func profile( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.profileKey]) + } + + public static func authMode( + environment: [String: String] = ProcessInfo.processInfo.environment) -> BedrockAuthMode + { + if let raw = self.cleaned(environment[self.authModeKey])?.lowercased(), + let mode = BedrockAuthMode(rawValue: raw) + { + return mode + } + if self.profile(environment: environment) != nil, + !self.hasStaticKeys(environment: environment) + { + return .profile + } + return .keys + } + + static func hasStaticKeys(environment: [String: String]) -> Bool { self.accessKeyID(environment: environment) != nil && self.secretAccessKey(environment: environment) != nil } + public static func hasCredentials( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + switch self.authMode(environment: environment) { + case .keys: + self.hasStaticKeys(environment: environment) + case .profile: + self.profile(environment: environment) != nil + } + } + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift index b9e8124942..f2c447177e 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift @@ -416,8 +416,10 @@ enum BedrockUsageFetcher { } } -public enum BedrockUsageError: LocalizedError, Sendable { +public enum BedrockUsageError: LocalizedError, Sendable, Equatable { case missingCredentials + case awsCLINotFound + case profileSessionExpired(String) case networkError(String) case apiError(String) case parseFailed(String) @@ -427,6 +429,10 @@ public enum BedrockUsageError: LocalizedError, Sendable { case .missingCredentials: "AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY " + "or configure Bedrock in Settings." + case .awsCLINotFound: + "AWS CLI not found. Install the AWS CLI (v2) or set AWS_CLI_PATH to its location." + case let .profileSessionExpired(profile): + "AWS profile session expired. Run `aws sso login --profile \(profile)` and try again." case let .networkError(message): "AWS Bedrock network error: \(message)" case let .apiError(message): diff --git a/Tests/CodexBarTests/BedrockCredentialResolverTests.swift b/Tests/CodexBarTests/BedrockCredentialResolverTests.swift new file mode 100644 index 0000000000..62db841e99 --- /dev/null +++ b/Tests/CodexBarTests/BedrockCredentialResolverTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private final class CapturedEnvironment: @unchecked Sendable { + private let lock = NSLock() + private var stored: [String: String] = [:] + func record(_ environment: [String: String]) { + self.lock.withLock { self.stored = environment } + } + + var value: [String: String] { + self.lock.withLock { self.stored } + } +} + +@Suite(.serialized) +struct BedrockCredentialResolverTests { + private static let credentialsJSON = #""" + {"Version":1,"AccessKeyId":"AKIAPROFILE","SecretAccessKey":"profile-secret","SessionToken":"profile-token"} + """# + + /// Fake AWS CLI runner: returns exported credentials and a profile region. + private func profileProvider(region: String = "ap-southeast-2") -> BedrockProfileCredentialProvider { + BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { arguments, _ in + if arguments.contains("export-credentials") { + return SubprocessResult(stdout: Self.credentialsJSON, stderr: "") + } + if arguments.contains("get") { + return SubprocessResult(stdout: region + "\n", stderr: "") + } + return SubprocessResult(stdout: "", stderr: "") + } + } + + @Test + func `keys mode resolves static credentials and region`() async throws { + let env = [ + BedrockSettingsReader.accessKeyIDKey: "AKIAKEYS", + BedrockSettingsReader.secretAccessKeyKey: "keys-secret", + BedrockSettingsReader.regionKeys[0]: "us-west-2", + ] + let resolved = try await BedrockCredentialResolver.resolve(environment: env) + #expect(resolved.credentials.accessKeyID == "AKIAKEYS") + #expect(resolved.credentials.secretAccessKey == "keys-secret") + #expect(resolved.region == "us-west-2") + } + + @Test + func `keys mode without credentials throws missingCredentials`() async { + await #expect(throws: BedrockUsageError.missingCredentials) { + try await BedrockCredentialResolver.resolve(environment: [:]) + } + } + + @Test + func `profile mode resolves credentials via the AWS CLI`() async throws { + let env = [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + ] + let resolved = try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in "/usr/bin/aws" }, + makeProvider: { _ in self.profileProvider() }) + #expect(resolved.credentials.accessKeyID == "AKIAPROFILE") + #expect(resolved.credentials.sessionToken == "profile-token") + // No explicit region in env, so it is derived from the profile. + #expect(resolved.region == "ap-southeast-2") + } + + @Test + func `profile mode prefers explicit region over the profile region`() async throws { + let env = [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + BedrockSettingsReader.regionKeys[0]: "eu-central-1", + ] + let resolved = try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in "/usr/bin/aws" }, + makeProvider: { _ in self.profileProvider() }) + #expect(resolved.region == "eu-central-1") + } + + @Test + func `profile mode without a profile name throws missingCredentials`() async { + let env = [BedrockSettingsReader.authModeKey: "profile"] + await #expect(throws: BedrockUsageError.missingCredentials) { + try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in "/usr/bin/aws" }, + makeProvider: { _ in self.profileProvider() }) + } + } + + @Test + func `profile mode preserves source credentials but removes AWS_PROFILE for AWS CLI`() async throws { + let captured = CapturedEnvironment() + let env = [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + BedrockSettingsReader.accessKeyIDKey: "AKIAINHERITED", + BedrockSettingsReader.secretAccessKeyKey: "inherited-secret", + BedrockSettingsReader.sessionTokenKey: "inherited-token", + ] + _ = try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in "/usr/bin/aws" }, + makeProvider: { _ in + BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { arguments, environment in + captured.record(environment) + if arguments.contains("export-credentials") { + return SubprocessResult(stdout: Self.credentialsJSON, stderr: "") + } + return SubprocessResult(stdout: "us-east-1\n", stderr: "") + } + }) + let seen = captured.value + #expect(seen[BedrockSettingsReader.accessKeyIDKey] == "AKIAINHERITED") + #expect(seen[BedrockSettingsReader.secretAccessKeyKey] == "inherited-secret") + #expect(seen[BedrockSettingsReader.sessionTokenKey] == "inherited-token") + #expect(seen[BedrockSettingsReader.profileKey] == nil) + } + + @Test + func `profile mode without the AWS CLI throws awsCLINotFound`() async { + let env = [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + ] + await #expect(throws: BedrockUsageError.awsCLINotFound) { + try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in nil }, + makeProvider: { _ in self.profileProvider() }) + } + } +} diff --git a/Tests/CodexBarTests/BedrockProfileCredentialProviderTests.swift b/Tests/CodexBarTests/BedrockProfileCredentialProviderTests.swift new file mode 100644 index 0000000000..f8861bcd2d --- /dev/null +++ b/Tests/CodexBarTests/BedrockProfileCredentialProviderTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct BedrockProfileCredentialProviderTests { + private func provider( + stdout: String = "", + stderr: String = "", + throwsNonZero: Bool = false) -> BedrockProfileCredentialProvider + { + BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { _, _ in + if throwsNonZero { + throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: stderr) + } + return SubprocessResult(stdout: stdout, stderr: stderr) + } + } + + @Test + func `parses export-credentials json with session token`() async throws { + let json = """ + {"Version":1,"AccessKeyId":"AKIA","SecretAccessKey":"secret",\ + "SessionToken":"token","Expiration":"2026-05-27T12:00:00Z"} + """ + let creds = try await provider(stdout: json).exportCredentials(profile: "work") + #expect(creds.accessKeyID == "AKIA") + #expect(creds.secretAccessKey == "secret") + #expect(creds.sessionToken == "token") + } + + @Test + func `parses export-credentials json without session token`() async throws { + let json = #"{"Version":1,"AccessKeyId":"AKIA","SecretAccessKey":"secret"}"# + let creds = try await provider(stdout: json).exportCredentials(profile: "work") + #expect(creds.accessKeyID == "AKIA") + #expect(creds.sessionToken == nil) + } + + @Test + func `maps expired SSO stderr to profileSessionExpired`() async { + let stderr = "The SSO session associated with this profile has expired. " + + "To refresh this SSO session run aws sso login with the corresponding profile." + let sut = self.provider(stderr: stderr, throwsNonZero: true) + await #expect(throws: BedrockUsageError.profileSessionExpired("work")) { + try await sut.exportCredentials(profile: "work") + } + } + + @Test + func `maps other non-zero exit to apiError`() async { + let sut = self.provider(stderr: "The config profile (work) could not be found", throwsNonZero: true) + do { + _ = try await sut.exportCredentials(profile: "work") + Issue.record("expected an error") + } catch let error as BedrockUsageError { + if case .apiError = error { } else { Issue.record("expected apiError, got \(error)") } + } catch { + Issue.record("unexpected error type: \(error)") + } + } + + @Test + func `malformed json throws parseFailed`() async { + let sut = self.provider(stdout: "not json") + do { + _ = try await sut.exportCredentials(profile: "work") + Issue.record("expected an error") + } catch let error as BedrockUsageError { + if case .parseFailed = error { } else { Issue.record("expected parseFailed, got \(error)") } + } catch { + Issue.record("unexpected error type: \(error)") + } + } + + @Test + func `resolveRegion returns trimmed value`() async throws { + let region = try await provider(stdout: "eu-west-1\n").resolveRegion(profile: "work") + #expect(region == "eu-west-1") + } + + @Test + func `resolveRegion returns nil when unset (non-zero exit)`() async throws { + let region = try await provider(throwsNonZero: true).resolveRegion(profile: "work") + #expect(region == nil) + } + + @Test + func `resolveRegion returns nil for empty output`() async throws { + let region = try await provider(stdout: "\n").resolveRegion(profile: "work") + #expect(region == nil) + } +} diff --git a/Tests/CodexBarTests/BedrockSettingsFlowTests.swift b/Tests/CodexBarTests/BedrockSettingsFlowTests.swift index f14a3ca32d..526eeb80f9 100644 --- a/Tests/CodexBarTests/BedrockSettingsFlowTests.swift +++ b/Tests/CodexBarTests/BedrockSettingsFlowTests.swift @@ -70,4 +70,34 @@ struct BedrockSettingsFlowTests { settings: settings, environment: env))) } + + @Test + func `profile mode maps profile into provider environment and is available`() throws { + let suite = "BedrockSettingsFlowTests-profile-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + settings.bedrockAuthMode = BedrockAuthMode.profile.rawValue + settings.bedrockProfile = "work" + + let config = try #require(settings.providerConfig(for: .bedrock)) + #expect(config.sanitizedAWSAuthMode == "profile") + #expect(config.sanitizedAWSProfile == "work") + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .bedrock, + settings: settings, + tokenOverride: nil) + + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(BedrockSettingsReader.hasCredentials(environment: env)) + } } diff --git a/Tests/CodexBarTests/BedrockSettingsReaderTests.swift b/Tests/CodexBarTests/BedrockSettingsReaderTests.swift new file mode 100644 index 0000000000..e8a119a1cc --- /dev/null +++ b/Tests/CodexBarTests/BedrockSettingsReaderTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct BedrockSettingsReaderTests { + @Test + func `default auth mode is keys`() { + #expect(BedrockSettingsReader.authMode(environment: [:]) == .keys) + } + + @Test + func `explicit profile auth mode wins`() { + let env = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile"] + #expect(BedrockSettingsReader.authMode(environment: env) == .profile) + } + + @Test + func `AWS_PROFILE without keys implies profile mode`() { + let env = ["AWS_PROFILE": "work"] + #expect(BedrockSettingsReader.authMode(environment: env) == .profile) + #expect(BedrockSettingsReader.profile(environment: env) == "work") + } + + @Test + func `AWS_PROFILE alongside static keys keeps keys mode`() { + let env = [ + "AWS_PROFILE": "work", + "AWS_ACCESS_KEY_ID": "AKIA", + "AWS_SECRET_ACCESS_KEY": "secret", + ] + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `hasCredentials in profile mode requires a profile name`() { + let withProfile = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile", "AWS_PROFILE": "work"] + let withoutProfile = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile"] + #expect(BedrockSettingsReader.hasCredentials(environment: withProfile)) + #expect(!BedrockSettingsReader.hasCredentials(environment: withoutProfile)) + } + + @Test + func `hasCredentials in keys mode requires both keys`() { + let both = ["AWS_ACCESS_KEY_ID": "AKIA", "AWS_SECRET_ACCESS_KEY": "secret"] + let onlyAccess = ["AWS_ACCESS_KEY_ID": "AKIA"] + #expect(BedrockSettingsReader.hasCredentials(environment: both)) + #expect(!BedrockSettingsReader.hasCredentials(environment: onlyAccess)) + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index de81c9f204..c2bf3a36ef 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -196,6 +196,130 @@ struct ProviderConfigEnvironmentTests { #expect(BedrockSettingsReader.hasCredentials(environment: env)) } + @Test + func `bedrock merged static credentials win over inherited AWS_PROFILE`() { + let config = ProviderConfig( + id: .bedrock, + secretKey: "config-secret", + region: "eu-central-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.profileKey: "work", + BedrockSettingsReader.accessKeyIDKey: "env-access", + ], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "env-access") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "config-secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-central-1") + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `bedrock profile mode projects AWS_PROFILE without saved static keys`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + region: "eu-west-1", + awsProfile: "work", + awsAuthMode: "profile") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-west-1") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + } + + @Test + func `bedrock config without explicit mode preserves env profile inference`() { + let config = ProviderConfig(id: .bedrock, region: "us-east-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.profileKey: "work"], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == nil) + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(BedrockSettingsReader.authMode(environment: env) == .profile) + } + + @Test + func `bedrock saved static keys survive base AWS_PROFILE when auth mode is unset`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIASAVED", + secretKey: "saved-secret", + region: "us-east-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.profileKey: "work"], + provider: .bedrock, + config: config) + // Upgrade path: saved keys win over an inherited AWS_PROFILE, no silent switch. + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIASAVED") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "saved-secret") + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `bedrock profile mode preserves inherited static credentials for environment source profiles`() { + let config = ProviderConfig(id: .bedrock, awsProfile: "work", awsAuthMode: "profile") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.accessKeyIDKey: "AKIAINHERITED", + BedrockSettingsReader.secretAccessKeyKey: "inherited-secret", + BedrockSettingsReader.sessionTokenKey: "inherited-token", + ], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIAINHERITED") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "inherited-secret") + #expect(env[BedrockSettingsReader.sessionTokenKey] == "inherited-token") + #expect(env[BedrockSettingsReader.profileKey] == "work") + } + + @Test + func `bedrock env profile mode does not project saved static credentials`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIASAVED", + secretKey: "saved-secret") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + ], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + } + + @Test + func `bedrock keys mode still projects static credentials`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + region: "us-west-2", + awsAuthMode: "keys") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == "keys") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret") + #expect(env[BedrockSettingsReader.profileKey] == nil) + } + @Test func `ignores legacy API key override for deepseek`() { let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") diff --git a/docs/bedrock.md b/docs/bedrock.md index 7a8237f6da..6cb6d7fa42 100644 --- a/docs/bedrock.md +++ b/docs/bedrock.md @@ -10,9 +10,13 @@ read_when: CodexBar reads AWS Cost Explorer for Bedrock spend and can compare the current month against an optional budget. -## Setup +## Authentication -Provide AWS credentials through the environment inherited by CodexBar or the CLI: +CodexBar supports two authentication modes, selected in Preferences → Providers → AWS Bedrock → Authentication. + +### Access keys (default) + +Provide static AWS credentials through Settings or the environment inherited by CodexBar/the CLI: ```bash export AWS_ACCESS_KEY_ID="..." @@ -27,7 +31,32 @@ export AWS_SESSION_TOKEN="..." export CODEXBAR_BEDROCK_BUDGET="250" ``` -The AWS identity must have permission to call Cost Explorer APIs, including `ce:GetCostAndUsage`. +### AWS profile + +Resolve credentials from a named profile in `~/.aws/config` / `~/.aws/credentials` instead of pasting keys. Set the +profile name in Settings (or via `AWS_PROFILE`). CodexBar shells out to the AWS CLI +(`aws configure export-credentials --profile `), so this works with **SSO**, **assume-role**, +`credential_process`, and MFA-cached profiles — not just static credentials. + +Requirements: + +- AWS CLI v2 on your `PATH` (CodexBar also checks `/opt/homebrew/bin/aws`, `/usr/local/bin/aws`, and `~/.local/bin/aws`). + Override the location with `AWS_CLI_PATH` if it lives elsewhere. +- For SSO profiles, an active session (`aws sso login --profile `). Credentials are resolved fresh on each + refresh; the AWS CLI caches the SSO token, so this does not re-prompt unless the session has expired. + +The profile's region is read automatically (`aws configure get region`); leave the Region field blank to use it, or set +`AWS_REGION` / the Region field to override. + +Relevant environment variables: + +```bash +export CODEXBAR_BEDROCK_AUTH_MODE="profile" # set automatically by Settings; "keys" or "profile" +export AWS_PROFILE="work" +export AWS_CLI_PATH="/opt/homebrew/bin/aws" # optional override +``` + +The AWS identity (from either mode) must have permission to call Cost Explorer APIs, including `ce:GetCostAndUsage`. ## Data source @@ -58,6 +87,17 @@ codexbar --provider bedrock --format json --pretty - Confirm the AWS account has Cost Explorer enabled. - Confirm the IAM principal can call `ce:GetCostAndUsage`. - If using temporary credentials, include `AWS_SESSION_TOKEN`. +- In profile mode, confirm the AWS CLI is installed (or set `AWS_CLI_PATH`) and that the profile name is correct. + +### "AWS profile session expired" + +The profile's SSO/temporary session has expired. Run `aws sso login --profile ` (or refresh the underlying +credentials) and retry. + +### "AWS CLI not found" + +Profile mode requires AWS CLI v2. Install it (e.g. `brew install awscli`) or point CodexBar at the binary with +`AWS_CLI_PATH`. ### Wrong region @@ -67,5 +107,6 @@ Set `AWS_REGION` or `AWS_DEFAULT_REGION`. Bedrock usage is regional, but Cost Ex - `Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift` - `Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift` - `Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift` - `Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift`