diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7d77add4..5cb259afea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.54.1 — Unreleased +- Fireworks: auto-discover account slugs from API keys and report invalid or ambiguous accounts instead of silently showing no spend (#3068). - Fixed `codexbar cost` SIGSEGV on Linux: `Bundle.allBundles` crashes under swift-corelibs-foundation, so test detection now checks the main executable path instead (#3058, #3059). Thanks @Lucenx9! - Codex: added a personal-access-token usage source — `personal_access_token` in `auth.json` gets its own PAT strategy (whoami then `/wham/usage`), Auto prefers a usable PAT and falls back to OAuth/CLI, and ambient-home PATs are found when a managed profile would hide them (#3060). Thanks @oakimov! - Count every enabled provider in Overview spend instead of only the six displayed cards, and bucket Overview spend with the configured calendar so boundary days match the dashboard (#3063, #3064). Thanks @Chipagosfinest! diff --git a/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift b/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift index 3e58da323a..b9e1ab5c55 100644 --- a/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Fireworks/FireworksProviderImplementation.swift @@ -26,9 +26,7 @@ struct FireworksProviderImplementation: ProviderImplementation { @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { - if FireworksSettingsReader.apiKey(environment: context.environment) != nil, - FireworksSettingsReader.accountSlug(environment: context.environment) != nil - { + if FireworksSettingsReader.apiKey(environment: context.environment) != nil { return true } return context.settings.hasFireworksCredentials @@ -50,21 +48,20 @@ struct FireworksProviderImplementation: ProviderImplementation { 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.", + subtitle: "Optional when the API key can access one account; CodexBar discovers it automatically. " + + "For multiple accounts, find the slug in the app.fireworks.ai home account switcher or run " + + "firectl whoami.", kind: .plain, placeholder: "x0mh0x", binding: context.stringBinding(\.fireworksAccountSlug), actions: [ ProviderSettingsActionDescriptor( id: "fireworks-open-billing", - title: "Open Fireworks billing", + title: "Open Fireworks", style: .link, isVisible: nil, perform: { - NSWorkspace.shared.open( - FireworksURLs.billing( - accountSlug: context.settings.fireworksAccountSlug)) + NSWorkspace.shared.open(FireworksURLs.home) }), ], isVisible: nil, @@ -74,11 +71,5 @@ struct FireworksProviderImplementation: ProviderImplementation { } 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")! - } + static let home = URL(string: "https://app.fireworks.ai")! } diff --git a/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift b/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift index e2f6dba677..c3afaff699 100644 --- a/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift +++ b/Sources/CodexBar/Providers/Fireworks/FireworksSettingsStore.swift @@ -23,7 +23,7 @@ extension SettingsStore { var hasFireworksCredentials: Bool { guard let config = self.configSnapshot.providerConfig(for: .fireworks) else { return false } - return config.sanitizedAPIKey != nil && config.sanitizedAccountSlug != nil + return config.sanitizedAPIKey != nil } } diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift index 6e959441ba..4d6d8f0752 100644 --- a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift +++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderConfig.swift @@ -1,8 +1,8 @@ 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. + /// Account slug that owns `apiKey`. When omitted, CodexBar discovers it from the + /// accounts visible to the Fireworks API key. public var accountSlug: String? { get { self.extensionValue(forKey: "accountSlug") } set { self.setExtensionValue(newValue, forKey: "accountSlug") } diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift index b82796c0d7..76fea18bca 100644 --- a/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksProviderDescriptor.swift @@ -9,24 +9,7 @@ public enum FireworksProviderDescriptor { 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)." - }) + resolve: FireworksSettingsReader.apiKey) static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( @@ -84,24 +67,46 @@ struct FireworksAPIFetchStrategy: ProviderFetchStrategy { 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, + accountSlug: FireworksSettingsReader.accountSlug(environment: context.env), session: self.transport) - return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + var diagnostic: String? + if usage.accountSlugWasDiscovered { + do { + try Self.persistAccountSlug(usage.accountSlug) + } catch { + diagnostic = "Auto-discovered Fireworks account '\(usage.accountSlug)' but could not save it: " + + error.localizedDescription + } + } + let sourceLabel = usage.accountSlugWasDiscovered + ? "api · \(usage.accountSlug) (auto-discovered)" + : "api · \(usage.accountSlug)" + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: sourceLabel, + diagnostic: diagnostic) } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } + + private static func persistAccountSlug(_ accountSlug: String) throws { + let store = CodexBarConfigStore() + var config = try store.load() ?? .makeDefault() + var providerConfig = config.providerConfig(for: UsageProvider.fireworks.instanceID) + ?? ProviderConfig(id: UsageProvider.fireworks.instanceID) + guard providerConfig.sanitizedAccountSlug != accountSlug else { return } + providerConfig.accountSlug = accountSlug + config.setProviderConfig(providerConfig) + try store.save(config) + } } diff --git a/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift b/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift index c6fe299bba..d8d5745afa 100644 --- a/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Fireworks/FireworksUsageFetcher.swift @@ -6,9 +6,17 @@ import FoundationNetworking public struct FireworksUsageSnapshot: Sendable { public let summary: FireworksUsageSummary + public let accountSlug: String + public let accountSlugWasDiscovered: Bool - public init(summary: FireworksUsageSummary) { + public init( + summary: FireworksUsageSummary, + accountSlug: String = "", + accountSlugWasDiscovered: Bool = false) + { self.summary = summary + self.accountSlug = accountSlug + self.accountSlugWasDiscovered = accountSlugWasDiscovered } public func toUsageSnapshot() -> UsageSnapshot { @@ -57,8 +65,10 @@ public struct FireworksUsageSummary: Sendable { public enum FireworksUsageError: LocalizedError, Sendable, Equatable { case missingCredentials - case missingAccountSlug case invalidAccountSlug(String) + case accountNotFound(String) + case noAccountsFound + case multipleAccountsFound([String]) case authenticationRejected case rateLimited case apiError(Int) @@ -68,10 +78,18 @@ public enum FireworksUsageError: LocalizedError, Sendable, Equatable { 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 let .accountNotFound(slug): + "Fireworks account slug '\(slug)' not found for this API key. Leave the slug blank to auto-discover " + + "it, choose it in the app.fireworks.ai account switcher, or run 'firectl whoami'." + case .noAccountsFound: + "No Fireworks accounts are visible to this API key. Check the key in app.fireworks.ai or run " + + "'firectl whoami'." + case let .multipleAccountsFound(slugs): + "This Fireworks API key can access multiple accounts: \(slugs.joined(separator: ", ")). Set the " + + "account slug in Settings or FIREWORKS_ACCOUNT_SLUG; find it in the app.fireworks.ai account " + + "switcher or with 'firectl whoami'." case .authenticationRejected: "Fireworks rejected the API key. Create a new key at app.fireworks.ai and update Settings." case .rateLimited: @@ -93,7 +111,7 @@ public struct FireworksUsageFetcher: Sendable { public static func fetchUsage( apiKey: String, - accountSlug: String, + accountSlug: String?, session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: Date = Date()) async throws -> FireworksUsageSnapshot { @@ -101,25 +119,78 @@ public struct FireworksUsageFetcher: Sendable { guard !cleanedKey.isEmpty else { throw FireworksUsageError.missingCredentials } - let cleanedSlug = accountSlug.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleanedSlug.isEmpty else { - throw FireworksUsageError.missingAccountSlug + let cleanedSlug = accountSlug?.trimmingCharacters(in: .whitespacesAndNewlines) + if let cleanedSlug, !cleanedSlug.isEmpty { + return try await self.fetchConfiguredAccount( + apiKey: cleanedKey, + accountSlug: cleanedSlug, + transport: transport, + now: now) } - let startTime = now.addingTimeInterval(-TimeInterval(self.lookbackDays * 24 * 60 * 60)) - var request = try URLRequest( - url: 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 slugs = try await self.listAccountSlugs(apiKey: cleanedKey, transport: transport) + let discoveredSlug = try self.singleDiscoveredAccount(from: slugs) + let summary = try await self.fetchSummary( + apiKey: cleanedKey, + accountSlug: discoveredSlug, + transport: transport, + now: now) + return FireworksUsageSnapshot( + summary: summary, + accountSlug: discoveredSlug, + accountSlugWasDiscovered: true) + } - let response: ProviderHTTPResponse + private static func fetchConfiguredAccount( + apiKey: String, + accountSlug: String, + transport: any ProviderHTTPTransport, + now: Date) async throws -> FireworksUsageSnapshot + { do { - response = try await transport.response(for: request) - } catch { - throw error + let summary = try await self.fetchSummary( + apiKey: apiKey, + accountSlug: accountSlug, + transport: transport, + now: now) + if summary.last30DaysSpend == nil { + let slugs = try await self.listAccountSlugs(apiKey: apiKey, transport: transport) + guard slugs.contains(accountSlug) else { + throw FireworksUsageError.accountNotFound(accountSlug) + } + } + return FireworksUsageSnapshot(summary: summary, accountSlug: accountSlug) + } catch FireworksUsageError.apiError(404) { + let slugs = try await self.listAccountSlugs(apiKey: apiKey, transport: transport) + guard slugs.count == 1, let discoveredSlug = slugs.first else { + if slugs.isEmpty { + throw FireworksUsageError.accountNotFound(accountSlug) + } + throw FireworksUsageError.multipleAccountsFound(slugs) + } + let summary = try await self.fetchSummary( + apiKey: apiKey, + accountSlug: discoveredSlug, + transport: transport, + now: now) + return FireworksUsageSnapshot( + summary: summary, + accountSlug: discoveredSlug, + accountSlugWasDiscovered: discoveredSlug != accountSlug) } + } + + private static func fetchSummary( + apiKey: String, + accountSlug: String, + transport: any ProviderHTTPTransport, + now: Date) async throws -> FireworksUsageSummary + { + let startTime = now.addingTimeInterval(-TimeInterval(self.lookbackDays * 24 * 60 * 60)) + var request = try URLRequest( + url: Self.resolveSummaryURL(accountSlug: accountSlug, startTime: startTime, endTime: now)) + self.authorize(&request, apiKey: apiKey) + let response = try await transport.response(for: request) switch response.statusCode { case 200: @@ -133,8 +204,65 @@ public struct FireworksUsageFetcher: Sendable { throw FireworksUsageError.apiError(response.statusCode) } - let summary = try self.parseSummary(data: response.data, now: now) - return FireworksUsageSnapshot(summary: summary) + return try self.parseSummary(data: response.data, now: now) + } + + private static func listAccountSlugs( + apiKey: String, + transport: any ProviderHTTPTransport) async throws -> [String] + { + var slugs: Set = [] + var pageToken: String? + repeat { + var request = URLRequest(url: self.resolveAccountsURL(pageToken: pageToken)) + self.authorize(&request, apiKey: apiKey) + let response = try await transport.response(for: request) + switch response.statusCode { + case 200: + break + case 401, 403: + throw FireworksUsageError.authenticationRejected + case 429: + throw FireworksUsageError.rateLimited + default: + Self.log.error("Fireworks accounts API returned HTTP \(response.statusCode)") + throw FireworksUsageError.apiError(response.statusCode) + } + + let page: FireworksAccountsResponse + do { + page = try JSONDecoder().decode(FireworksAccountsResponse.self, from: response.data) + } catch { + throw FireworksUsageError.parseFailed(error.localizedDescription) + } + for account in page.accounts ?? [] { + if let slug = account.slug, self.isValidAccountSlug(slug) { + slugs.insert(slug) + } + } + pageToken = page.nextPageToken?.trimmingCharacters(in: .whitespacesAndNewlines) + if pageToken?.isEmpty == true { + pageToken = nil + } + } while pageToken != nil + return slugs.sorted() + } + + private static func singleDiscoveredAccount(from slugs: [String]) throws -> String { + guard !slugs.isEmpty else { + throw FireworksUsageError.noAccountsFound + } + guard slugs.count == 1, let slug = slugs.first else { + throw FireworksUsageError.multipleAccountsFound(slugs) + } + return slug + } + + private static func authorize(_ request: inout URLRequest, apiKey: String) { + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = self.timeoutSeconds } /// Characters permitted in a Fireworks account slug. Fireworks slugs are simple @@ -145,6 +273,18 @@ public struct FireworksUsageFetcher: Sendable { private static let accountSlugAllowedCharacters = CharacterSet( charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-") + private static func isValidAccountSlug(_ slug: String) -> Bool { + !slug.isEmpty && slug.rangeOfCharacter(from: self.accountSlugAllowedCharacters.inverted) == nil + } + + public static func resolveAccountsURL(pageToken: String? = nil) -> URL { + var components = URLComponents(string: "https://api.fireworks.ai/v1/accounts")! + if let pageToken { + components.queryItems = [URLQueryItem(name: "pageToken", value: pageToken)] + } + return components.url! + } + /// `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 @@ -228,6 +368,27 @@ private struct FireworksBillingSummaryResponse: Decodable { let usageBuckets: [FireworksUsageBucket]? } +private struct FireworksAccountsResponse: Decodable { + let accounts: [FireworksAccount]? + let nextPageToken: String? +} + +private struct FireworksAccount: Decodable { + let name: String? + let accountId: String? + let id: String? + + var slug: String? { + for value in [self.accountId, self.id, self.name] { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + continue + } + return value.split(separator: "/").last.map(String.init) + } + return nil + } +} + private struct FireworksLineItem: Decodable { let category: String? let groupingKey: String? diff --git a/Tests/CodexBarTests/FireworksUsageFetcherTests.swift b/Tests/CodexBarTests/FireworksUsageFetcherTests.swift index 975e88de31..c07ca48e1d 100644 --- a/Tests/CodexBarTests/FireworksUsageFetcherTests.swift +++ b/Tests/CodexBarTests/FireworksUsageFetcherTests.swift @@ -220,18 +220,174 @@ struct FireworksUsageFetcherTests { } @Test - func `fetch usage requires key and slug`() async { - await #expect(throws: FireworksUsageError.missingCredentials) { + func `wrong slug with empty billing response is an explicit account error`() 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) + let body: String + if url.path == "/v1/accounts" { + body = #"{"accounts":[{"name":"accounts/actual-team"}]}"# + } else { + #expect(url.path == "/v1/accounts/guessed-user/billing/summary") + body = #"{"lineItems":[],"usageBuckets":[]}"# + } + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(body.utf8)) + } + + await #expect { _ = try await FireworksUsageFetcher.fetchUsage( - apiKey: " ", - accountSlug: "x0mh0x", - session: URLSession(configuration: .ephemeral)) + apiKey: "fw-test-key", + accountSlug: "guessed-user", + session: session) + } throws: { error in + guard error as? FireworksUsageError == .accountNotFound("guessed-user") else { return false } + return error.localizedDescription.hasPrefix( + "Fireworks account slug 'guessed-user' not found for this API key") + } + #expect(FireworksStubURLProtocol.requests.map(\.url?.path) == [ + "/v1/accounts/guessed-user/billing/summary", + "/v1/accounts", + ]) + } + + @Test + func `missing slug auto discovers a single account before fetching billing`() 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) + let body: String + if url.path == "/v1/accounts" { + body = #"{"accounts":[{"name":"accounts/discovered-team","displayName":"Discovered Team"}]}"# + } else { + #expect(url.path == "/v1/accounts/discovered-team/billing/summary") + body = """ + { + "lineItems": [ + { "totalCost": { "currencyCode": "USD", "nanos": 250000000, "units": "2" } } + ] + } + """ + } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fw-test-key") + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(body.utf8)) } - await #expect(throws: FireworksUsageError.missingAccountSlug) { + let snapshot = try await FireworksUsageFetcher.fetchUsage( + apiKey: "fw-test-key", + accountSlug: nil, + session: session) + + #expect(snapshot.accountSlug == "discovered-team") + #expect(snapshot.accountSlugWasDiscovered) + #expect(snapshot.summary.last30DaysSpend == 2.25) + #expect(FireworksStubURLProtocol.requests.map(\.url?.path) == [ + "/v1/accounts", + "/v1/accounts/discovered-team/billing/summary", + ]) + } + + @Test + func `multiple visible accounts report sorted slug candidates`() 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(url.path == "/v1/accounts") + let body = #"{"accounts":[{"name":"accounts/zeta"},{"name":"accounts/alpha"}]}"# + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data(body.utf8)) + } + + await #expect { _ = try await FireworksUsageFetcher.fetchUsage( apiKey: "fw-test-key", - accountSlug: "", + accountSlug: nil, + session: session) + } throws: { error in + guard error as? FireworksUsageError == .multipleAccountsFound(["alpha", "zeta"]) else { + return false + } + return error.localizedDescription.contains("alpha, zeta") + } + #expect(FireworksStubURLProtocol.requests.count == 1) + } + + @Test + func `configured 404 auto discovers the sole visible account`() 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) + let response: HTTPURLResponse + let body: String + switch url.path { + case "/v1/accounts/old-slug/billing/summary": + response = HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)! + body = #"{"code":5,"message":"account not found"}"# + case "/v1/accounts": + response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + body = #"{"accounts":[{"name":"accounts/current-slug"}]}"# + default: + #expect(url.path == "/v1/accounts/current-slug/billing/summary") + response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + body = #"{"lineItems":[{"totalCost":{"currencyCode":"USD","nanos":0,"units":"1"}}]}"# + } + return (response, Data(body.utf8)) + } + + let snapshot = try await FireworksUsageFetcher.fetchUsage( + apiKey: "fw-test-key", + accountSlug: "old-slug", + session: session) + + #expect(snapshot.accountSlug == "current-slug") + #expect(snapshot.accountSlugWasDiscovered) + #expect(snapshot.summary.last30DaysSpend == 1) + #expect(FireworksStubURLProtocol.requests.map(\.url?.path) == [ + "/v1/accounts/old-slug/billing/summary", + "/v1/accounts", + "/v1/accounts/current-slug/billing/summary", + ]) + } + + @Test + func `fetch usage requires key`() async { + await #expect(throws: FireworksUsageError.missingCredentials) { + _ = try await FireworksUsageFetcher.fetchUsage( + apiKey: " ", + accountSlug: "x0mh0x", session: URLSession(configuration: .ephemeral)) } } diff --git a/docs/fireworks.md b/docs/fireworks.md index 3bb3c24166..8cc32488ed 100644 --- a/docs/fireworks.md +++ b/docs/fireworks.md @@ -1,5 +1,5 @@ --- -summary: "Fireworks provider data sources: API key, account slug, and the 30-day spend billing summary." +summary: "Fireworks provider data sources: API key account discovery and the 30-day spend billing summary." read_when: - Adding or tweaking Fireworks spend parsing - Updating Fireworks API key or account slug handling @@ -14,12 +14,12 @@ Fireworks is API-only for billing: there is no public credit-balance endpoint, s ## 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`. +2. **Optional 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. +CodexBar calls `GET https://api.fireworks.ai/v1/accounts` to list the accounts visible to the key. A single +account is selected automatically and its slug is saved to the config. When several accounts are visible, the +user must choose one in the app.fireworks.ai home account switcher or obtain it from `firectl whoami`, then enter +it in Settings. A configured slug remains useful for selecting among multiple accounts. ## Spend endpoint @@ -35,13 +35,16 @@ and the config validator flags a missing slug when a key is configured. - 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. +- A 404 for a configured slug retries account discovery. An empty billing response is accepted only when the + slug is present in the account listing, so a guessed or stale slug cannot look like a successful refresh. - There is no balance display; the Fireworks web console (app.fireworks.ai → Settings/Billing) is the authoritative balance source. ## Plugin conversion status -The native fetcher remains authoritative. A valid response with no rated line items intentionally produces a -successful snapshot with no rate window, cost, or detail; the current plugin snapshot contract rejects that shape. +The native fetcher remains authoritative. A valid response with no rated line items for a listed account +intentionally produces a successful snapshot with no rate window, cost, or detail; the current plugin snapshot +contract rejects that shape. ## Key files