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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
## 0.48.0 — Unreleased

### Added
- Fireworks: new provider showing 30-day spend from the account billing summary API, with Settings fields for the API key and account slug (Fireworks has no whoami endpoint and no public balance API).
- CLI: expose Codex cost-history completeness in JSON and add an experimental provider-native-only scan mode (#2520). Thanks @NickGuAI!
- CLI: add a built-in auto-refreshing web dashboard at `/` to `codexbar serve`.
- CLI: the serve web dashboard renders per-account sections for claude-swap providers, and `codexbar serve --identity full` opts authorized dashboard clients into real account emails on trusted, private networks.
- CLI: the serve web dashboard gains daily spend bar charts from local cost history, real provider brand icons served from an embedded `/icons/` route, and a grouped vertical layout — multi-account providers render one card per account under a titled section.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
- [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking.
- [Mistral](docs/mistral.md) — Browser cookies for API spend, credit balance, and monthly-plan usage.
- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown).
- [Fireworks](docs/fireworks.md) — API key + account slug for 30-day spend from the billing summary API.
- [DeepInfra](docs/deepinfra.md) — API key for prepaid balance, current-month spend, and spending-limit tracking.
- [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking.
- [Venice](docs/venice.md) — API key for DIEM or USD balance tracking.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import AppKit
import CodexBarCore
import Foundation
import SwiftUI

struct FireworksProviderImplementation: ProviderImplementation {
let id: UsageProvider = .fireworks

@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { _ in "api" }
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.fireworksAPIToken
_ = settings.fireworksAccountSlug
}

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext)
-> ProviderSettingsSnapshotContribution?
{
.fireworks(context.settings.fireworksSettingsSnapshot())
}

@MainActor
func isAvailable(context: ProviderAvailabilityContext) -> Bool {
if FireworksSettingsReader.apiKey(environment: context.environment) != nil,
FireworksSettingsReader.accountSlug(environment: context.environment) != nil
{
return true
}
return context.settings.hasFireworksCredentials
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "fireworks-api-key",
title: "API key",
subtitle: "Create a key at app.fireworks.ai/settings. The same key authorizes billing reads.",
kind: .secure,
placeholder: "fw_...",
binding: context.stringBinding(\.fireworksAPIToken),
actions: [],
isVisible: nil,
onActivate: nil),
ProviderSettingsFieldDescriptor(
id: "fireworks-account-slug",
title: "Account slug",
subtitle: "The segment after /accounts/ in your app.fireworks.ai URLs, e.g. x0mh0x for "
+ "app.fireworks.ai/accounts/x0mh0x. Required because Fireworks has no whoami endpoint.",
kind: .plain,
placeholder: "x0mh0x",
binding: context.stringBinding(\.fireworksAccountSlug),
actions: [
ProviderSettingsActionDescriptor(
id: "fireworks-open-billing",
title: "Open Fireworks billing",
style: .link,
isVisible: nil,
perform: {
NSWorkspace.shared.open(
FireworksURLs.billing(
accountSlug: context.settings.fireworksAccountSlug))
}),
],
isVisible: nil,
onActivate: nil),
]
}
}

enum FireworksURLs {
static func billing(accountSlug: String) -> URL {
let slug = accountSlug.trimmingCharacters(in: .whitespacesAndNewlines)
if slug.isEmpty {
return URL(string: "https://app.fireworks.ai")!
}
return URL(string: "https://app.fireworks.ai/accounts/\(slug)/settings/billing")!
}
}
34 changes: 34 additions & 0 deletions Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import CodexBarCore
import Foundation

extension SettingsStore {
var fireworksAPIToken: String {
get { self.configSnapshot.providerConfig(for: .fireworks)?.sanitizedAPIKey ?? "" }
set {
self.updateProviderConfig(provider: .fireworks) { entry in
entry.apiKey = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .fireworks, field: "apiKey", value: newValue)
}
}

var fireworksAccountSlug: String {
get { self.configSnapshot.providerConfig(for: .fireworks)?.sanitizedAccountSlug ?? "" }
set {
self.updateProviderConfig(provider: .fireworks) { entry in
entry.accountSlug = self.normalizedConfigValue(newValue)
}
}
}

var hasFireworksCredentials: Bool {
guard let config = self.configSnapshot.providerConfig(for: .fireworks) else { return false }
return config.sanitizedAPIKey != nil && config.sanitizedAccountSlug != nil
}
}

