Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ docs/.astro/
# Swift Package Manager metadata (leave sources tracked)
# Packages/
# Package.resolved

# Claude
.claude
2 changes: 1 addition & 1 deletion Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
Original file line number Diff line number Diff line change
@@ -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),
]
}
}
37 changes: 37 additions & 0 deletions Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ enum ProviderImplementationRegistry {
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
case .perplexity: PerplexityProviderImplementation()
case .bedrock: BedrockProviderImplementation()
}
}

Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-bedrock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand All @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCLI/TokenAccountCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
16 changes: 16 additions & 0 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 36 additions & 4 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Logging/LogCategories.swift
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading