Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct OpenAIAPIProviderImplementation: ProviderImplementation {
@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.openAIAPIKey
_ = settings.openAIAPIProjectID
}

@MainActor
Expand Down Expand Up @@ -52,6 +53,28 @@ struct OpenAIAPIProviderImplementation: ProviderImplementation {
],
isVisible: nil,
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "openai-project-id",
title: "Project ID",
subtitle: "Optional. Applies to the configured Admin API key; selected token accounts do not " +
"inherit OPENAI_PROJECT_ID.",
kind: .plain,
placeholder: "proj_...",
binding: context.stringBinding(\.openAIAPIProjectID),
actions: [
ProviderSettingsActionDescriptor(
id: "openai-open-projects",
title: "Open projects",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://platform.openai.com/settings/organization/projects") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: nil,
onActivate: nil),
]
}
}
10 changes: 10 additions & 0 deletions Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,14 @@ extension SettingsStore {
self.logSecretUpdate(provider: .openai, field: "apiKey", value: newValue)
}
}

var openAIAPIProjectID: String {
get { self.configSnapshot.providerConfig(for: .openai)?.sanitizedWorkspaceID ?? "" }
set {
self.updateProviderConfig(provider: .openai) { entry in
entry.workspaceID = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .openai, field: "projectID", value: newValue)
}
}
}
29 changes: 21 additions & 8 deletions Sources/CodexBarCore/Config/CodexBarConfigValidation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ public struct CodexBarConfigIssue: Codable, Sendable, Equatable {
}

public enum CodexBarConfigValidator {
private static let workspaceIDProviders: [UsageProvider] = [
.azureopenai,
.openai,
.opencode,
.opencodego,
.deepgram,
]

public static func validate(_ config: CodexBarConfig) -> [CodexBarConfigIssue] {
var issues: [CodexBarConfigIssue] = []

Expand Down Expand Up @@ -138,8 +146,7 @@ public enum CodexBarConfigValidator {
provider: provider,
field: "workspaceID",
code: "workspace_unused",
message: "workspaceID is set but only azureopenai, opencode, opencodego, and deepgram support " +
"workspaceID."))
message: "workspaceID is set but only \(self.workspaceIDProviderList) support workspaceID."))
}

if let enterpriseHost = entry.enterpriseHost,
Expand Down Expand Up @@ -183,12 +190,18 @@ public enum CodexBarConfigValidator {
}

private static func providerSupportsWorkspaceID(_ provider: UsageProvider) -> Bool {
switch provider {
case .azureopenai, .opencode, .opencodego, .deepgram:
true
default:
false
}
self.workspaceIDProviders.contains(provider)
}

private static var workspaceIDProviderList: String {
self.formattedProviderList(self.workspaceIDProviders)
}

private static func formattedProviderList(_ providers: [UsageProvider]) -> String {
let names = providers.map(\.rawValue)
guard let last = names.last else { return "" }
guard names.count > 1 else { return last }
return "\(names.dropLast().joined(separator: ", ")), and \(last)"
}

private static func providerSupportsEnterpriseHost(_ provider: UsageProvider) -> Bool {
Expand Down
18 changes: 18 additions & 0 deletions Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ public enum ProviderConfigEnvironment {
provider: UsageProvider,
config: ProviderConfig?) -> [String: String]
{
if provider == .openai {
return self.applyOpenAIOverrides(base: base, config: config)
}
if provider == .bedrock {
return self.applyBedrockOverrides(base: base, config: config)
}
Expand Down Expand Up @@ -110,6 +113,21 @@ public enum ProviderConfigEnvironment {
}
}

private static func applyOpenAIOverrides(
base: [String: String],
config: ProviderConfig?) -> [String: String]
{
guard let config else { return base }
var env = base
if let apiKey = config.sanitizedAPIKey {
env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] = apiKey
}
if let projectID = config.sanitizedWorkspaceID {
env[OpenAIAPISettingsReader.projectIDEnvironmentKey] = projectID
}
return env
}

private static func applyBedrockOverrides(
base: [String: String],
config: ProviderConfig?) -> [String: String]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,12 @@ public enum OpenAIAPIProviderDescriptor {
struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy {
let id: String = "openai.api.balance"
let kind: ProviderFetchKind = .apiToken
let usageFetcher: @Sendable (String, Int) async throws -> OpenAIAPIUsageSnapshot
let usageFetcher: @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot
let balanceFetcher: @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot

init(
usageFetcher: @escaping @Sendable (String, Int) async throws -> OpenAIAPIUsageSnapshot = { apiKey, days in
try await OpenAIAPIUsageFetcher.fetchUsage(apiKey: apiKey, historyDays: days)
},
usageFetcher: @escaping @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot =
OpenAIAPIBalanceFetchStrategy.fetchUsage(credential:days:),
balanceFetcher: @escaping @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot = { apiKey in
try await OpenAIAPICreditBalanceFetcher.fetchBalance(apiKey: apiKey)
})
Expand All @@ -59,24 +58,27 @@ struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy {
}

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
Self.resolveToken(environment: context.env) != nil
OpenAIAPIUsageCredential(environment: context.env) != nil
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
guard let apiKey = Self.resolveToken(environment: context.env) else {
guard let credential = OpenAIAPIUsageCredential(environment: context.env) else {
throw OpenAIAPISettingsError.missingToken
}

do {
let usage = try await self.usageFetcher(apiKey, context.costUsageHistoryDays)
let usage = try await self.usageFetcher(credential, context.costUsageHistoryDays)
return self.makeResult(
usage: usage.toUsageSnapshot(),
sourceLabel: "admin-api")
sourceLabel: credential.sourceLabel)
} catch {
let usageError = error
// Preserve the older balance-only path for project/user keys and admin API outages.
if !credential.allowsLegacyBalanceFallback {
throw usageError
}
// Preserve the older balance-only path for unscoped keys and Admin API outages.
do {
let balance = try await self.balanceFetcher(apiKey)
let balance = try await self.balanceFetcher(credential.apiKey)
return self.makeResult(
usage: balance.toUsageSnapshot(),
sourceLabel: "billing-api")
Expand All @@ -93,7 +95,40 @@ struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy {
false
}

private static func resolveToken(environment: [String: String]) -> String? {
ProviderTokenResolver.openAIAPIToken(environment: environment)
private static func fetchUsage(
credential: OpenAIAPIUsageCredential,
days: Int) async throws -> OpenAIAPIUsageSnapshot
{
try await OpenAIAPIUsageFetcher.fetchUsage(
apiKey: credential.apiKey,
projectID: credential.projectID,
historyDays: days)
}
}

struct OpenAIAPIUsageCredential: Equatable, Sendable {
let apiKey: String
let projectID: String?
let usesAdminKey: Bool

init?(environment: [String: String]) {
if let adminKey = OpenAIAPISettingsReader.adminAPIKey(environment: environment) {
self.apiKey = adminKey
self.usesAdminKey = true
} else if let apiKey = OpenAIAPISettingsReader.apiKey(environment: environment) {
self.apiKey = apiKey
self.usesAdminKey = false
} else {
return nil
}
self.projectID = OpenAIAPISettingsReader.projectID(environment: environment)
}

var sourceLabel: String {
self.projectID == nil ? "admin-api" : "admin-api:project"
}

var allowsLegacyBalanceFallback: Bool {
self.projectID == nil || !self.usesAdminKey
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
public enum OpenAIAPISettingsReader {
public static let adminAPIKeyEnvironmentKey = "OPENAI_ADMIN_KEY"
public static let apiKeyEnvironmentKey = "OPENAI_API_KEY"
public static let projectIDEnvironmentKey = "OPENAI_PROJECT_ID"
public static let apiKeyEnvironmentKeys = [
Self.adminAPIKeyEnvironmentKey,
Self.apiKeyEnvironmentKey,
Expand All @@ -15,6 +16,14 @@ public enum OpenAIAPISettingsReader {
return nil
}

public static func adminAPIKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
self.cleaned(environment[self.adminAPIKeyEnvironmentKey])
}

public static func projectID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? {
self.cleaned(environment[self.projectIDEnvironmentKey])
}

static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
Expand Down
Loading