diff --git a/CHANGELOG.md b/CHANGELOG.md
index c19cbafb26..c31755c689 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 30000e31fd..6efe7628a3 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift b/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift
new file mode 100644
index 0000000000..3e58da323a
--- /dev/null
+++ b/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift
@@ -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")!
+ }
+}
diff --git a/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift b/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift
new file mode 100644
index 0000000000..e2f6dba677
--- /dev/null
+++ b/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift
@@ -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)
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift
index 11d44d27ed..4a1849f947 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift
@@ -18,6 +18,7 @@ enum ProviderImplementationManifest {
{ AlibabaTokenPlanProviderImplementation() },
{ QwenCloudProviderImplementation() },
{ FactoryProviderImplementation() },
+ { FireworksProviderImplementation() },
{ GeminiProviderImplementation() },
{ AntigravityProviderImplementation() },
{ CopilotProviderImplementation() },
diff --git a/Sources/CodexBar/Resources/ProviderIcon-fireworks.svg b/Sources/CodexBar/Resources/ProviderIcon-fireworks.svg
new file mode 100644
index 0000000000..07cd5f38f2
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-fireworks.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift
new file mode 100644
index 0000000000..6e959441ba
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift
@@ -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)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift
new file mode 100644
index 0000000000..2cd9a8151c
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift
@@ -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/ 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
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderSettings.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderSettings.swift
new file mode 100644
index 0000000000..7dcb8bb0d4
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderSettings.swift
@@ -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)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksSettingsReader.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksSettingsReader.swift
new file mode 100644
index 0000000000..32a5bea0cb
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksSettingsReader.swift
@@ -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)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift
new file mode 100644
index 0000000000..7b299da757
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift
@@ -0,0 +1,250 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct FireworksUsageSnapshot: Sendable {
+ public let summary: FireworksUsageSummary
+
+ public init(summary: FireworksUsageSummary) {
+ self.summary = summary
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ self.summary.toUsageSnapshot()
+ }
+}
+
+public struct FireworksUsageSummary: Sendable {
+ /// Sum of rated line items from `GET /v1/accounts/{slug}/billing/summary` for the
+ /// last 30 days. Fireworks exposes no credit-balance API, so spend is the only
+ /// usable usage signal.
+ public let last30DaysSpend: Double?
+ public let currencyCode: String?
+ public let updatedAt: Date
+
+ public init(
+ last30DaysSpend: Double?,
+ currencyCode: String?,
+ updatedAt: Date)
+ {
+ self.last30DaysSpend = last30DaysSpend
+ self.currencyCode = currencyCode
+ self.updatedAt = updatedAt
+ }
+
+ public func toUsageSnapshot() -> UsageSnapshot {
+ // Fireworks is prepaid with no quota windows, so no RateWindows are synthesized.
+ UsageSnapshot(
+ primary: nil,
+ secondary: nil,
+ tertiary: nil,
+ providerCost: self.last30DaysSpend.flatMap { spend in
+ self.currencyCode.map { code in
+ ProviderCostSnapshot(
+ used: spend,
+ limit: 0,
+ currencyCode: code,
+ period: "Last 30 days",
+ updatedAt: self.updatedAt)
+ }
+ },
+ updatedAt: self.updatedAt,
+ identity: nil)
+ }
+}
+
+public enum FireworksUsageError: LocalizedError, Sendable, Equatable {
+ case missingCredentials
+ case missingAccountSlug
+ case invalidAccountSlug(String)
+ case authenticationRejected
+ case rateLimited
+ case apiError(Int)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "Missing Fireworks API key. Add one in Settings or set FIREWORKS_API_KEY."
+ case .missingAccountSlug:
+ "Missing Fireworks account slug. Set FIREWORKS_ACCOUNT_SLUG or the slug field in Settings."
+ case let .invalidAccountSlug(slug):
+ "Invalid Fireworks account slug '\(slug)'. Please double-check the account slug in Settings."
+ case .authenticationRejected:
+ "Fireworks rejected the API key. Create a new key at app.fireworks.ai and update Settings."
+ case .rateLimited:
+ "Fireworks rate limit exceeded. Usage will refresh on the next cycle."
+ case let .apiError(statusCode):
+ "Fireworks billing API returned HTTP \(statusCode)."
+ case let .parseFailed(message):
+ "Could not parse Fireworks usage: \(message)"
+ }
+ }
+}
+
+public struct FireworksUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.provider(.fireworks, scope: "usage"))
+ private static let timeoutSeconds: TimeInterval = 15
+ /// Fireworks billing windows are tied to calendar days; a 30-day lookback matches the
+ /// card's "Last 30 days" period.
+ private static let lookbackDays = 30
+
+ public static func fetchUsage(
+ apiKey: String,
+ accountSlug: String,
+ session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared,
+ now: Date = Date()) async throws -> FireworksUsageSnapshot
+ {
+ let cleanedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !cleanedKey.isEmpty else {
+ throw FireworksUsageError.missingCredentials
+ }
+ let cleanedSlug = accountSlug.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !cleanedSlug.isEmpty else {
+ throw FireworksUsageError.missingAccountSlug
+ }
+
+ let startTime = now.addingTimeInterval(-TimeInterval(self.lookbackDays * 24 * 60 * 60))
+ var request = URLRequest(
+ url: try Self.resolveSummaryURL(accountSlug: cleanedSlug, startTime: startTime, endTime: now))
+ request.httpMethod = "GET"
+ request.setValue("Bearer \(cleanedKey)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.timeoutInterval = Self.timeoutSeconds
+
+ let response: ProviderHTTPResponse
+ do {
+ response = try await transport.response(for: request)
+ } catch {
+ throw error
+ }
+
+ switch response.statusCode {
+ case 200:
+ break
+ case 401, 403:
+ throw FireworksUsageError.authenticationRejected
+ case 429:
+ throw FireworksUsageError.rateLimited
+ default:
+ Self.log.error("Fireworks API returned HTTP \(response.statusCode)")
+ throw FireworksUsageError.apiError(response.statusCode)
+ }
+
+ let summary = try self.parseSummary(data: response.data, now: now)
+ return FireworksUsageSnapshot(summary: summary)
+ }
+
+ /// Characters permitted in a Fireworks account slug. Fireworks slugs are simple
+ /// lower-case ASCII path segments (alnum plus `-`, `_`, `.`); restricting to this explicit
+ /// ASCII set means a misconfigured slug can never widen the request path, inject a query,
+ /// or crash on URL construction, and every allowed character is already URL-safe in a
+ /// single path segment (no encoding needed).
+ private static let accountSlugAllowedCharacters = CharacterSet(
+ charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
+
+ /// `https://api.fireworks.ai/v1/accounts//billing/summary` with an explicit
+ /// 30-day `startTime`/`endTime` window.
+ /// - Throws: `FireworksUsageError.invalidAccountSlug` if the slug cannot be embedded
+ /// safely, so a bad slug surfaces as a config error rather than a URL-construction crash.
+ public static func resolveSummaryURL(
+ accountSlug: String,
+ startTime: Date? = nil,
+ endTime: Date? = nil) throws -> URL
+ {
+ guard accountSlug.rangeOfCharacter(from: Self.accountSlugAllowedCharacters.inverted) == nil else {
+ throw FireworksUsageError.invalidAccountSlug(accountSlug)
+ }
+ guard let components = URLComponents(
+ string: "https://api.fireworks.ai/v1/accounts/\(accountSlug)/billing/summary")
+ else {
+ throw FireworksUsageError.invalidAccountSlug(accountSlug)
+ }
+ var built = components
+ var query: [URLQueryItem] = []
+ if let startTime {
+ query.append(URLQueryItem(name: "startTime", value: Self.isoString(startTime)))
+ }
+ if let endTime {
+ query.append(URLQueryItem(name: "endTime", value: Self.isoString(endTime)))
+ }
+ built.queryItems = query.isEmpty ? nil : query
+ guard let url = built.url else {
+ throw FireworksUsageError.invalidAccountSlug(accountSlug)
+ }
+ return url
+ }
+
+ static func _parseSummaryForTesting(_ data: Data, now: Date = Date()) throws -> FireworksUsageSummary {
+ try self.parseSummary(data: data, now: now)
+ }
+
+ private static func parseSummary(data: Data, now: Date) throws -> FireworksUsageSummary {
+ let response: FireworksBillingSummaryResponse
+ do {
+ response = try JSONDecoder().decode(FireworksBillingSummaryResponse.self, from: data)
+ } catch {
+ throw FireworksUsageError.parseFailed(error.localizedDescription)
+ }
+
+ // Rated line items arrive grouped by category/model; the newest-rated currency
+ // decides the display currency and only rows in that currency are summed.
+ var currency: String?
+ var total = 0.0
+ for item in response.lineItems ?? [] {
+ guard let cost = item.totalCost,
+ let units = cost.units.flatMap(Double.init),
+ let nanos = cost.nanos,
+ let code = cost.currencyCode?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !code.isEmpty
+ else {
+ continue
+ }
+ if currency == nil {
+ currency = code
+ }
+ guard code == currency else { continue }
+ total += units + Double(nanos) / 1_000_000_000
+ }
+
+ return FireworksUsageSummary(
+ last30DaysSpend: currency.map { _ in total },
+ currencyCode: currency,
+ updatedAt: now)
+ }
+
+ private static func isoString(_ date: Date) -> String {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter.string(from: date)
+ }
+}
+
+private struct FireworksBillingSummaryResponse: Decodable {
+ let lineItems: [FireworksLineItem]?
+ let usageBuckets: [FireworksUsageBucket]?
+}
+
+private struct FireworksLineItem: Decodable {
+ let category: String?
+ let groupingKey: String?
+ let groupingValue: String?
+ let quantity: Double?
+ let series: String?
+ let totalCost: FireworksMoney?
+ let unitAmount: FireworksMoney?
+}
+
+private struct FireworksMoney: Decodable {
+ let currencyCode: String?
+ let nanos: Int?
+ let units: String?
+}
+
+private struct FireworksUsageBucket: Decodable {
+ let bucketStartTime: String?
+ let lineItems: [FireworksLineItem]?
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift
index 3512241c24..f1b361d865 100644
--- a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift
+++ b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift
@@ -15,6 +15,7 @@ extension ProviderInstanceID {
public static let alibabatokenplan = UsageProvider.alibabatokenplan.instanceID
public static let qwencloud = UsageProvider.qwencloud.instanceID
public static let factory = UsageProvider.factory.instanceID
+ public static let fireworks = UsageProvider.fireworks.instanceID
public static let gemini = UsageProvider.gemini.instanceID
public static let antigravity = UsageProvider.antigravity.instanceID
public static let copilot = UsageProvider.copilot.instanceID
diff --git a/Sources/CodexBarCore/Providers/ProviderManifest.swift b/Sources/CodexBarCore/Providers/ProviderManifest.swift
index e54fba273f..804fe27ec1 100644
--- a/Sources/CodexBarCore/Providers/ProviderManifest.swift
+++ b/Sources/CodexBarCore/Providers/ProviderManifest.swift
@@ -17,6 +17,7 @@ public enum ProviderManifest {
AlibabaTokenPlanProviderDescriptor.descriptor,
QwenCloudProviderDescriptor.descriptor,
FactoryProviderDescriptor.descriptor,
+ FireworksProviderDescriptor.descriptor,
GeminiProviderDescriptor.descriptor,
AntigravityProviderDescriptor.descriptor,
CopilotProviderDescriptor.descriptor,
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 8b11b0c33c..e9072b35b6 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -31,6 +31,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case alibabatokenplan
case qwencloud
case factory
+ case fireworks
case gemini
case antigravity
case copilot
diff --git a/Tests/CodexBarTests/FireworksSettingsReaderTests.swift b/Tests/CodexBarTests/FireworksSettingsReaderTests.swift
new file mode 100644
index 0000000000..ec670ed5b5
--- /dev/null
+++ b/Tests/CodexBarTests/FireworksSettingsReaderTests.swift
@@ -0,0 +1,53 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+struct FireworksSettingsReaderTests {
+ @Test
+ func `config api key takes precedence over environment`() {
+ let environment = [
+ FireworksSettingsReader.configAPIKeyEnvironmentKey: "config-key",
+ "FIREWORKS_API_KEY": "env-key",
+ ]
+
+ #expect(FireworksSettingsReader.apiKey(environment: environment) == "config-key")
+ }
+
+ @Test
+ func `falls back through api key environment keys`() {
+ #expect(
+ FireworksSettingsReader.apiKey(
+ environment: ["FIREWORKS_API_KEY": "env-key"]) == "env-key")
+ #expect(
+ FireworksSettingsReader.apiKey(
+ environment: ["FIREWORKS_KEY": "legacy-key"]) == "legacy-key")
+ #expect(
+ FireworksSettingsReader.apiKey(environment: [:]) == nil)
+ }
+
+ @Test
+ func `quoted and padded values are cleaned`() {
+ let environment = [
+ "FIREWORKS_API_KEY": "\" fw-quoted-key \"",
+ FireworksSettingsReader.accountSlugEnvironmentKey: "' x0mh0x '",
+ ]
+
+ #expect(FireworksSettingsReader.apiKey(environment: environment) == "fw-quoted-key")
+ #expect(FireworksSettingsReader.accountSlug(environment: environment) == "x0mh0x")
+ }
+
+ @Test
+ func `config account slug takes precedence over environment`() {
+ let environment = [
+ FireworksSettingsReader.configAccountSlugEnvironmentKey: "config-slug",
+ FireworksSettingsReader.accountSlugEnvironmentKey: "env-slug",
+ ]
+
+ #expect(FireworksSettingsReader.accountSlug(environment: environment) == "config-slug")
+ #expect(
+ FireworksSettingsReader.accountSlug(
+ environment: [FireworksSettingsReader.accountSlugEnvironmentKey: "env-slug"])
+ == "env-slug")
+ #expect(FireworksSettingsReader.accountSlug(environment: [:]) == nil)
+ }
+}
diff --git a/Tests/CodexBarTests/FireworksUsageFetcherTests.swift b/Tests/CodexBarTests/FireworksUsageFetcherTests.swift
new file mode 100644
index 0000000000..7e09e26519
--- /dev/null
+++ b/Tests/CodexBarTests/FireworksUsageFetcherTests.swift
@@ -0,0 +1,280 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct FireworksUsageFetcherTests {
+ @Test
+ func `sums rated line items from units and nanos`() throws {
+ let json = """
+ {
+ "lineItems": [
+ {
+ "category": "LLM input tokens (cached)",
+ "groupingKey": "model_bucket",
+ "groupingValue": "DeepSeek V4 Flash",
+ "quantity": 17580572,
+ "series": "SERVERLESS",
+ "totalCost": { "currencyCode": "USD", "nanos": 492256016, "units": "0" },
+ "unitAmount": { "currencyCode": "USD", "nanos": 28, "units": "0" }
+ },
+ {
+ "category": "LLM output tokens",
+ "groupingKey": "model_bucket",
+ "groupingValue": "DeepSeek V4 Flash",
+ "quantity": 118901,
+ "series": "SERVERLESS",
+ "totalCost": { "currencyCode": "USD", "nanos": 33292280, "units": "1" },
+ "unitAmount": { "currencyCode": "USD", "nanos": 280, "units": "0" }
+ }
+ ],
+ "usageBuckets": []
+ }
+ """
+
+ let summary = try FireworksUsageFetcher._parseSummaryForTesting(Data(json.utf8))
+
+ #expect((summary.last30DaysSpend ?? -1) == 1.525548296, accuracy: 0.000000001)
+ #expect(summary.currencyCode == "USD")
+
+ let usage = FireworksUsageSnapshot(summary: summary).toUsageSnapshot()
+ #expect(usage.primary == nil)
+ #expect(usage.secondary == nil)
+ #expect(usage.providerCost?.used == 1.525548296, accuracy: 0.000000001)
+ #expect(usage.providerCost?.currencyCode == "USD")
+ #expect(usage.providerCost?.period == "Last 30 days")
+ #expect(usage.providerCost?.limit == 0)
+ }
+
+ @Test
+ func `only rows in the first rated currency are summed`() throws {
+ let json = """
+ {
+ "lineItems": [
+ {
+ "category": "LLM input tokens (cached)",
+ "totalCost": { "currencyCode": "USD", "nanos": 100000000, "units": "1" }
+ },
+ {
+ "category": "LLM output tokens",
+ "totalCost": { "currencyCode": "EUR", "nanos": 900000000, "units": "9" }
+ },
+ {
+ "category": "LLM input tokens (uncached)",
+ "totalCost": { "currencyCode": "USD", "nanos": 250000000, "units": "0" }
+ }
+ ],
+ "usageBuckets": []
+ }
+ """
+
+ let summary = try FireworksUsageFetcher._parseSummaryForTesting(Data(json.utf8))
+
+ #expect(summary.currencyCode == "USD")
+ #expect((summary.last30DaysSpend ?? -1) == 1.35, accuracy: 0.000000001)
+ }
+
+ @Test
+ func `empty line items report no spend window`() throws {
+ let json = """
+ { "lineItems": [], "usageBuckets": [] }
+ """
+
+ let summary = try FireworksUsageFetcher._parseSummaryForTesting(Data(json.utf8))
+
+ #expect(summary.last30DaysSpend == nil)
+ #expect(summary.currencyCode == nil)
+ #expect(FireworksUsageSnapshot(summary: summary).toUsageSnapshot().providerCost == nil)
+ }
+
+ @Test
+ func `invalid root returns parse error`() {
+ let json = """
+ [{ "lineItems": [] }]
+ """
+
+ #expect {
+ _ = try FireworksUsageFetcher._parseSummaryForTesting(Data(json.utf8))
+ } throws: { error in
+ guard case FireworksUsageError.parseFailed = error else { return false }
+ return true
+ }
+ }
+
+ @Test
+ func `summary url carries account slug and iso window`() throws {
+ let url = try FireworksUsageFetcher.resolveSummaryURL(
+ accountSlug: "x0mh0x",
+ startTime: Date(timeIntervalSince1970: 0),
+ endTime: Date(timeIntervalSince1970: 86_400))
+
+ #expect(url.absoluteString.hasPrefix("https://api.fireworks.ai/v1/accounts/x0mh0x/billing/summary?"))
+ #expect(url.absoluteString.contains("startTime=1970-01-01T00:00:00Z"))
+ #expect(url.absoluteString.contains("endTime=1970-01-02T00:00:00Z"))
+ }
+
+ @Test
+ func `malformed account slugs fail with a config error instead of misrouting`() async {
+ // A slug with reserved/invalid URL characters must surface as a config error
+ // (never widen the path, inject a query, or crash on URL construction).
+ for badSlug in ["sp ace", "has/slash", "has?query", "has#fragment", "percent%2F", "col\u{00e9}on"] {
+ await #expect {
+ _ = try FireworksUsageFetcher.resolveSummaryURL(accountSlug: badSlug)
+ } throws: { error in
+ guard case FireworksUsageError.invalidAccountSlug = error else { return false }
+ return true
+ }
+ }
+
+ // Permitted slug characters still produce the exact billing-summary path.
+ for goodSlug in ["x0mh0x", "acct-1_x.d"] {
+ let url = try? FireworksUsageFetcher.resolveSummaryURL(accountSlug: goodSlug)
+ #expect(url?.path == "/v1/accounts/\(goodSlug)/billing/summary", "\(goodSlug) should resolve")
+ }
+ }
+
+ @Test
+ func `fetch usage sends bearer token and bounded request`() async throws {
+ defer {
+ FireworksStubURLProtocol.requests = []
+ FireworksStubURLProtocol.handler = nil
+ }
+
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [FireworksStubURLProtocol.self]
+ let session = URLSession(configuration: config)
+
+ FireworksStubURLProtocol.requests = []
+ FireworksStubURLProtocol.handler = { request in
+ let url = try #require(request.url)
+ #expect(request.httpMethod == "GET")
+ #expect(url.absoluteString.hasPrefix("https://api.fireworks.ai/v1/accounts/x0mh0x/billing/summary?"))
+ #expect(url.absoluteString.contains("startTime="))
+ #expect(url.absoluteString.contains("endTime="))
+ #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fw-test-key")
+ #expect(request.value(forHTTPHeaderField: "Accept") == "application/json")
+ #expect(request.timeoutInterval == 15)
+
+ let body = """
+ {
+ "lineItems": [
+ {
+ "category": "LLM input tokens (cached)",
+ "totalCost": { "currencyCode": "USD", "nanos": 500000000, "units": "0" }
+ }
+ ],
+ "usageBuckets": []
+ }
+ """
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: ["Content-Type": "application/json"])!
+ return (response, Data(body.utf8))
+ }
+
+ let snapshot = try await FireworksUsageFetcher.fetchUsage(
+ apiKey: "fw-test-key",
+ accountSlug: "x0mh0x",
+ session: session)
+
+ #expect(FireworksStubURLProtocol.requests.count == 1)
+ #expect((snapshot.summary.last30DaysSpend ?? -1) == 0.5, accuracy: 0.000000001)
+ }
+
+ @Test
+ func `fetch usage maps authentication and rate limit failures`() async throws {
+ for (statusCode, expectedError) in [
+ (401, FireworksUsageError.authenticationRejected),
+ (403, FireworksUsageError.authenticationRejected),
+ (429, FireworksUsageError.rateLimited),
+ (500, FireworksUsageError.apiError(500)),
+ ] {
+ defer {
+ FireworksStubURLProtocol.requests = []
+ FireworksStubURLProtocol.handler = nil
+ }
+
+ let config = URLSessionConfiguration.ephemeral
+ config.protocolClasses = [FireworksStubURLProtocol.self]
+ let session = URLSession(configuration: config)
+
+ FireworksStubURLProtocol.handler = { request in
+ guard let url = request.url else { throw URLError(.badURL) }
+ let response = HTTPURLResponse(
+ url: url,
+ statusCode: statusCode,
+ httpVersion: nil,
+ headerFields: nil)!
+ return (response, Data(#"{"error":"secret-ish provider body"}"#.utf8))
+ }
+
+ await #expect {
+ _ = try await FireworksUsageFetcher.fetchUsage(
+ apiKey: "fw-test-key",
+ accountSlug: "x0mh0x",
+ session: session)
+ } throws: { error in
+ error == expectedError
+ }
+ }
+ }
+
+ @Test
+ func `fetch usage requires key and slug`() async {
+ await #expect {
+ _ = try await FireworksUsageFetcher.fetchUsage(
+ apiKey: " ",
+ accountSlug: "x0mh0x",
+ session: URLSession(configuration: .ephemeral))
+ } throws: { error in
+ error == FireworksUsageError.missingCredentials
+ }
+
+ await #expect {
+ _ = try await FireworksUsageFetcher.fetchUsage(
+ apiKey: "fw-test-key",
+ accountSlug: "",
+ session: URLSession(configuration: .ephemeral))
+ } throws: { error in
+ error == FireworksUsageError.missingAccountSlug
+ }
+ }
+}
+
+final class FireworksStubURLProtocol: URLProtocol {
+ nonisolated(unsafe) static var requests: [URLRequest] = []
+ private static let _handlerBox = LockIsolated<(@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?>(nil)
+ static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? {
+ get { Self._handlerBox.value }
+ set { Self._handlerBox.setValue(newValue) }
+ }
+
+ override static func canInit(with _: URLRequest) -> Bool {
+ true
+ }
+
+ override static func canonicalRequest(for request: URLRequest) -> URLRequest {
+ request
+ }
+
+ override func startLoading() {
+ Self.requests.append(self.request)
+ guard let handler = Self.handler else {
+ self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
+ return
+ }
+
+ do {
+ let (response, data) = try handler(self.request)
+ self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ self.client?.urlProtocol(self, didLoad: data)
+ self.client?.urlProtocolDidFinishLoading(self)
+ } catch {
+ self.client?.urlProtocol(self, didFailWithError: error)
+ }
+ }
+
+ override func stopLoading() {}
+}
diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift
index f10eda0393..8a35bc7b0e 100644
--- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift
+++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift
@@ -154,8 +154,8 @@ struct ProviderArchitectureGatekeeperTests {
Self.hash(descriptor.branding.burnDownWidgetColor, into: &burnDownFingerprint)
}
- #expect(widgetFingerprint == 8_322_639_844_029_602_741)
- #expect(burnDownFingerprint == 3_478_078_203_311_670_951)
+ #expect(widgetFingerprint == 14_107_788_210_679_862_955)
+ #expect(burnDownFingerprint == 10_228_205_203_406_434_725)
}
@Test
diff --git a/docs/fireworks.md b/docs/fireworks.md
new file mode 100644
index 0000000000..ce7e08a9bb
--- /dev/null
+++ b/docs/fireworks.md
@@ -0,0 +1,47 @@
+---
+summary: "Fireworks provider data sources: API key, account slug, and the 30-day spend billing summary."
+read_when:
+ - Adding or tweaking Fireworks spend parsing
+ - Updating Fireworks API key or account slug handling
+ - Documenting Fireworks provider behavior
+---
+
+# Fireworks provider
+
+Fireworks is API-only for billing: there is no public credit-balance endpoint, so CodexBar shows the
+**last 30 days of rated spend** from the account billing summary API instead of a balance gauge.
+
+## Data sources
+
+1. **API key** stored in `~/.codexbar/config.json` or supplied via `FIREWORKS_API_KEY` (legacy alias: `FIREWORKS_KEY`).
+2. **Account slug** stored in `~/.codexbar/config.json` or supplied via `FIREWORKS_ACCOUNT_SLUG`.
+
+The slug is the segment after `/accounts/` in console URLs (e.g. the slug for
+`app.fireworks.ai/accounts/x0mh0x` is `x0mh0x`). Fireworks does not expose a whoami endpoint, so the slug
+cannot be derived from the API key and is required. Settings shows an API key field plus an Account slug field,
+and the config validator flags a missing slug when a key is configured.
+
+## Spend endpoint
+
+- `GET https://api.fireworks.ai/v1/accounts/{account_slug}/billing/summary?startTime=...&endTime=...`
+- Request headers: `Authorization: Bearer `, `Accept: application/json`
+- The 30-day window is sent explicitly (`startTime`/`endTime` as ISO 8601); `granularity` is not requested.
+- Response contains `lineItems` with rated `totalCost` entries (`currencyCode`, `units`, `nanos`).
+- CodexBar sums `units + nanos / 1e9` across line items, using the first rated currency as the display currency
+ and skipping rows in other currencies.
+
+## Usage details
+
+- The menu card shows the 30-day spend, e.g. `$0.53` under a "Spend" label.
+- There is no session or weekly window — Fireworks does not expose per-window quota via API.
+- HTTP 401/403 surfaces an invalid-key message, 429 a rate-limit message.
+- There is no balance display; the Fireworks web console (app.fireworks.ai → Settings/Billing) is the
+ authoritative balance source.
+
+## Key files
+
+- `Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift` (descriptor + fetch strategy)
+- `Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift` (HTTP client + JSON parser)
+- `Sources/CodexBarCore/Providers/Fireworks/FireworksSettingsReader.swift` (env var resolution)
+- `Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift` (settings fields)
+- `Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift` (SettingsStore extension)
diff --git a/docs/provider-ids.md b/docs/provider-ids.md
index c09a5673fc..146586213c 100644
--- a/docs/provider-ids.md
+++ b/docs/provider-ids.md
@@ -2,4 +2,4 @@
# Provider IDs
-`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`.
+`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`.
diff --git a/docs/providers.md b/docs/providers.md
index c1fab9957f..aba856c840 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -74,6 +74,7 @@ scan fails, while provider/account configuration changes replace obsolete result
| Abacus AI | Browser cookies → compute points + billing API (`web`). |
| Mistral | Console billing, credit balance, and Vibe subscription usage via browser cookies (`web`). |
| DeepSeek | API key from env or token accounts → balance endpoint (`api`). |
+| Fireworks | API key + account slug → 30-day spend from the billing summary API (`api`). |
| DeepInfra | API key from env or token accounts → billing checklist + monthly usage endpoints (`api`). |
| Moonshot | API key from config/env → balance endpoint (`api`). |
| Codebuff | API token from config/env or `codebuff login` credentials → usage API (`api`). |