Add Fireworks balance usage panel (#6488)
* Add a Fireworks balance collector and teach the agents panel prepaid ledgers The omarchy-agent-usage-fireworks collector reads serverless token usage from the Fireworks billing API, grouped by day and model for the last 30 days, and reshapes it into the shared record contract. Fireworks does not expose its prepaid ledger through the documented API, so the record carries an estimated balance instead of rate limits: credits configured in ~/.config/omarchy/agents/fireworks.json minus rated account costs since the funding date. Credentials come from FIREWORKS_API_KEY/FIREWORKS_ACCOUNT_ID, the auth.ini that firectl set-api-key writes, or — last, so an explicit login wins — the key opencode stores for its fireworks-ai provider. The panel gains two generic capabilities any agent record can use: a balance object draws a BALANCE section — remaining credit, a fuel-gauge meter that drains toward empty and lights the bar alarm below 10%, and funded-versus-spent detail — and hasPromptStats: false keeps prompt and session counts out of today's tooltip for agents whose billing API only ever reports tokens, on this machine and through synced snapshots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Feed Claude and Codex usage from pi, omp, and opencode sessions A subscription burned entirely through another coding agent leaves no native Claude Code transcripts and no Codex session files, so the panel showed nothing for it. pi and omp write compatible JSONL sessions, and opencode records per-message provider, model, and token usage in its message database; the claude and codex collectors now scan all three — filtered to Anthropic and OpenAI providers respectively — and merge those numbers into their local stats. Fireworks stays out on purpose: its billing API already sees that traffic server-side, and a local scan would count the same tokens twice. The collector tests pin XDG_DATA_HOME so a developer's real opencode history cannot leak into fixture runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b85ae70ebd
commit
77cf58ccfe
@@ -220,6 +220,23 @@ Item {
|
||||
return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0
|
||||
|| numberValue(p.activeDays) > 0 || numberValue(p.todayPrompts) > 0
|
||||
|| numberValue(p.todaySessions) > 0 || (p.limits && p.limits.length > 0)
|
||||
|| !!p.balance
|
||||
}
|
||||
|
||||
// A prepaid agent's credit ledger. Like rate limits, the balance is
|
||||
// per-account and never merged across devices.
|
||||
function balanceValue(raw) {
|
||||
if (!raw || typeof raw !== "object") return null
|
||||
var remaining = Number(raw.remaining)
|
||||
var funded = Number(raw.funded)
|
||||
if (!isFinite(remaining) || remaining < 0) return null
|
||||
return {
|
||||
remaining: remaining,
|
||||
funded: isFinite(funded) && funded > 0 ? funded : 0,
|
||||
spent: Math.max(0, Number(raw.spent) || 0),
|
||||
currency: String(raw.currency || "USD"),
|
||||
estimated: raw.estimated === true
|
||||
}
|
||||
}
|
||||
|
||||
function displayProvider(record) {
|
||||
@@ -234,9 +251,11 @@ Item {
|
||||
usageStatusText: String(record.usageStatusText || ""),
|
||||
authHelpText: String(record.authHelpText || ""),
|
||||
|
||||
// Rate limits stay per-account and are never merged across devices.
|
||||
// Rate limits and balances stay per-account and are never merged
|
||||
// across devices.
|
||||
limits: Array.isArray(record.limits) ? record.limits : [],
|
||||
tierLabel: String(record.tierLabel || ""),
|
||||
balance: balanceValue(record.balance),
|
||||
|
||||
todayPrompts: synced ? numberValue(stats.todayPrompts) : numberValue(record.todayPrompts),
|
||||
todaySessions: synced ? numberValue(stats.todaySessions) : numberValue(record.todaySessions),
|
||||
@@ -248,6 +267,7 @@ Item {
|
||||
activeDays: synced ? numberValue(stats.activeDays) : numberValue(record.activeDays),
|
||||
modelUsage: synced ? (stats.modelUsage || ({})) : (record.modelUsage || ({})),
|
||||
hasLocalStats: synced ? (stats.hasLocalStats !== false) : (record.hasLocalStats !== false),
|
||||
hasPromptStats: synced ? (stats.hasPromptStats !== false) : (record.hasPromptStats !== false),
|
||||
|
||||
syncEnabled: synced,
|
||||
syncDeviceCount: deviceCount,
|
||||
@@ -514,9 +534,17 @@ Item {
|
||||
return { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }
|
||||
}
|
||||
|
||||
function addObjectNumbers(target, source) {
|
||||
// Device-scoped stats add up across machines; account-scoped stats
|
||||
// (Fireworks' billing API) are replicas of the same upstream truth on
|
||||
// every synced device, so the widest value wins — summing them would
|
||||
// double every token per machine.
|
||||
function combineNumber(additive, current, value) {
|
||||
return additive ? numberValue(current) + numberValue(value) : Math.max(numberValue(current), numberValue(value))
|
||||
}
|
||||
|
||||
function combineObjectNumbers(additive, target, source) {
|
||||
if (!source) return
|
||||
for (var key in source) target[key] = numberValue(target[key]) + numberValue(source[key])
|
||||
for (var key in source) target[key] = combineNumber(additive, target[key], source[key])
|
||||
}
|
||||
|
||||
function aggregateSnapshots(snapshots) {
|
||||
@@ -533,6 +561,7 @@ Item {
|
||||
providerName: "",
|
||||
ready: false,
|
||||
hasLocalStats: false,
|
||||
hasPromptStats: false,
|
||||
todayPrompts: 0,
|
||||
todaySessions: 0,
|
||||
todayTotalTokens: 0,
|
||||
@@ -560,35 +589,36 @@ Item {
|
||||
if (stats.providerName && acc.providerName === "") acc.providerName = String(stats.providerName)
|
||||
acc.ready = acc.ready || stats.ready === true
|
||||
acc.hasLocalStats = acc.hasLocalStats || stats.hasLocalStats !== false
|
||||
acc.todayPrompts += numberValue(stats.todayPrompts)
|
||||
acc.todaySessions += numberValue(stats.todaySessions)
|
||||
acc.todayTotalTokens += numberValue(stats.todayTotalTokens)
|
||||
acc.totalPrompts += numberValue(stats.totalPrompts)
|
||||
acc.totalSessions += numberValue(stats.totalSessions)
|
||||
// Snapshots from before the field existed only came from agents that
|
||||
// count prompts, so a missing value reads as true.
|
||||
acc.hasPromptStats = acc.hasPromptStats || stats.hasPromptStats !== false
|
||||
var additive = String(stats.scope || "device") !== "account"
|
||||
acc.todayPrompts = combineNumber(additive, acc.todayPrompts, stats.todayPrompts)
|
||||
acc.todaySessions = combineNumber(additive, acc.todaySessions, stats.todaySessions)
|
||||
acc.todayTotalTokens = combineNumber(additive, acc.todayTotalTokens, stats.todayTotalTokens)
|
||||
acc.totalPrompts = combineNumber(additive, acc.totalPrompts, stats.totalPrompts)
|
||||
acc.totalSessions = combineNumber(additive, acc.totalSessions, stats.totalSessions)
|
||||
// Active days overlap between machines, so union the dates rather than
|
||||
// summing counts. Snapshots written before activeDates existed only
|
||||
// carry a count; the widest one stands in for them.
|
||||
var activeDates = Array.isArray(stats.activeDates) ? stats.activeDates : []
|
||||
for (var ad = 0; ad < activeDates.length; ad++) acc.activeDates[String(activeDates[ad])] = true
|
||||
acc.activeDays = Math.max(acc.activeDays, numberValue(stats.activeDays))
|
||||
addObjectNumbers(acc.todayTokensByModel, stats.todayTokensByModel || {})
|
||||
combineObjectNumbers(additive, acc.todayTokensByModel, stats.todayTokensByModel || {})
|
||||
|
||||
var recent = Array.isArray(stats.recentDays) ? stats.recentDays : []
|
||||
for (var r = 0; r < recent.length; r++) {
|
||||
var day = recent[r] || {}
|
||||
var date = String(day.date || "")
|
||||
if (acc.recentByDay[date] !== undefined) acc.recentByDay[date] += numberValue(day.messageCount)
|
||||
if (acc.recentByDay[date] !== undefined)
|
||||
acc.recentByDay[date] = combineNumber(additive, acc.recentByDay[date], day.messageCount)
|
||||
}
|
||||
|
||||
var usage = stats.modelUsage || {}
|
||||
for (var modelId in usage) {
|
||||
var bucket = acc.modelUsage[modelId]
|
||||
if (!bucket) bucket = acc.modelUsage[modelId] = emptyTokenBucket()
|
||||
var source = usage[modelId] || {}
|
||||
bucket.inputTokens += numberValue(source.inputTokens)
|
||||
bucket.outputTokens += numberValue(source.outputTokens)
|
||||
bucket.cacheReadInputTokens += numberValue(source.cacheReadInputTokens)
|
||||
bucket.cacheCreationInputTokens += numberValue(source.cacheCreationInputTokens)
|
||||
combineObjectNumbers(additive, bucket, usage[modelId] || {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -604,6 +634,7 @@ Item {
|
||||
providerName: acc.providerName,
|
||||
ready: acc.ready || providerDevices.length > 0,
|
||||
hasLocalStats: acc.hasLocalStats,
|
||||
hasPromptStats: acc.hasPromptStats,
|
||||
todayPrompts: acc.todayPrompts,
|
||||
todaySessions: acc.todaySessions,
|
||||
todayTotalTokens: acc.todayTotalTokens,
|
||||
@@ -636,6 +667,8 @@ Item {
|
||||
providerName: String(record.name || record.id),
|
||||
ready: record.ready === true,
|
||||
hasLocalStats: record.hasLocalStats !== false,
|
||||
hasPromptStats: record.hasPromptStats !== false,
|
||||
scope: String(record.scope || "device"),
|
||||
todayPrompts: numberValue(record.todayPrompts),
|
||||
todaySessions: numberValue(record.todaySessions),
|
||||
todayTotalTokens: numberValue(record.todayTotalTokens),
|
||||
@@ -683,6 +716,7 @@ Item {
|
||||
|
||||
function modelWordCase(word) {
|
||||
if (word === "gpt") return "GPT"
|
||||
if (word === "deepseek") return "DeepSeek"
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,12 @@ Panel {
|
||||
readonly property var limits: limitWindows(provider)
|
||||
readonly property var models: modelRows(provider)
|
||||
readonly property var headline: bindingWindow(provider)
|
||||
readonly property bool alarming: !!headline && headline.percent >= 0.9
|
||||
readonly property var balance: provider ? (provider.balance || null) : null
|
||||
// A prepaid account runs low the way a subscription window fills up: the
|
||||
// last 10% of the funded credits lights the same alarm.
|
||||
readonly property bool balanceAlarming: !!balance && balance.funded > 0
|
||||
&& balance.remaining / balance.funded <= 0.1
|
||||
readonly property bool alarming: (!!headline && headline.percent >= 0.9) || balanceAlarming
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
|
||||
function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
|
||||
@@ -134,6 +139,32 @@ Panel {
|
||||
return Math.max(1, minutes) + "m"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- balance
|
||||
//
|
||||
// Prepaid agents report a credit ledger instead of rate-limit windows: the
|
||||
// record's balance object carries remaining, funded, and spent amounts.
|
||||
|
||||
function currencyPrefix(currency) {
|
||||
var code = String(currency || "USD").toUpperCase()
|
||||
if (code === "USD") return "$"
|
||||
if (code === "EUR") return "€"
|
||||
if (code === "GBP") return "£"
|
||||
return code + " "
|
||||
}
|
||||
|
||||
function formatMoney(value, currency) {
|
||||
var amount = Number(value)
|
||||
if (!isFinite(amount)) amount = 0
|
||||
return currencyPrefix(currency) + amount.toFixed(2)
|
||||
}
|
||||
|
||||
function balanceDetailText(b) {
|
||||
if (!b || !(b.funded > 0)) return ""
|
||||
var text = formatMoney(b.spent, b.currency) + " spent of " + formatMoney(b.funded, b.currency) + " funded"
|
||||
if (b.estimated) text += " · estimated"
|
||||
return text
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- content
|
||||
|
||||
// The plan you pay for, under the name of the tool it pays for. Limits live
|
||||
@@ -174,8 +205,9 @@ Panel {
|
||||
: dayName(day.date) + " " + (parsed.getMonth() + 1) + "/" + parsed.getDate()
|
||||
var text = label + " · " + usage.formatTokenCount(Number(day.messageCount || 0)) + " tokens"
|
||||
// Prompt and session counts only exist for today, so they ride along here
|
||||
// instead of taking a section of their own.
|
||||
if (today && provider)
|
||||
// instead of taking a section of their own. Billing-API agents never
|
||||
// count prompts, and "0 prompts" would read as a quiet day, not a gap.
|
||||
if (today && provider && provider.hasPromptStats !== false)
|
||||
text += " · " + Number(provider.todayPrompts || 0) + " prompts · "
|
||||
+ Number(provider.todaySessions || 0) + " sessions"
|
||||
return text
|
||||
@@ -476,12 +508,73 @@ Panel {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Limits ----------
|
||||
// ---------- Balance / limits ----------
|
||||
PanelSeparator {
|
||||
visible: limitsSection.visible
|
||||
visible: balanceSection.visible || limitsSection.visible
|
||||
foreground: root.foreground
|
||||
}
|
||||
|
||||
Column {
|
||||
id: balanceSection
|
||||
visible: !!root.balance
|
||||
width: parent.width
|
||||
spacing: Style.space(10)
|
||||
|
||||
// The meter shows what is left, not what is used: a prepaid
|
||||
// account drains toward empty rather than filling toward a cap.
|
||||
readonly property real ratio: root.balance && root.balance.funded > 0
|
||||
? root.clamp(root.balance.remaining / root.balance.funded, 0, 1)
|
||||
: -1
|
||||
|
||||
PanelSectionHeader {
|
||||
width: parent.width
|
||||
text: "BALANCE"
|
||||
foreground: root.foreground
|
||||
fontFamily: root.fontFamily
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: Math.max(balanceLabel.implicitHeight, balanceValue.implicitHeight)
|
||||
|
||||
Text {
|
||||
id: balanceLabel
|
||||
text: "Prepaid credits"
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Text {
|
||||
id: balanceValue
|
||||
text: root.balance ? root.formatMoney(root.balance.remaining, root.balance.currency) : ""
|
||||
color: root.balanceAlarming ? root.urgent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
|
||||
Meter {
|
||||
visible: balanceSection.ratio >= 0
|
||||
width: parent.width
|
||||
value: balanceSection.ratio
|
||||
alarming: root.balanceAlarming
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: text !== ""
|
||||
width: parent.width
|
||||
text: root.balanceDetailText(root.balance)
|
||||
color: root.dim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: limitsSection
|
||||
visible: root.limits.length > 0
|
||||
|
||||
@@ -15,6 +15,9 @@ cross-device aggregation); `Agent.qml` is the per-record file watcher.
|
||||
It appears only when more than one agent is enabled.
|
||||
- **Limits** — the percentage of each allowance used, a matching meter, and
|
||||
the time until the session or weekly window resets.
|
||||
- **Balance** — prepaid agents report a credit ledger instead of limits:
|
||||
remaining credit, a fuel-gauge meter that drains toward empty, and
|
||||
funded-versus-spent detail.
|
||||
- **Tokens by day** — one row per day for the last week: day, bar, tokens, with today
|
||||
bolded at the bottom. Hover today for its prompt and session count.
|
||||
- **Tokens by model** — tokens per model with the bar behind each row scaled
|
||||
@@ -49,12 +52,45 @@ light surfaces — and the bar glyph stands in when there is none.
|
||||
|
||||
| Collector | Limits | Local stats |
|
||||
|---|---|---|
|
||||
| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, plus `stats-cache.json` and `history.jsonl` as fallback |
|
||||
| `codex` | The Codex app-server RPC | native Codex CLI session files (and pi sessions) |
|
||||
| `claude` | Anthropic's OAuth usage endpoint (5-hour session + 7-day weekly) | `~/.claude/projects` transcripts, opencode sessions on an Anthropic provider, plus `stats-cache.json` and `history.jsonl` as fallback |
|
||||
| `codex` | The Codex app-server RPC | native Codex CLI session files (plus pi and opencode sessions) |
|
||||
| `fireworks` | Estimated prepaid balance: configured funding minus rated account costs | Fireworks billing API, grouped by day and model for the last 30 days |
|
||||
|
||||
Claude limits need a signed-in CLI; without credentials the panel says so and
|
||||
falls back to local stats only. A non-default Claude directory is honored via
|
||||
`CLAUDE_CONFIG_DIR`, Codex via `CODEX_HOME`.
|
||||
`CLAUDE_CONFIG_DIR`, Codex via `CODEX_HOME`. Fireworks reads
|
||||
`FIREWORKS_API_KEY` and `FIREWORKS_ACCOUNT_ID` first, then
|
||||
`~/.fireworks/auth.ini` (which `firectl set-api-key` creates), then the key
|
||||
opencode stores in `~/.local/share/opencode/auth.json` when Fireworks is
|
||||
signed in there.
|
||||
|
||||
### Fireworks balance
|
||||
|
||||
The collector first asks the account's `:getBalance` endpoint for the real
|
||||
prepaid ledger. That endpoint exists but is permission-gated, and as of
|
||||
August 2026 no console-issued API key passes it — Fireworks appears to
|
||||
reserve it for the dashboard session. The probe stays because it is cheap
|
||||
and the live figure lights up automatically if Fireworks ever opens it to
|
||||
keys. Until then the collector falls back to estimating the balance from
|
||||
configuration in `~/.config/omarchy/agents/fireworks.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"accountId": "",
|
||||
"fundedAmount": 20,
|
||||
"fundedAt": "2026-07-01"
|
||||
}
|
||||
```
|
||||
|
||||
Set `fundedAmount` to the credits purchased and optionally `fundedAt` to the
|
||||
purchase date; with no date, the collector uses the account creation time. It
|
||||
subtracts rated account costs and the panel labels the result as estimated.
|
||||
For a later top-up, increase `fundedAmount` by the new credit while keeping
|
||||
the original `fundedAt`, so both the funding and spend still cover the same
|
||||
period. `accountId` only matters when one API key can access several
|
||||
accounts. Without a configured `fundedAmount` the tab still shows token
|
||||
usage, just no balance. With a live ledger, `fundedAmount` is optional and
|
||||
only adds the meter and the spent-of-funded line under the real figure.
|
||||
|
||||
## Interactions
|
||||
|
||||
@@ -91,7 +127,8 @@ edit `shell.json` directly):
|
||||
```bash
|
||||
omarchy bar set omarchy.agents providers '{
|
||||
"claude": { "enabled": true },
|
||||
"codex": { "enabled": false }
|
||||
"codex": { "enabled": false },
|
||||
"fireworks": { "enabled": true }
|
||||
}' --json
|
||||
```
|
||||
|
||||
@@ -102,8 +139,12 @@ the records regenerate.
|
||||
With `syncMode` on, every `*.json` snapshot in `syncDir` is merged, so today,
|
||||
the last 7 days, and the all-time totals cover every machine you code on —
|
||||
active days are unioned by date rather than summed. Rate limits stay
|
||||
per-account and are never merged.
|
||||
per-account and are never merged. A record may declare `"scope": "account"`
|
||||
when its stats are account-global rather than machine-local (Fireworks'
|
||||
billing API); those merge by taking the widest value instead of summing, so
|
||||
the same account synced from two machines is not counted twice.
|
||||
|
||||
One caveat on "all-time": the Codex collector only reads native session files
|
||||
touched in the last 30 days, so Codex totals and its day count cover that
|
||||
window. Claude's cover every transcript still on disk.
|
||||
touched in the last 30 days, and Fireworks requests the last 30 days from its
|
||||
billing API, so their totals and day counts cover that window. Claude's cover
|
||||
every transcript still on disk.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<g fill="none" stroke="#ff6b22" stroke-linecap="round" stroke-width="5">
|
||||
<path d="M32 5v12M32 47v12M5 32h12M47 32h12"/>
|
||||
<path d="m13 13 8.5 8.5M42.5 42.5 51 51M51 13l-8.5 8.5M21.5 42.5 13 51"/>
|
||||
</g>
|
||||
<circle cx="32" cy="32" r="4" fill="#ff6b22"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
@@ -5,7 +5,7 @@
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"license": "MIT",
|
||||
"description": "Claude Code and Codex usage, limits, and pace in a native Omarchy bar panel.",
|
||||
"description": "Claude Code, Codex, and Fireworks usage, limits, and pace in a native Omarchy bar panel.",
|
||||
"kinds": ["bar-widget"],
|
||||
"activation": "on-demand",
|
||||
"entryPoints": {
|
||||
@@ -20,7 +20,8 @@
|
||||
"defaults": {
|
||||
"providers": {
|
||||
"claude": { "enabled": true },
|
||||
"codex": { "enabled": true }
|
||||
"codex": { "enabled": true },
|
||||
"fireworks": { "enabled": true }
|
||||
},
|
||||
"refreshIntervalSec": 900,
|
||||
"syncMode": "Off",
|
||||
|
||||
Reference in New Issue
Block a user