extension SettingsStore {
func fireworksSettingsSnapshot() -> ProviderSettingsSnapshot.FireworksProviderSettings {
ProviderSettingsSnapshot.FireworksProviderSettings(accountSlug: self.fireworksAccountSlug)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum ProviderImplementationManifest {
{ AlibabaTokenPlanProviderImplementation() },
{ QwenCloudProviderImplementation() },
{ FactoryProviderImplementation() },
{ FireworksProviderImplementation() },
{ GeminiProviderImplementation() },
{ AntigravityProviderImplementation() },
{ CopilotProviderImplementation() },
Expand Down
11 changes: 11 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-fireworks.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

extension ProviderConfig {
/// Account slug (the segment after `/accounts/` in console URLs) that owns `apiKey`.
/// Fireworks does not expose a whoami endpoint, so the slug cannot be derived from the key.
public var accountSlug: String? {
get { self.extensionValue(forKey: "accountSlug") }
set { self.setExtensionValue(newValue, forKey: "accountSlug") }
}

public var sanitizedAccountSlug: String? {
Self.clean(self.accountSlug)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Foundation

public enum FireworksProviderDescriptor {
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
private static let credentials = ProviderCredentialAdapter.apiKey(
environmentKey: FireworksSettingsReader.configAPIKeyEnvironmentKey,
additionalProjections: [
ProviderCredentialEnvironmentProjection(
key: FireworksSettingsReader.configAccountSlugEnvironmentKey,
value: { $0.sanitizedAccountSlug }),
],
resolve: FireworksSettingsReader.apiKey,
configValidator: { config in
guard config.sanitizedAPIKey != nil, config.sanitizedAccountSlug == nil else {
return []
}
return [CodexBarConfigIssue(
severity: .error,
provider: .fireworks,
field: "accountSlug",
code: "missing_account_slug",
message: "Fireworks needs the account slug from app.fireworks.ai/accounts/<slug> to read billing.")]
},
missingCredentialMessage: { environment in
guard FireworksSettingsReader.apiKey(environment: environment) != nil else {
return nil
}
return "Fireworks needs the account slug (set FIREWORKS_ACCOUNT_SLUG or the slug field in Settings)."
})

static func makeDescriptor() -> ProviderDescriptor {
ProviderDescriptor(
id: .fireworks,
credentials: self.credentials,
metadata: ProviderMetadata(
id: .fireworks,
displayName: "Fireworks",
sessionLabel: "Spend",
weeklyLabel: "Spend",
opusLabel: nil,
supportsOpus: false,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show Fireworks usage",
cliName: "fireworks",
defaultEnabled: false,
widgetSelectable: false,
isPrimaryProvider: false,
usesAccountFallback: false,
balanceOnly: false,
dashboardURL: "https://app.fireworks.ai",
statusPageURL: nil),
branding: ProviderBranding(
iconStyle: .init(provider: .fireworks),
iconResourceName: "ProviderIcon-fireworks",
color: ProviderColor(red: 242 / 255, green: 91 / 255, blue: 28 / 255),
confettiPalette: [
ProviderColor(hex: 0xE65618),
ProviderColor(hex: 0xFF9A3C),
ProviderColor(hex: 0x2B2B2E),
]),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Fireworks spend comes from the billing summary API; cost history is not tracked." }),
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .api],
pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [FireworksAPIFetchStrategy()] })),
cli: ProviderCLIConfig(
name: "fireworks",
aliases: ["fw"],
versionDetector: nil))
}
}

struct FireworksAPIFetchStrategy: ProviderFetchStrategy {
let id = "fireworks.api"
let kind: ProviderFetchKind = .apiToken
private let transport: any ProviderHTTPTransport

init(transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) {
self.transport = transport
}

func isAvailable(_ context: ProviderFetchContext) async -> Bool {
FireworksSettingsReader.apiKey(environment: context.env) != nil
&& FireworksSettingsReader.accountSlug(environment: context.env) != nil
}

func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
guard let apiKey = FireworksSettingsReader.apiKey(environment: context.env) else {
throw FireworksUsageError.missingCredentials
}
guard let accountSlug = FireworksSettingsReader.accountSlug(environment: context.env) else {
throw FireworksUsageError.missingAccountSlug
}
let usage = try await FireworksUsageFetcher.fetchUsage(
apiKey: apiKey,
accountSlug: accountSlug,
session: self.transport)
return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api")
}

func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Foundation

public struct FireworksProviderSettings: Sendable {
public let accountSlug: String?

public init(accountSlug: String? = nil) {
self.accountSlug = accountSlug
}
}

public enum FireworksProviderSettingsKey: ProviderSettingsSectionKey {
public static let providerID = ProviderInstanceID.fireworks
public typealias Section = FireworksProviderSettings
}

extension ProviderSettingsSnapshot {
public typealias FireworksProviderSettings = CodexBarCore.FireworksProviderSettings
public var fireworks: FireworksProviderSettings? {
self[FireworksProviderSettingsKey.self]
}

public static func make(fireworks: FireworksProviderSettings?) -> Self {
self.make(fireworks, for: FireworksProviderSettingsKey.self)
}
}

extension ProviderSettingsSnapshotContribution {
public static func fireworks(_ section: FireworksProviderSettings) -> Self {
Self(section, for: FireworksProviderSettingsKey.self)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

public struct FireworksSettingsReader: Sendable {
public static let apiKeyEnvironmentKeys = [
"FIREWORKS_API_KEY",
"FIREWORKS_KEY",
]
public static let accountSlugEnvironmentKey = "FIREWORKS_ACCOUNT_SLUG"
public static let configAPIKeyEnvironmentKey = "CODEXBAR_FIREWORKS_API_KEY"
public static let configAccountSlugEnvironmentKey = "CODEXBAR_FIREWORKS_ACCOUNT_SLUG"

public static func apiKey(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
for key in [self.configAPIKeyEnvironmentKey] + self.apiKeyEnvironmentKeys {
guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
else {
continue
}
let cleaned = Self.cleaned(raw)
if !cleaned.isEmpty {
return cleaned
}
}
return nil
}

public static func accountSlug(
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
{
for key in [self.configAccountSlugEnvironmentKey, self.accountSlugEnvironmentKey] {
guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
else {
continue
}
let cleaned = Self.cleaned(raw)
if !cleaned.isEmpty {
return cleaned
}
}
return nil
}

private static func cleaned(_ raw: String) -> String {
var value = raw
if (value.hasPrefix("\"") && value.hasSuffix("\""))
|| (value.hasPrefix("'") && value.hasSuffix("'"))
{
value = String(value.dropFirst().dropLast())
}
return value.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
Loading