Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
47 changes: 43 additions & 4 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
12 changes: 2 additions & 10 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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,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
}
}
Loading