Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ struct BedrockProviderImplementation: ProviderImplementation {

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.bedrockAuthMode
_ = settings.bedrockProfile
_ = settings.bedrockAccessKeyID
_ = settings.bedrockSecretAccessKey
_ = settings.bedrockRegion
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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),
Expand Down
24 changes: 24 additions & 0 deletions Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
16 changes: 15 additions & 1 deletion Sources/CodexBarCore/Config/CodexBarConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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? {
Expand All @@ -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
Expand Down
23 changes: 18 additions & 5 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,15 +134,28 @@ public enum ProviderConfigEnvironment {
{
guard let config else { return base }
var env = base
if let accessKeyID = config.sanitizedAPIKey {
env[BedrockSettingsReader.accessKeyIDKey] = accessKeyID
}
if let secretAccessKey = config.sanitizedSecretKey {
env[BedrockSettingsReader.secretAccessKeyKey] = secretAccessKey

let mode = BedrockAuthMode(rawValue: config.sanitizedAWSAuthMode ?? "") ?? .keys
env[BedrockSettingsReader.authModeKey] = mode.rawValue
Comment thread
oleksandr-soldatov marked this conversation as resolved.
Outdated

switch mode {
case .profile:
if let profile = config.sanitizedAWSProfile {
env[BedrockSettingsReader.profileKey] = profile
}
Comment thread
oleksandr-soldatov marked this conversation as resolved.
Outdated
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
}

Expand Down
31 changes: 31 additions & 0 deletions Sources/CodexBarCore/PathEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()] })),
Expand All @@ -46,23 +48,53 @@ 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
&& BinaryLocator.resolveAWSBinary(env: 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
let region: String

switch BedrockSettingsReader.authMode(environment: context.env) {
case .keys:
guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: context.env),
let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: context.env)
else {
throw BedrockUsageError.missingCredentials
}
credentials = BedrockAWSSigner.Credentials(
accessKeyID: accessKeyID,
secretAccessKey: secretAccessKey,
sessionToken: BedrockSettingsReader.sessionToken(environment: context.env))
region = BedrockSettingsReader.region(environment: context.env)

case .profile:
guard let profile = BedrockSettingsReader.profile(environment: context.env) else {
throw BedrockUsageError.missingCredentials
}
guard let awsBinary = BinaryLocator.resolveAWSBinary(env: context.env) else {
throw BedrockUsageError.awsCLINotFound
}
let resolver = BedrockProfileCredentialProvider.live(awsBinaryPath: awsBinary)
credentials = try await resolver.exportCredentials(profile: profile, environment: context.env)
if let explicit = BedrockSettingsReader.cleaned(context.env[BedrockSettingsReader.regionKeys[0]])
?? BedrockSettingsReader.cleaned(context.env[BedrockSettingsReader.regionKeys[1]])
{
region = explicit
} else if let derived = try await resolver.resolveRegion(profile: profile, environment: context.env) {
region = derived
} else {
region = BedrockSettingsReader.defaultRegion
}
}

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,
Expand Down
Loading