From 59d328eacbd1978c3b651ca1313993cd38635483 Mon Sep 17 00:00:00 2001 From: Sayuru Akash Amarasinghe Date: Wed, 8 Apr 2026 01:28:00 +0530 Subject: [PATCH] feat: add Fireworks AI plugin for usage tracking and billing metrics - Implemented the Fireworks AI plugin to track serverless usage, prompt tokens, generated tokens, monthly spend, and budget. - Added API calls to list accounts and quotas, and export billing metrics. - Integrated macOS Keychain and environment variable support for API key management. - Created a detailed documentation file for the Fireworks AI plugin. - Added SVG icon for the Fireworks AI plugin. - Developed comprehensive tests to ensure functionality and error handling. --- README.md | 1 + docs/providers/fireworks-ai.md | 100 +++++++ plugins/fireworks-ai/icon.svg | 6 + plugins/fireworks-ai/plugin.js | 369 ++++++++++++++++++++++++ plugins/fireworks-ai/plugin.json | 17 ++ plugins/fireworks-ai/plugin.test.js | 285 ++++++++++++++++++ plugins/test-helpers.js | 3 + src-tauri/src/plugin_engine/host_api.rs | 261 ++++++++++++++++- src-tauri/src/plugin_engine/runtime.rs | 3 + 9 files changed, 1043 insertions(+), 2 deletions(-) create mode 100644 docs/providers/fireworks-ai.md create mode 100644 plugins/fireworks-ai/icon.svg create mode 100644 plugins/fireworks-ai/plugin.js create mode 100644 plugins/fireworks-ai/plugin.json create mode 100644 plugins/fireworks-ai/plugin.test.js diff --git a/README.md b/README.md index 44bae57f..29c0e29f 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr - [**Copilot**](docs/providers/copilot.md) / premium, chat, completions - [**Cursor**](docs/providers/cursor.md) / credits, total usage, auto usage, API usage, on-demand, CLI auth - [**Factory / Droid**](docs/providers/factory.md) / standard, premium tokens +- [**Fireworks AI**](docs/providers/fireworks-ai.md) / serverless usage, prompt/generated TPS, monthly spend - [**Gemini**](docs/providers/gemini.md) / pro, flash, workspace/free/paid tier - [**JetBrains AI Assistant**](docs/providers/jetbrains-ai-assistant.md) / quota, remaining - [**Kiro**](docs/providers/kiro.md) / credits, bonus credits, overages diff --git a/docs/providers/fireworks-ai.md b/docs/providers/fireworks-ai.md new file mode 100644 index 00000000..2a96d6b5 --- /dev/null +++ b/docs/providers/fireworks-ai.md @@ -0,0 +1,100 @@ +# Fireworks AI + +> Based on Fireworks AI's documented control-plane API and quota model. + +## Overview + +- **Product:** [Fireworks AI](https://fireworks.ai/) +- **Auth:** Fireworks API key +- **Primary API:** `https://api.fireworks.ai/v1` +- **Key setup:** macOS Keychain first, `FIREWORKS_API_KEY` fallback + +OpenUsage uses Fireworks' official account + quota endpoints plus Fireworks' official billing-export path when available. It does not depend on a browser login. + +## Plugin Metrics + +| Metric | Source | Scope | Format | Notes | +| --- | --- | --- | --- | --- | +| Serverless usage | billing export or aggregate token counter | overview | text | Main cumulative usage line, shown as a compact token total like `104.85M tokens` | +| Prompt tokens | billing export or token counter | overview | text | Prompt/input token total for the selected rolling window | +| Generated tokens | billing export or token counter | overview | text | Generated/output token total for the selected rolling window | +| Month spend | `monthly-spend-usd.usage` | overview | text | Current calendar-month billable spend | +| Budget | `monthly-spend-usd.value` / `maxValue` | detail | text | Configured monthly budget plus the tier cap | +| Status | account `state` / `suspendState` | detail | badge | Only shown when the account is not in a healthy state | + +The plan label is inferred from Fireworks' documented monthly budget cap tiers: + +- Tier 1: `$50` +- Tier 2: `$500` +- Tier 3: `$5,000` +- Tier 4: `$50,000` + +## API Calls + +### 1) List accounts + +```http +GET https://api.fireworks.ai/v1/accounts?pageSize=200 +Authorization: Bearer +Accept: application/json +``` + +This returns the accounts attached to the key. OpenUsage picks the first healthy account (`READY` + `UNSUSPENDED`) and falls back to the first returned account if none are healthy. + +### 2) List quotas + +```http +GET https://api.fireworks.ai/v1/accounts/{account_id}/quotas?pageSize=200 +Authorization: Bearer +Accept: application/json +``` + +OpenUsage currently reads: + +- `monthly-spend-usd` +- aggregate token counters when the quota payload exposes them +- any other live quota only when it is a truthful user-facing limit worth showing + +### 3) Export billing metrics + +Fireworks documents `firectl billing export-metrics` as the official way to export billable usage, and the command accepts `--api-key` and `--account-id`. OpenUsage uses that path when available to compute rolling token totals without relying on a browser session. The working export format is date-only `YYYY-MM-DD` windows. + +Observed/expected quota fields: + +- `name` +- `value` +- `maxValue` +- `usage` +- `currentUsage` +- `updateTime` + +## Credential Setup + +### Recommended: macOS Keychain + +OpenUsage looks for this service name first: + +```text +OpenUsage Fireworks AI API Key +``` + +Add/update it with: + +```bash +security add-generic-password -U -a "$(id -un)" -s "OpenUsage Fireworks AI API Key" -w "" +``` + +### Fallback: environment variable + +```bash +export FIREWORKS_API_KEY="" +``` + +Restart OpenUsage after changing shell env. The host caches env values for the app session. + +## Notes + +- Fireworks' official docs document aggregate usage export through `firectl billing export-metrics`, but not a public documented browser-free history endpoint for the dashboard chart itself. OpenUsage therefore prefers the official billing-export path for cumulative token totals, falls back to live account token counters when present, and otherwise falls back to spend/budget only. +- Live validation on this account showed that Fireworks exposes monthly spend and request-rate quotas, but not prompt/generated token-per-second quotas. OpenUsage therefore does not ship speculative prompt/generated rate bars for Fireworks. +- Fireworks docs describe monthly spend limits and tier caps. OpenUsage labels the live spend number as `Month spend` to avoid implying a settled invoice total or a rolling 30-day window. +- If no key is configured, OpenUsage shows a direct setup hint instead of a generic failure. diff --git a/plugins/fireworks-ai/icon.svg b/plugins/fireworks-ai/icon.svg new file mode 100644 index 00000000..f01a0688 --- /dev/null +++ b/plugins/fireworks-ai/icon.svg @@ -0,0 +1,6 @@ + diff --git a/plugins/fireworks-ai/plugin.js b/plugins/fireworks-ai/plugin.js new file mode 100644 index 00000000..c04369a8 --- /dev/null +++ b/plugins/fireworks-ai/plugin.js @@ -0,0 +1,369 @@ +(function () { + const API_BASE = "https://api.fireworks.ai/v1" + const ACCOUNTS_URL = API_BASE + "/accounts?pageSize=200" + const KEYCHAIN_SERVICE = "OpenUsage Fireworks AI API Key" + const ENV_VAR = "FIREWORKS_API_KEY" + const BILLING_WINDOW_DAYS = 30 + const TIER_BY_CAP = { 50: "Tier 1", 500: "Tier 2", 5000: "Tier 3", 50000: "Tier 4" } + const HEALTHY_STATES = ["READY", "ACTIVE", "STATE_UNSPECIFIED"] + const HEALTHY_SUSPEND = ["UNSUSPENDED", "SUSPEND_STATE_UNSPECIFIED", null] + const TOTAL_TOKENS_NAMES = ["serverless-inference-total-tokens", "serverless-inference-tokens", "serverless-usage-tokens"] + + function text(value) { + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed || null + } + + function num(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : null + const raw = text(value) + if (!raw) return null + const parsed = Number(raw) + return Number.isFinite(parsed) ? parsed : null + } + + function title(value) { + const raw = text(value) + return raw ? raw.replace(/[_-]+/g, " ").replace(/\b[a-z]/g, (m) => m.toUpperCase()) : null + } + + function lastPathPart(value) { + const raw = text(value) + return raw ? raw.split("/").filter(Boolean).pop() || null : null + } + + function lastFinite(values) { + for (let i = 0; i < values.length; i += 1) { + if (Number.isFinite(values[i])) return values[i] + } + return null + } + + function formatGroupedNumber(value) { + const parts = String(value).split(".") + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",") + return parts.join(".") + } + + function formatFixed(value, decimals) { + const scale = Math.pow(10, decimals) + const rounded = Math.round(value * scale) / scale + const fixed = String(rounded.toFixed(decimals)) + return decimals > 0 ? fixed.replace(/\.?0+$/, "") : fixed + } + + function formatDollars(value) { + if (!Number.isFinite(value)) return null + const rounded = Math.round(value * 100) / 100 + const fixed = formatFixed(rounded, rounded % 1 === 0 ? 0 : 2) + const parts = fixed.split(".") + return "$" + formatGroupedNumber(parts.join(".")) + } + + function formatCompactCount(value) { + if (!Number.isFinite(value)) return null + const abs = Math.abs(value) + const units = [{ threshold: 1e12, suffix: "T" }, { threshold: 1e9, suffix: "B" }, { threshold: 1e6, suffix: "M" }, { threshold: 1e3, suffix: "K" }] + for (let i = 0; i < units.length; i += 1) { + const unit = units[i] + if (abs >= unit.threshold) return formatFixed(value / unit.threshold, 2) + unit.suffix + } + return formatGroupedNumber(String(Math.round(value))) + } + + function startOfUtcDayMs(ms) { + const date = new Date(ms) + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + } + + function buildBillingWindow(nowMs) { + const endMs = startOfUtcDayMs(nowMs) + 24 * 60 * 60 * 1000 + const startMs = endMs - BILLING_WINDOW_DAYS * 24 * 60 * 60 * 1000 + const fmt = (ms) => new Date(ms).toISOString().slice(0, 10) + return { startTime: fmt(startMs), endTime: fmt(endMs), label: "Last " + BILLING_WINDOW_DAYS + " days" } + } + + function parseCsvRow(text) { + const out = [] + let current = "" + let inQuotes = false + for (let i = 0; i < text.length; i += 1) { + const char = text.charAt(i) + if (char === '"') { + if (inQuotes && text.charAt(i + 1) === '"') { + current += '"' + i += 1 + } else { + inQuotes = !inQuotes + } + } else if (char === "," && !inQuotes) { + out.push(current) + current = "" + } else { + current += char + } + } + out.push(current) + return out + } + + function parseBillingMetricsCsv(text) { + const raw = text && String(text).trim() + if (!raw) return [] + const lines = raw.split(/\r?\n/).filter(Boolean) + if (lines.length < 2) return [] + const header = parseCsvRow(lines[0]).map((value) => value.trim()) + const rows = [] + for (let i = 1; i < lines.length; i += 1) { + const cols = parseCsvRow(lines[i]) + if (!cols.length) continue + const row = {} + for (let j = 0; j < header.length; j += 1) row[header[j]] = cols[j] || "" + rows.push(row) + } + return rows + } + + function readKeychainApiKey(ctx) { + const keychain = ctx.host.keychain + if (!keychain) return null + if (typeof keychain.readGenericPasswordForCurrentUser === "function") { + try { + const value = text(keychain.readGenericPasswordForCurrentUser(KEYCHAIN_SERVICE)) + if (value) return { value, source: "keychain-current-user" } + } catch (e) { + ctx.host.log.info("current-user keychain read failed, trying legacy lookup: " + String(e)) + } + } + if (typeof keychain.readGenericPassword !== "function") return null + try { + const value = text(keychain.readGenericPassword(KEYCHAIN_SERVICE)) + return value ? { value, source: "keychain-legacy" } : null + } catch (e) { + ctx.host.log.info("keychain read failed (may not exist): " + String(e)) + return null + } + } + + function loadApiKey(ctx) { + const keychainValue = readKeychainApiKey(ctx) + if (keychainValue) return keychainValue + try { + const value = text(ctx.host.env.get(ENV_VAR)) + return value ? { value, source: ENV_VAR } : null + } catch (e) { + ctx.host.log.warn("env read failed for " + ENV_VAR + ": " + String(e)) + return null + } + } + + function requestJson(ctx, apiKey, url) { + let resp + try { + resp = ctx.util.request({ + method: "GET", + url, + headers: { + Authorization: "Bearer " + apiKey, + Accept: "application/json", + "User-Agent": "OpenUsage/" + String(ctx.app && ctx.app.version ? ctx.app.version : "0.0.0"), + }, + timeoutMs: 15000, + }) + } catch (e) { + ctx.host.log.error("request failed: " + String(e)) + throw "Request failed. Check your connection." + } + if (ctx.util.isAuthStatus(resp.status)) throw "API key invalid. Check your Fireworks AI API key." + if (resp.status < 200 || resp.status >= 300) throw "Request failed (HTTP " + resp.status + "). Try again later." + const parsed = ctx.util.tryParseJson(resp.bodyText) + if (!parsed || typeof parsed !== "object") throw "Response invalid. Try again later." + return parsed + } + + function listAccounts(ctx, apiKey) { + const parsed = requestJson(ctx, apiKey, ACCOUNTS_URL) + const accounts = Array.isArray(parsed.accounts) ? parsed.accounts : Array.isArray(parsed.data) ? parsed.data : null + if (!accounts || !accounts.length) throw "No Fireworks account found for this API key." + return accounts + } + + function pickAccount(accounts) { + let fallback = null + for (let i = 0; i < accounts.length; i += 1) { + const item = accounts[i] + if (!item || typeof item !== "object") continue + const accountId = lastPathPart(item.name) || text(item.accountId) || text(item.account_id) + if (!accountId) continue + const account = { + accountId, + state: text(item.state), + suspendState: text(item.suspendState || item.suspend_state), + } + if (!fallback) fallback = account + if (HEALTHY_STATES.includes(account.state) && HEALTHY_SUSPEND.includes(account.suspendState)) return account + } + return fallback + } + + function listQuotas(ctx, apiKey, accountId) { + const parsed = requestJson(ctx, apiKey, API_BASE + "/accounts/" + encodeURIComponent(accountId) + "/quotas?pageSize=200") + return Array.isArray(parsed.quotas) ? parsed.quotas : Array.isArray(parsed.data) ? parsed.data : [] + } + + function normalizeQuota(ctx, raw) { + if (!raw || typeof raw !== "object") return null + const name = lastPathPart(raw.name) || text(raw.quotaId) || text(raw.id) + if (!name) return null + return { + name, + value: num(raw.value), + maxValue: num(raw.maxValue || raw.max_value), + usage: lastFinite([ + num(raw.usage), + num(raw.currentUsage || raw.current_usage), + num(raw.consumedValue || raw.consumed_value), + ]), + updateTime: ctx.util.toIso(raw.updateTime || raw.update_time), + } + } + + function findQuota(quotas, exactNames, pattern) { + for (let i = 0; i < quotas.length; i += 1) { + const quota = quotas[i] + if (!quota) continue + if (exactNames.includes(quota.name)) return quota + } + for (let i = 0; i < quotas.length; i += 1) { + const quota = quotas[i] + if (!quota) continue + if (pattern.test(quota.name)) return quota + } + return null + } + + function inferPlan(spendQuota) { + if (!spendQuota || spendQuota.maxValue === null) return null + const cap = Math.round(spendQuota.maxValue) + return TIER_BY_CAP[cap] || null + } + + function buildStatusLine(ctx, account) { + const suspendState = account && account.suspendState + if (suspendState && !HEALTHY_SUSPEND.includes(suspendState)) { + return ctx.line.badge({ label: "Status", text: title(suspendState) || suspendState, color: "#f97316" }) + } + const state = account && account.state + if (state && !HEALTHY_STATES.includes(state)) { + return ctx.line.badge({ label: "Status", text: title(state) || state, color: "#f97316" }) + } + return null + } + + function tokenAmount(quota) { + if (!quota) return null + if (quota.usage !== null && quota.usage >= 0) return quota.usage + if (quota.maxValue === null && quota.value !== null && quota.value >= 0) return quota.value + return null + } + + function loadBillingUsage(ctx, apiKey, accountId, nowMs) { + const fireworks = ctx.host.fireworks + if (!fireworks || typeof fireworks.exportBillingMetrics !== "function") return { status: "unsupported" } + const window = buildBillingWindow(nowMs) + let exported + try { + exported = fireworks.exportBillingMetrics({ + apiKey, + accountId, + startTime: window.startTime, + endTime: window.endTime, + }) + } catch (e) { + ctx.host.log.warn("billing export failed: " + String(e)) + return { status: "runner_failed" } + } + if (!exported || typeof exported.status !== "string") return { status: "runner_failed" } + if (exported.status !== "ok" || !text(exported.csv)) return { status: exported.status } + const rows = parseBillingMetricsCsv(exported.csv) + let promptTokens = 0 + let generatedTokens = 0 + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i] + const usageType = text(row.usage_type || row.usageType) + if (usageType && !/inference_usage/i.test(usageType)) continue + promptTokens += num(row.prompt_tokens || row.promptTokens) || 0 + generatedTokens += num(row.completion_tokens || row.completionTokens) || 0 + } + const totalTokens = promptTokens + generatedTokens + return totalTokens > 0 + ? { status: "ok", label: window.label, promptTokens, generatedTokens, totalTokens } + : { status: "empty" } + } + + function buildOutput(ctx, account, quotas, billingUsage) { + const spendQuota = findQuota(quotas, ["monthly-spend-usd"], /^monthly-spend-usd$/) + const totalTokensQuota = findQuota(quotas, TOTAL_TOKENS_NAMES, /(serverless|inference).*(total|usage).*tokens/i) + const plan = inferPlan(spendQuota) + const lines = [] + + const promptTotal = billingUsage && billingUsage.status === "ok" ? billingUsage.promptTokens : null + const generatedTotal = billingUsage && billingUsage.status === "ok" ? billingUsage.generatedTokens : null + const totalTokens = (billingUsage && billingUsage.status === "ok" && billingUsage.totalTokens) || tokenAmount(totalTokensQuota) || null + if (totalTokens !== null && totalTokens > 0) { + lines.push(ctx.line.text({ label: "Serverless usage", value: formatCompactCount(totalTokens) + " tokens", subtitle: billingUsage && billingUsage.status === "ok" ? billingUsage.label : "Tokens reported by Fireworks" })) + } else if (billingUsage && billingUsage.status !== "ok") { + const reason = + billingUsage.status === "no_runner" || billingUsage.status === "unsupported" + ? "Install firectl for official billing export" + : billingUsage.status === "runner_failed" + ? "Billing export failed; showing spend fallback" + : "Token totals not exposed for this account" + lines.push(ctx.line.text({ label: "Serverless usage", value: "Unavailable", subtitle: reason })) + } + + if (promptTotal !== null && promptTotal > 0) { + lines.push(ctx.line.text({ label: "Prompt tokens", value: formatCompactCount(promptTotal) + " tokens", subtitle: billingUsage && billingUsage.status === "ok" ? billingUsage.label : "Tokens reported by Fireworks" })) + } + + if (generatedTotal !== null && generatedTotal > 0) { + lines.push(ctx.line.text({ label: "Generated tokens", value: formatCompactCount(generatedTotal) + " tokens", subtitle: billingUsage && billingUsage.status === "ok" ? billingUsage.label : "Tokens reported by Fireworks" })) + } + + if (spendQuota && spendQuota.usage !== null && spendQuota.usage >= 0) { + const spendSubtitle = + spendQuota.value !== null && spendQuota.value > 0 + ? "This month against budget " + formatDollars(spendQuota.value) + : "Calendar month to date" + lines.push(ctx.line.text({ label: "Month spend", value: formatDollars(spendQuota.usage), subtitle: spendSubtitle })) + } + + if (spendQuota && spendQuota.maxValue !== null && spendQuota.maxValue > 0) { + const budgetValue = spendQuota.value !== null && spendQuota.value > 0 ? formatDollars(spendQuota.value) : "Not set" + lines.push(ctx.line.text({ label: "Budget", value: budgetValue, subtitle: "Tier cap " + formatDollars(spendQuota.maxValue) })) + } + + const statusLine = buildStatusLine(ctx, account) + if (statusLine) lines.push(statusLine) + + if (!lines.length) lines.push(ctx.line.badge({ label: "Status", text: "No usage data", color: "#a3a3a3" })) + return { plan, lines } + } + + function probe(ctx) { + const nowMs = ctx.util.parseDateMs(ctx.nowIso) || Date.now() + const apiKey = loadApiKey(ctx) + if (!apiKey) { + throw 'No Fireworks API key found. Save it in macOS Keychain as "OpenUsage Fireworks AI API Key" or set FIREWORKS_API_KEY, then restart OpenUsage.' + } + ctx.host.log.info("api key loaded from " + apiKey.source) + const account = pickAccount(listAccounts(ctx, apiKey.value)) + if (!account) throw "No Fireworks account found for this API key." + const quotas = listQuotas(ctx, apiKey.value, account.accountId).map((item) => normalizeQuota(ctx, item)).filter(Boolean) + const billingUsage = loadBillingUsage(ctx, apiKey.value, account.accountId, nowMs) + return buildOutput(ctx, account, quotas, billingUsage) + } + + globalThis.__openusage_plugin = { id: "fireworks-ai", probe } +})() diff --git a/plugins/fireworks-ai/plugin.json b/plugins/fireworks-ai/plugin.json new file mode 100644 index 00000000..38510227 --- /dev/null +++ b/plugins/fireworks-ai/plugin.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "fireworks-ai", + "name": "Fireworks AI", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#5019C5", + "lines": [ + { "type": "text", "label": "Serverless usage", "scope": "overview" }, + { "type": "text", "label": "Prompt tokens", "scope": "overview" }, + { "type": "text", "label": "Generated tokens", "scope": "overview" }, + { "type": "text", "label": "Month spend", "scope": "overview" }, + { "type": "text", "label": "Budget", "scope": "detail" }, + { "type": "badge", "label": "Status", "scope": "detail" } + ] +} diff --git a/plugins/fireworks-ai/plugin.test.js b/plugins/fireworks-ai/plugin.test.js new file mode 100644 index 00000000..5915cacc --- /dev/null +++ b/plugins/fireworks-ai/plugin.test.js @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { makeCtx } from "../test-helpers.js" + +const KEYCHAIN_SERVICE = "OpenUsage Fireworks AI API Key" +const BILLING_CSV = `usage_type,model_name,prompt_tokens,completion_tokens,start_time,end_time +TEXT_COMPLETION_INFERENCE_USAGE,accounts/fireworks/models/kimi-k2p5,70000000,33890000,2026-01-03T00:00:00Z,2026-02-02T00:00:00Z` + +const loadPlugin = async () => { + await import("./plugin.js") + return globalThis.__openusage_plugin +} + +const ACCOUNTS = { + accounts: [ + { + name: "accounts/acct_primary", + displayName: "Primary", + state: "READY", + suspendState: "UNSUSPENDED", + }, + ], +} + +const QUOTAS = { + quotas: [ + { + name: "accounts/acct_primary/quotas/monthly-spend-usd", + value: 50, + maxValue: 500, + usage: 12.34, + updateTime: "2026-02-01T12:00:00.000Z", + }, + { + name: "accounts/acct_primary/quotas/serverless-inference-prompt-tokens-per-second", + value: 2000, + usage: 350, + updateTime: "2026-02-01T12:00:00.000Z", + }, + { + name: "accounts/acct_primary/quotas/serverless-inference-generated-tokens-per-second", + value: 400, + usage: 80, + updateTime: "2026-02-01T12:00:00.000Z", + }, + { + name: "accounts/acct_primary/quotas/serverless-inference-prompt-tokens", + usage: 70000000, + updateTime: "2026-02-01T12:00:00.000Z", + }, + { + name: "accounts/acct_primary/quotas/serverless-inference-output-tokens", + usage: 33890000, + updateTime: "2026-02-01T12:00:00.000Z", + }, + ], +} + +const mockApi = (ctx, accounts = ACCOUNTS, quotas = QUOTAS) => { + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/accounts?")) return { status: 200, bodyText: JSON.stringify(accounts) } + return { status: 200, bodyText: JSON.stringify(quotas) } + }) +} + +describe("fireworks-ai plugin", () => { + beforeEach(() => { + delete globalThis.__openusage_plugin + vi.resetModules() + }) + + it("throws when no keychain item or env var is configured", async () => { + const ctx = makeCtx() + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("No Fireworks API key found") + }) + + it("prefers current-user keychain over env", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + ctx.host.env.get.mockImplementation((name) => (name === "FIREWORKS_API_KEY" ? "fw-env-key" : null)) + mockApi(ctx) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Tier 2") + expect(ctx.host.keychain.readGenericPasswordForCurrentUser).toHaveBeenCalledWith(KEYCHAIN_SERVICE) + expect(ctx.host.http.request.mock.calls[0][0].headers.Authorization).toBe("Bearer fw-current-user-key") + }) + + it("falls back to FIREWORKS_API_KEY when keychain is unavailable", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockImplementation(() => { + throw new Error("keychain item not found") + }) + ctx.host.keychain.readGenericPassword.mockImplementation(() => { + throw new Error("keychain item not found") + }) + ctx.host.env.get.mockImplementation((name) => (name === "FIREWORKS_API_KEY" ? "fw-env-key" : null)) + mockApi(ctx) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(ctx.host.http.request.mock.calls[0][0].headers.Authorization).toBe("Bearer fw-env-key") + }) + + it("renders serverless usage first, then throughput, spend, and budget from quotas", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + ctx.host.fireworks.exportBillingMetrics.mockReturnValue({ status: "ok", csv: BILLING_CSV }) + mockApi(ctx) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Tier 2") + expect(result.lines[0]).toMatchObject({ + type: "text", + label: "Serverless usage", + value: "103.89M tokens", + subtitle: "Last 30 days", + }) + expect(result.lines.find((line) => line.label === "Prompt tokens")).toMatchObject({ + type: "text", + value: "70M tokens", + subtitle: "Last 30 days", + }) + expect(result.lines.find((line) => line.label === "Generated tokens")).toMatchObject({ + type: "text", + value: "33.89M tokens", + subtitle: "Last 30 days", + }) + expect(result.lines.find((line) => line.label === "Month spend")).toMatchObject({ + type: "text", + value: "$12.34", + subtitle: "This month against budget $50", + }) + expect(result.lines.find((line) => line.label === "Budget")).toMatchObject({ + type: "text", + value: "$50", + subtitle: "Tier cap $500", + }) + }) + + it("uses date-only billing windows for the official export helper", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + ctx.host.fireworks.exportBillingMetrics.mockReturnValue({ status: "empty" }) + mockApi(ctx) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(ctx.host.fireworks.exportBillingMetrics).toHaveBeenCalledWith({ + apiKey: "fw-current-user-key", + accountId: "acct_primary", + startTime: "2026-01-04", + endTime: "2026-02-03", + }) + }) + + it("uses the stripped account id when the account name is a resource path", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + mockApi(ctx) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + const quotaCall = ctx.host.http.request.mock.calls[1][0] + expect(quotaCall.url).toContain("/accounts/acct_primary/quotas?pageSize=200") + }) + + it("shows Budget not set when the account has a tier cap but no configured spend limit", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + mockApi(ctx, ACCOUNTS, { + quotas: [ + { + name: "accounts/acct_primary/quotas/monthly-spend-usd", + value: 0, + maxValue: 50, + usage: 0, + updateTime: "2026-02-01T12:00:00.000Z", + }, + ], + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Tier 1") + expect(result.lines.find((line) => line.label === "Budget")).toMatchObject({ + type: "text", + value: "Not set", + subtitle: "Tier cap $50", + }) + }) + + it("uses aggregate token quota directly when the API returns a single total-tokens line", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + ctx.host.fireworks.exportBillingMetrics.mockReturnValue({ status: "unavailable" }) + mockApi(ctx, ACCOUNTS, { + quotas: [ + { + name: "accounts/acct_primary/quotas/serverless-inference-total-tokens", + currentUsage: 1250000, + updateTime: "2026-02-01T12:00:00.000Z", + }, + ], + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Serverless usage")).toMatchObject({ + type: "text", + value: "1.25M tokens", + }) + }) + + it("falls back to spend and budget when billing export is unavailable and token quotas are absent", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + ctx.host.fireworks.exportBillingMetrics.mockReturnValue({ status: "no_runner" }) + mockApi(ctx, ACCOUNTS, { + quotas: [ + { + name: "accounts/acct_primary/quotas/monthly-spend-usd", + value: 30, + maxValue: 50, + usage: 7, + updateTime: "2026-02-01T12:00:00.000Z", + }, + ], + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Serverless usage")).toMatchObject({ + type: "text", + value: "Unavailable", + subtitle: "Install firectl for official billing export", + }) + expect(result.lines.map((line) => line.label)).toEqual(["Serverless usage", "Month spend", "Budget"]) + }) + + it("shows account status when the selected account is suspended", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + mockApi( + ctx, + { + accounts: [ + { + name: "accounts/acct_primary", + state: "READY", + suspendState: "SUSPENDED", + }, + ], + }, + { quotas: [] } + ) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Status")).toMatchObject({ + type: "badge", + text: "SUSPENDED", + }) + }) + + it("throws an invalid-key error on auth failures", async () => { + const ctx = makeCtx() + ctx.host.keychain.readGenericPasswordForCurrentUser.mockReturnValue("fw-current-user-key") + ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("API key invalid. Check your Fireworks AI API key.") + }) +}) diff --git a/plugins/test-helpers.js b/plugins/test-helpers.js index ab1a1d61..ef7410f8 100644 --- a/plugins/test-helpers.js +++ b/plugins/test-helpers.js @@ -82,6 +82,9 @@ export const makeCtx = () => { ccusage: { query: vi.fn(() => null), }, + fireworks: { + exportBillingMetrics: vi.fn(() => ({ status: "unavailable" })), + }, log: { trace: vi.fn(), debug: vi.fn(), diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index ef184ad9..b2648a2d 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; -const WHITELISTED_ENV_VARS: [&str; 16] = [ +const WHITELISTED_ENV_VARS: [&str; 17] = [ "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", @@ -28,6 +28,7 @@ const WHITELISTED_ENV_VARS: [&str; 16] = [ "MINIMAX_CN_API_KEY", "SYNTHETIC_API_KEY", "PI_CODING_AGENT_DIR", + "FIREWORKS_API_KEY", ]; fn last_non_empty_trimmed_line(text: &str) -> Option { @@ -216,6 +217,8 @@ fn redact_value(value: &str) -> String { /// Redact sensitive query parameters in URL fn redact_url(url: &str) -> String { + let account_path_pattern = regex_lite::Regex::new(r"(?P/accounts/)(?P[^/?#]+)") + .expect("valid account path regex"); let sensitive_params = [ "key", "api_key", @@ -239,6 +242,12 @@ fn redact_url(url: &str) -> String { "login", ]; + let url = account_path_pattern + .replace_all(url, |caps: ®ex_lite::Captures| { + format!("{}{}", &caps["prefix"], redact_value(&caps["value"])) + }) + .to_string(); + if let Some(query_start) = url.find('?') { let (base, query) = url.split_at(query_start + 1); let redacted_params: Vec = query @@ -261,7 +270,7 @@ fn redact_url(url: &str) -> String { .collect(); format!("{}{}", base, redacted_params.join("&")) } else { - url.to_string() + url } } @@ -509,6 +518,7 @@ pub fn inject_host_api<'js>( inject_sqlite(ctx, &host)?; inject_ls(ctx, &host, plugin_id)?; inject_ccusage(ctx, &host, plugin_id)?; + inject_fireworks(ctx, &host, plugin_id)?; probe_ctx.set("host", host)?; globals.set("__openusage_ctx", probe_ctx)?; @@ -1992,6 +2002,164 @@ pub fn patch_ccusage_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { ) } +#[derive(Default, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct FireworksBillingExportOpts { + api_key: String, + account_id: String, + start_time: String, + end_time: String, +} + +fn firectl_runner_candidates() -> [&'static str; 3] { + ["firectl", "/opt/homebrew/bin/firectl", "/usr/local/bin/firectl"] +} + +fn resolve_firectl_runner() -> Option { + for candidate in firectl_runner_candidates() { + if Command::new(candidate) + .arg("--help") + .status() + .map(|status| status.success()) + .unwrap_or(false) + { + return Some(candidate.to_string()); + } + } + None +} + +fn run_fireworks_billing_export( + opts: &FireworksBillingExportOpts, + plugin_id: &str, +) -> serde_json::Value { + if opts.api_key.trim().is_empty() + || opts.account_id.trim().is_empty() + || opts.start_time.trim().is_empty() + || opts.end_time.trim().is_empty() + { + return serde_json::json!({ "status": "invalid_opts" }); + } + + let Some(program) = resolve_firectl_runner() else { + log::warn!("[plugin:{}] firectl not found for billing export", plugin_id); + return serde_json::json!({ "status": "no_runner" }); + }; + + let file_name = format!( + "openusage-fireworks-{}-{}.csv", + std::process::id(), + iso_now().replace([':', '.'], "-") + ); + let temp_dir = std::env::temp_dir(); + let output_path = temp_dir.join(&file_name); + let result = Command::new(&program) + .current_dir(&temp_dir) + .args([ + "billing", + "export-metrics", + "--api-key", + opts.api_key.trim(), + "--account-id", + opts.account_id.trim(), + "--start-time", + opts.start_time.trim(), + "--end-time", + opts.end_time.trim(), + "--filename", + file_name.as_str(), + ]) + .output(); + + let read_csv = || std::fs::read_to_string(&output_path).ok(); + let cleanup = || { + let _ = std::fs::remove_file(&output_path); + }; + + match result { + Ok(output) if output.status.success() => { + let csv = read_csv(); + cleanup(); + match csv { + Some(text) if !text.trim().is_empty() => { + serde_json::json!({ "status": "ok", "csv": text }) + } + _ => { + log::warn!( + "[plugin:{}] billing export succeeded but no CSV was produced", + plugin_id + ); + serde_json::json!({ "status": "empty" }) + } + } + } + Ok(output) => { + cleanup(); + let stderr = String::from_utf8_lossy(&output.stderr); + log::warn!( + "[plugin:{}] firectl billing export failed: {}", + plugin_id, + stderr.lines().next().unwrap_or("unknown error").trim() + ); + serde_json::json!({ "status": "runner_failed" }) + } + Err(err) => { + cleanup(); + log::warn!( + "[plugin:{}] failed to spawn firectl billing export: {}", + plugin_id, + err + ); + serde_json::json!({ "status": "runner_failed" }) + } + } +} + +fn inject_fireworks<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + plugin_id: &str, +) -> rquickjs::Result<()> { + let fireworks_obj = Object::new(ctx.clone())?; + let pid = plugin_id.to_string(); + + fireworks_obj.set( + "_exportBillingMetricsRaw", + Function::new( + ctx.clone(), + move |_ctx_inner: Ctx<'_>, opts_json: String| -> rquickjs::Result { + let opts: FireworksBillingExportOpts = + serde_json::from_str(&opts_json).unwrap_or_default(); + Ok(run_fireworks_billing_export(&opts, &pid).to_string()) + }, + )?, + )?; + + host.set("fireworks", fireworks_obj)?; + Ok(()) +} + +pub fn patch_fireworks_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + var rawFn = __openusage_ctx.host.fireworks._exportBillingMetricsRaw; + __openusage_ctx.host.fireworks.exportBillingMetrics = function(opts) { + var result = rawFn(JSON.stringify(opts || {})); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "runner_failed" }; + }; + })(); + "# + .as_bytes(), + ) +} + fn inject_keychain<'js>( ctx: &Ctx<'js>, host: &Object<'js>, @@ -2526,6 +2694,27 @@ mod tests { }); } + #[test] + fn fireworks_api_exposes_billing_export_helper() { + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); + patch_fireworks_wrapper(&ctx).expect("patch fireworks wrapper"); + let globals = ctx.globals(); + let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); + let host: Object = probe_ctx.get("host").expect("host"); + let fireworks: Object = host.get("fireworks").expect("fireworks"); + let _raw: Function = fireworks + .get("_exportBillingMetricsRaw") + .expect("_exportBillingMetricsRaw"); + let _wrapped: Function = fireworks + .get("exportBillingMetrics") + .expect("exportBillingMetrics"); + }); + } + #[test] fn env_api_respects_allowlist_in_host_and_js() { let claude_env_vars = [ @@ -2643,6 +2832,57 @@ mod tests { }); } + #[test] + fn env_api_exposes_fireworks_api_key() { + struct RestoreEnvVar { + name: &'static str, + old: Option, + } + + impl Drop for RestoreEnvVar { + fn drop(&mut self) { + if let Some(value) = self.old.take() { + unsafe { std::env::set_var(self.name, value) }; + } else { + unsafe { std::env::remove_var(self.name) }; + } + } + } + + let name = "FIREWORKS_API_KEY"; + let old = std::env::var(name).ok(); + let _restore = RestoreEnvVar { name, old }; + unsafe { std::env::set_var(name, "fw-process-env-test-1234567890") }; + + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); + let globals = ctx.globals(); + let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); + let host: Object = probe_ctx.get("host").expect("host"); + let env: Object = host.get("env").expect("env"); + let get: Function = env.get("get").expect("get"); + + let value: Option = get.call((name.to_string(),)).expect("get"); + assert_eq!( + value.as_deref(), + Some("fw-process-env-test-1234567890"), + "fireworks env should be exposed to plugins" + ); + + let js_value: Option = ctx + .eval(r#"__openusage_ctx.host.env.get("FIREWORKS_API_KEY")"#) + .expect("js get"); + assert_eq!( + js_value.as_deref(), + Some("fw-process-env-test-1234567890"), + "fireworks env should be exposed from JS" + ); + }); + } + #[test] fn current_macos_keychain_account_prefers_explicit_user_value() { assert_eq!( @@ -2802,6 +3042,23 @@ mod tests { ); } + #[test] + fn redact_url_redacts_account_id_path_segment() { + let url = + "https://api.fireworks.ai/v1/accounts/acct_1234567890abcdef/quotas?pageSize=200"; + let redacted = redact_url(url); + assert!( + !redacted.contains("acct_1234567890abcdef"), + "account path segment should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("/quotas?pageSize=200"), + "non-sensitive path/query parts should remain visible, got: {}", + redacted + ); + } + #[test] fn redact_body_redacts_jwt() { let body = r#"{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}"#; diff --git a/src-tauri/src/plugin_engine/runtime.rs b/src-tauri/src/plugin_engine/runtime.rs index a45e0bea..1a68d118 100644 --- a/src-tauri/src/plugin_engine/runtime.rs +++ b/src-tauri/src/plugin_engine/runtime.rs @@ -82,6 +82,9 @@ pub fn run_probe(plugin: &LoadedPlugin, app_data_dir: &PathBuf, app_version: &st if host_api::patch_ccusage_wrapper(&ctx).is_err() { return error_output(plugin, "ccusage wrapper patch failed".to_string()); } + if host_api::patch_fireworks_wrapper(&ctx).is_err() { + return error_output(plugin, "fireworks wrapper patch failed".to_string()); + } if host_api::inject_utils(&ctx).is_err() { return error_output(plugin, "utils injection failed".to_string()); }