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`). |