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:
David Heinemeier Hansson
2026-08-07 23:49:43 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b85ae70ebd
commit 77cf58ccfe
11 changed files with 1478 additions and 94 deletions
+257 -2
View File
@@ -6,8 +6,9 @@
Everything the agents panel shows for Claude comes from this one
command: local transcript stats from ~/.claude/projects, the stats-cache and
history fallbacks for machines without transcripts, and the authoritative
rate limits from Anthropic's OAuth usage endpoint. The panel itself only ever
history fallbacks for machines without transcripts, pi/omp and opencode
sessions that ran on an Anthropic provider, and the authoritative rate
limits from Anthropic's OAuth usage endpoint. The panel itself only ever
reads the JSON this prints; it never talks to disk formats or endpoints.
"""
@@ -20,6 +21,7 @@ import hashlib
import json
import os
import re
import sqlite3
import sys
import time
import urllib.error
@@ -315,6 +317,251 @@ def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]:
return prompts, len(sessions)
# --------------------------------------------------------------- pi and omp
#
# These agents can consume a Claude subscription without writing native
# Claude Code transcripts. Their compatible JSONL session formats carry the
# provider, model, and token usage on every assistant message.
def scan_pi_usage(max_age_seconds: float) -> dict[str, Any] | None:
roots = [
Path.home() / ".pi" / "agent" / "sessions",
Path.home() / ".omp" / "agent" / "sessions",
]
cache_file = cache_root() / "claude-pi-sessions.json"
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
return cached.get("stats")
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
seen: set[str] = set()
prompts = 0
today_prompt_count = 0
today_token_total = 0
for root in roots:
files = root.rglob("*.jsonl") if root.is_dir() else []
for path in files:
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, 1):
if '"usage"' not in line or '"assistant"' not in line:
continue
try:
entry = json.loads(line)
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
if entry.get("type") != "message" or message.get("role") != "assistant":
continue
provider = str(message.get("provider") or "")
api = str(message.get("api") or "")
if provider != "anthropic" and not api.startswith("anthropic"):
continue
unique_key = f"{path}:{entry.get('id') or line_number}"
if unique_key in seen:
continue
seen.add(unique_key)
usage = message.get("usage") or {}
input_tokens = usage_token(usage, "input", "inputTokens")
output_tokens = usage_token(usage, "output", "outputTokens")
cache_read = usage_token(usage, "cacheRead", "cache_read_input_tokens")
cache_write = usage_token(usage, "cacheWrite", "cache_creation_input_tokens")
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
total = number(usage.get("totalTokens"))
input_tokens = total
if total <= 0:
continue
model = str(message.get("model") or "claude")
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
except Exception:
continue
session_key = str(path)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except OSError:
continue
stats = None
if prompts > 0:
stats = {
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": [recent[day] for day in recent_dates],
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
"activeDays": len(active_days),
"activeDates": sorted(active_days),
}
write_json(cache_file, {"stats": stats})
return stats
# ---------------------------------------------------------------- opencode
#
# A Claude subscription burned entirely through opencode never writes a
# transcript under ~/.claude, but opencode records per-message provider,
# model, and token usage in its own database. Scan it for Anthropic-provider
# messages and merge the result into whatever the transcript scan found.
def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
if not db.is_file():
return None
# Same freshness contract as the transcript scan: --limits-only promises to
# reuse recent local stats, and a big opencode history walked on every panel
# open would break that promise.
cache_file = cache_root() / f"claude-opencode-{hashlib.sha1(str(db).encode('utf-8')).hexdigest()[:16]}.json"
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
return cached.get("stats")
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
prompts = 0
today_prompt_count = 0
today_token_total = 0
try:
# Read-only: opencode may be writing right now.
conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2)
except sqlite3.Error:
return None
try:
conn.execute("PRAGMA query_only = ON")
for session_id, raw in conn.execute("SELECT session_id, data FROM message"):
# One malformed row must not abort the scan, so every shape assumption
# lives inside the try.
try:
entry = json.loads(raw)
# Exact match: opencode provider ids are free-form, and a custom
# "anthropic-proxy" gateway is not this subscription.
if not isinstance(entry, dict) or entry.get("role") != "assistant":
continue
if str(entry.get("providerID") or "") != "anthropic":
continue
tokens = entry.get("tokens") or {}
cache = tokens.get("cache") or {}
input_tokens = number(tokens.get("input"))
# opencode keeps thinking tokens out of output; both are generated.
output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning"))
cache_read = number(cache.get("read"))
cache_write = number(cache.get("write"))
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
continue
created = number((entry.get("time") or {}).get("created"))
day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today
model = str(entry.get("modelID") or "claude").rstrip("/").split("/")[-1]
except Exception:
continue
session_key = "opencode:" + str(session_id)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except sqlite3.Error:
return None
finally:
conn.close()
stats = None
if prompts > 0:
stats = {
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": [recent[day] for day in recent_dates],
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
"activeDays": len(active_days),
"activeDates": sorted(active_days),
}
write_json(cache_file, {"stats": stats})
return stats
def merge_stats(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
for key in ("todayPrompts", "todaySessions", "todayTotalTokens", "totalPrompts", "totalSessions"):
merged[key] = number(base.get(key)) + number(extra.get(key))
combined = dict(base.get("todayTokensByModel") or {})
for model, count in (extra.get("todayTokensByModel") or {}).items():
combined[model] = number(combined.get(model)) + number(count)
merged["todayTokensByModel"] = combined
usage = {model: dict(bucket) for model, bucket in (base.get("modelUsage") or {}).items()}
for model, bucket in (extra.get("modelUsage") or {}).items():
target = usage.setdefault(model, empty_bucket())
for field, count in (bucket or {}).items():
target[field] = number(target.get(field)) + number(count)
merged["modelUsage"] = usage
by_date: dict[str, int] = {}
for source in (base.get("recentDays") or [], extra.get("recentDays") or []):
for day in source:
date = str((day or {}).get("date") or "")
if date:
by_date[date] = by_date.get(date, 0) + number((day or {}).get("messageCount"))
merged["recentDays"] = [{"date": date, "messageCount": by_date[date]} for date in sorted(by_date)]
# Sources overlap in time, so union dates rather than summing counts. A
# fallback that only knows a count still bounds the answer from below.
dates = set(base.get("activeDates") or []) | set(extra.get("activeDates") or [])
merged["activeDates"] = sorted(dates)
merged["activeDays"] = max(len(dates), number(base.get("activeDays")), number(extra.get("activeDays")))
return merged
# ------------------------------------------------------------------- limits
@@ -504,6 +751,14 @@ def main() -> int:
if today_prompts or today_sessions:
stats = dict(stats, todayPrompts=today_prompts, todaySessions=today_sessions)
pi_usage = scan_pi_usage(scan_age)
if pi_usage is not None:
stats = merge_stats(stats, pi_usage)
opencode = scan_opencode_usage(scan_age)
if opencode is not None:
stats = merge_stats(stats, opencode)
access_token, expires_at_ms, plan = oauth_login(claude_dir)
limits = collect_limits(access_token, expires_at_ms, args.force)
+111 -60
View File
@@ -4,9 +4,10 @@
# omarchy:hidden=true
"""Collect Codex usage into one display-ready JSON record.
Local stats come from native Codex CLI session files (and pi sessions that
ran through openai-codex); rate limits and the plan come from the Codex
app-server RPC. The agents panel only ever reads the JSON this prints.
Local stats come from native Codex CLI session files, pi/omp sessions that
ran through openai-codex, and opencode sessions that ran on an OpenAI
provider; rate limits and the plan come from the Codex app-server RPC. The agents
panel only ever reads the JSON this prints.
"""
import argparse
@@ -14,6 +15,7 @@ import json
import os
import select
import shutil
import sqlite3
import subprocess
import time
from datetime import datetime, timedelta, timezone
@@ -122,69 +124,117 @@ def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read,
def scan_pi_sessions():
root = Path.home() / ".pi" / "agent" / "sessions"
if not root.exists():
return
try:
rg = find_command("rg") or "rg"
proc = subprocess.Popen(
[rg, "--json", "-e", '"provider":"openai-codex"', "-e", '"api":"openai-codex"', str(root)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
errors="replace",
env=ENV,
)
except FileNotFoundError:
return
assert proc.stdout is not None
for raw in proc.stdout:
roots = [
Path.home() / ".pi" / "agent" / "sessions",
Path.home() / ".omp" / "agent" / "sessions",
]
rg = find_command("rg") or "rg"
for root in roots:
if not root.exists():
continue
try:
event = json.loads(raw)
if event.get("type") != "match":
proc = subprocess.Popen(
[rg, "--json", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
errors="replace",
env=ENV,
)
except FileNotFoundError:
return
assert proc.stdout is not None
for raw in proc.stdout:
try:
event = json.loads(raw)
if event.get("type") != "match":
continue
line = event.get("data", {}).get("lines", {}).get("text", "")
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
entry = json.loads(line)
except Exception:
continue
line = event.get("data", {}).get("lines", {}).get("text", "")
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
entry = json.loads(line)
if entry.get("type") != "message":
continue
message_key = path + ":" + str(entry.get("id") or "")
if message_key in seen_pi_messages:
continue
seen_pi_messages.add(message_key)
message = entry.get("message") or {}
if message.get("role") != "assistant":
continue
provider = str(message.get("provider") or "")
api = str(message.get("api") or "")
if provider != "openai-codex" and not api.startswith("openai-codex"):
continue
usage = message.get("usage") or {}
if not usage:
continue
total = number(usage.get("totalTokens"))
input_tokens = number(usage.get("input"))
output_tokens = number(usage.get("output"))
cache_read = number(usage.get("cacheRead"))
cache_write = number(usage.get("cacheWrite"))
if total and not (input_tokens or output_tokens or cache_read or cache_write):
input_tokens = total
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or message.get("timestamp"))
session_key = path
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
try:
proc.wait(timeout=1)
except Exception:
continue
proc.kill()
if entry.get("type") != "message":
continue
message_key = path + ":" + str(entry.get("id") or "")
if message_key in seen_pi_messages:
continue
seen_pi_messages.add(message_key)
message = entry.get("message") or {}
if message.get("role") != "assistant":
continue
provider = str(message.get("provider") or "")
api = str(message.get("api") or "")
if provider != "openai-codex" and not api.startswith("openai-codex"):
continue
usage = message.get("usage") or {}
if not usage:
continue
total = number(usage.get("totalTokens"))
input_tokens = number(usage.get("input"))
output_tokens = number(usage.get("output"))
cache_read = number(usage.get("cacheRead"))
cache_write = number(usage.get("cacheWrite"))
if total and not (input_tokens or output_tokens or cache_read or cache_write):
input_tokens = total
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or message.get("timestamp"))
session_key = path
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
def scan_opencode_sessions():
# A subscription burned entirely through opencode leaves no native session
# files, but opencode records per-message provider, model, and token usage
# in its own database. Read-only: opencode may be writing right now.
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
if not db.is_file():
return
try:
proc.wait(timeout=1)
except Exception:
proc.kill()
conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=2)
except sqlite3.Error:
return
try:
conn.execute("PRAGMA query_only = ON")
for session_id, raw in conn.execute("SELECT session_id, data FROM message"):
# One malformed row must not abort the scan, so every shape assumption
# lives inside the try.
try:
entry = json.loads(raw)
# Exact match: opencode provider ids are free-form, and a custom
# "openai-local" gateway is not this subscription.
if not isinstance(entry, dict) or entry.get("role") != "assistant":
continue
if str(entry.get("providerID") or "") != "openai":
continue
tokens = entry.get("tokens") or {}
cache = tokens.get("cache") or {}
input_tokens = number(tokens.get("input"))
# opencode keeps thinking tokens out of output; both are generated.
output_tokens = number(tokens.get("output")) + number(tokens.get("reasoning"))
cache_read = number(cache.get("read"))
cache_write = number(cache.get("write"))
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day((entry.get("time") or {}).get("created"))
model = model_name(str(entry.get("modelID") or "").rstrip("/").split("/")[-1])
except Exception:
continue
add_usage(day, "opencode:" + str(session_id), model, input_tokens, output_tokens, cache_read, cache_write)
except sqlite3.Error:
pass
finally:
conn.close()
def scan_native_codex_sessions():
@@ -349,6 +399,7 @@ def main():
scan_pi_sessions()
scan_native_codex_sessions()
scan_opencode_sessions()
rpc = fetch_codex_rpc()
record = {
+511
View File
@@ -0,0 +1,511 @@
#!/usr/bin/python3
# omarchy:summary=Print the Fireworks usage record as JSON
# omarchy:args=[--force] [--limits-only]
# omarchy:hidden=true
"""Collect Fireworks serverless usage into one display-ready JSON record.
Token stats come from the Fireworks billing API grouped by day and model for
the last 30 days. Fireworks does not expose its prepaid ledger, so the record
carries an estimated balance instead of rate limits: the credits configured in
~/.config/omarchy/agents/fireworks.json minus rated account costs since the
funding date. The agents panel only ever reads the JSON this prints.
"""
from __future__ import annotations
import argparse
import configparser
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Any
AGENT_ID = "fireworks"
AGENT_NAME = "Fireworks"
AUTH_HELP = "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in opencode."
API_BASE_URL = "https://api.fireworks.ai"
class FireworksError(Exception):
pass
def number(value: Any) -> int:
try:
return max(0, round(float(value or 0)))
except (TypeError, ValueError):
return 0
def money_value(value: Any) -> Decimal:
if not isinstance(value, dict):
return Decimal("0")
try:
units = Decimal(str(value.get("units", 0) or 0))
nanos = Decimal(str(value.get("nanos", 0) or 0)) / Decimal("1000000000")
return units + nanos
except (InvalidOperation, TypeError, ValueError):
return Decimal("0")
def model_id(row: dict[str, Any]) -> str:
group = row.get("group") if isinstance(row.get("group"), dict) else {}
raw = group.get("model_name") or row.get("modelName") or "unknown"
name = str(raw).rstrip("/").split("/")[-1] or "unknown"
return re.sub(r"(?<=\d)p(?=\d)", ".", name)
def row_date(row: dict[str, Any]) -> str:
# The query asks for day buckets in the local timezone, but the API reports
# each bucket's boundary in UTC: local Aug 7 starts at Aug 6 22:00Z east of
# Greenwich. Convert back to local time to recover the day the bucket names —
# taking the raw date prefix would file every day under its predecessor.
raw = str(row.get("startTime") or "")
if not raw:
return ""
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return raw[:10] if len(raw) >= 10 else ""
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone().date().isoformat()
def empty_bucket() -> dict[str, int]:
return {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
}
def empty_stats() -> dict[str, Any]:
return {
"todayPrompts": 0,
"todaySessions": 0,
"todayTotalTokens": 0,
"todayTokensByModel": {},
"recentDays": [],
"totalPrompts": 0,
"totalSessions": 0,
"activeDays": 0,
"activeDates": [],
"modelUsage": {},
}
def base_record(**overrides: Any) -> dict[str, Any]:
record: dict[str, Any] = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": datetime.now(timezone.utc).isoformat(),
"ready": False,
"hasLocalStats": False,
# Billing-API numbers are account-global, not machine-local: every synced
# device reports the same truth, so aggregation must not sum them.
"scope": "account",
# The billing API reports tokens, never prompt or session counts; the
# panel keeps those numbers out of today's tooltip when this is false.
"hasPromptStats": False,
"tierLabel": "Prepaid",
"usageStatusText": "",
"authHelpText": "",
"limits": [],
}
record.update(empty_stats())
record.update(overrides)
return record
def summarize_usage(payload: dict[str, Any], today: date | None = None) -> dict[str, Any]:
today = today or datetime.now().astimezone().date()
recent_dates = [(today - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)]
recent = {day: 0 for day in recent_dates}
today_by_model: dict[str, int] = {}
model_usage: dict[str, dict[str, int]] = {}
active_dates: set[str] = set()
rows = payload.get("serverlessCosts")
if not isinstance(rows, list):
rows = []
for raw_row in rows:
if not isinstance(raw_row, dict):
continue
day = row_date(raw_row)
model = model_id(raw_row)
prompt = number(raw_row.get("promptTokens"))
cached = min(prompt, number(raw_row.get("cachedPromptTokens")))
uncached = number(raw_row.get("uncachedPromptTokens"))
if "uncachedPromptTokens" not in raw_row:
uncached = max(0, prompt - cached)
output = number(raw_row.get("completionTokens"))
total = uncached + cached + output
if total <= 0:
continue
bucket = model_usage.setdefault(model, empty_bucket())
bucket["inputTokens"] += uncached
bucket["outputTokens"] += output
bucket["cacheReadInputTokens"] += cached
if day:
active_dates.add(day)
if day in recent:
recent[day] += total
if day == today.isoformat():
today_by_model[model] = today_by_model.get(model, 0) + total
return {
"todayTotalTokens": sum(today_by_model.values()),
"todayTokensByModel": today_by_model,
"recentDays": [{"date": day, "messageCount": recent[day]} for day in recent_dates],
"activeDays": len(active_dates),
"activeDates": sorted(active_dates),
"modelUsage": model_usage,
}
def read_auth_file(path: Path) -> tuple[str, str]:
if not path.is_file():
return "", ""
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read(path)
except configparser.Error:
return "", ""
api_key = ""
account_id = ""
sections = [parser.defaults()]
sections.extend(parser[section] for section in parser.sections())
for values in sections:
api_key = api_key or str(values.get("api_key", values.get("api-key", ""))).strip()
account_id = account_id or str(values.get("account_id", values.get("account-id", ""))).strip()
return api_key, account_id
def opencode_auth_path() -> Path:
data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
return data_home / "opencode" / "auth.json"
def read_opencode_key(path: Path) -> str:
try:
parsed = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return ""
entry = parsed.get("fireworks-ai") if isinstance(parsed, dict) else None
if not isinstance(entry, dict):
return ""
return str(entry.get("key") or "").strip()
def config_path() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
return config_home / "omarchy" / "agents" / "fireworks.json"
def read_config() -> dict[str, Any]:
try:
parsed = json.loads(config_path().read_text())
return parsed if isinstance(parsed, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def credentials(auth_path: Path, config: dict[str, Any]) -> tuple[str, str]:
file_key, file_account = read_auth_file(auth_path)
# opencode is the last resort: an explicit key or a firectl login should
# win over whatever another tool happens to be signed in with.
api_key = (
str(os.environ.get("FIREWORKS_API_KEY", "")).strip()
or file_key
or read_opencode_key(opencode_auth_path())
)
account_id = (
str(os.environ.get("FIREWORKS_ACCOUNT_ID", "")).strip()
or str(config.get("accountId") or "").strip()
or file_account
)
return api_key, account_id
def normalize_account_id(value: str) -> str:
return str(value or "").strip().removeprefix("accounts/").strip("/")
def timezone_name() -> str:
configured = str(os.environ.get("TZ", "")).strip()
if configured:
return configured
try:
target = (Path("/etc/localtime").resolve()).as_posix()
marker = "/zoneinfo/"
if marker in target:
return target.split(marker, 1)[1]
except OSError:
pass
return "UTC"
def local_midnight_utc(day: date) -> str:
# The API buckets by the requested timezone, so the window must run between
# local midnights — expressed in UTC, since a bare date with a Z suffix
# shifts the window by the UTC offset and clips today's tail west of
# Greenwich.
return datetime.combine(day, time.min).astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def iso_timestamp(value: str) -> str:
raw = str(value or "").strip()
if not raw:
return ""
try:
if len(raw) == 10:
parsed = datetime.combine(date.fromisoformat(raw), time.min, tzinfo=timezone.utc)
else:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
except ValueError:
raise FireworksError("Fireworks fundedAt must be an ISO date such as 2026-07-01")
class FireworksClient:
def __init__(self, api_key: str, base_url: str = API_BASE_URL):
self.api_key = api_key
self.base_url = base_url.rstrip("/")
def request(
self,
path: str,
query: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
) -> dict[str, Any]:
url = self.base_url + path
if query:
url += "?" + urllib.parse.urlencode(query, doseq=True)
data = None if body is None else json.dumps(body).encode("utf-8")
request = urllib.request.Request(
url,
data=data,
method="POST" if body is not None else "GET",
headers={
"Authorization": "Bearer " + self.api_key,
"Accept": "application/json",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
decoded = json.load(response)
return decoded if isinstance(decoded, dict) else {}
except urllib.error.HTTPError as error:
if error.code == 401:
raise FireworksError("Fireworks rejected the API key")
if error.code == 403:
raise FireworksError("The Fireworks API key cannot read billing data")
if error.code == 404:
raise FireworksError("Fireworks account not found")
raise FireworksError(f"Fireworks API returned HTTP {error.code}")
except urllib.error.URLError as error:
raise FireworksError("Could not reach the Fireworks API") from error
except (json.JSONDecodeError, TimeoutError) as error:
raise FireworksError("Fireworks returned an invalid billing response") from error
def discover_account(self) -> tuple[str, dict[str, Any]]:
payload = self.request("/v1/accounts", query={"pageSize": 100})
accounts = [item for item in payload.get("accounts", []) if isinstance(item, dict)]
if len(accounts) == 1:
account = accounts[0]
return normalize_account_id(str(account.get("name") or "")), account
if not accounts:
raise FireworksError("No Fireworks account is available for this API key")
raise FireworksError("Set accountId in fireworks.json when the API key can access multiple accounts")
def account(self, account_id: str) -> dict[str, Any]:
quoted = urllib.parse.quote(normalize_account_id(account_id), safe="")
return self.request(f"/v1/accounts/{quoted}")
def usage(self, account_id: str, start_day: date, end_day: date) -> dict[str, Any]:
quoted = urllib.parse.quote(normalize_account_id(account_id), safe="")
query = {
"startTime": local_midnight_utc(start_day),
"endTime": local_midnight_utc(end_day),
"usageType": "SERVERLESS",
"timezone": timezone_name(),
"groupBy": ["model_name"],
}
# 30 days grouped by model can exceed one page; follow the continuation
# tokens or heavy accounts lose their tail. The bound is a runaway stop.
rows: list[Any] = []
for _ in range(20):
payload = self.request(f"/v1/accounts/{quoted}/billingUsage", query=query)
page = payload.get("serverlessCosts")
if isinstance(page, list):
rows.extend(page)
token = str(payload.get("nextPageToken") or "")
if not token:
break
query = dict(query, pageToken=token)
return {"serverlessCosts": rows}
def spent(self, account_id: str, start_at: str, end_at: str) -> Decimal:
quoted = urllib.parse.quote(normalize_account_id(account_id), safe="")
body = {
"startTime": start_at,
"endTime": end_at,
"scope": "ACCOUNT",
}
try:
payload = self.request(f"/v1/accounts/{quoted}/usageCosts:query", body=body)
if not isinstance(payload.get("subtotal"), dict):
raise FireworksError("Fireworks cost response did not include a subtotal")
return money_value(payload.get("subtotal"))
except FireworksError:
parsed_end = datetime.fromisoformat(end_at.replace("Z", "+00:00"))
summary_end = (parsed_end.date() + timedelta(days=1)).isoformat() + "T00:00:00Z"
payload = self.request(
f"/v1/accounts/{quoted}/billing/summary",
query={"startTime": start_at, "endTime": summary_end},
)
return sum(
(money_value(item.get("totalCost")) for item in payload.get("lineItems", []) if isinstance(item, dict)),
Decimal("0"),
)
def live_balance(client: FireworksClient, account_id: str) -> Decimal | None:
# accounts/{id}:getBalance exists but is permission-gated: keys without the
# billing role get PERMISSION_DENIED, and then the configured estimate below
# is the best we can do. The response shape is undocumented, so accept a
# Money object at the top level or under any plausible field name.
quoted = urllib.parse.quote(normalize_account_id(account_id), safe="")
try:
payload = client.request(f"/v1/accounts/{quoted}:getBalance")
except FireworksError:
return None
candidates = [payload] + [payload.get(field) for field in ("balance", "creditBalance", "prepaidBalance", "amount")]
for value in candidates:
if isinstance(value, dict) and ("units" in value or "nanos" in value):
return money_value(value)
return None
def estimated_balance(
client: FireworksClient,
account_id: str,
account: dict[str, Any],
config: dict[str, Any],
) -> dict[str, Any] | None:
try:
funded = Decimal(str(config.get("fundedAmount") or "0"))
except InvalidOperation:
raise FireworksError("Fireworks fundedAmount must be a number")
if not funded.is_finite():
raise FireworksError("Fireworks fundedAmount must be a finite number")
if funded <= 0:
return None
funded_at = iso_timestamp(str(config.get("fundedAt") or ""))
if not funded_at:
if not account:
account = client.account(account_id)
funded_at = iso_timestamp(str(account.get("createTime") or ""))
if not funded_at:
raise FireworksError("Set fundedAt because the Fireworks account creation date is unavailable")
end_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
spent = max(Decimal("0"), client.spent(account_id, funded_at, end_at))
return {
"remaining": float(max(Decimal("0"), funded - spent)),
"funded": float(funded),
"spent": float(spent),
"currency": "USD",
"estimated": True,
}
def scan(api_base_url: str, auth_path: Path) -> dict[str, Any]:
config = read_config()
api_key, account_id = credentials(auth_path, config)
if not api_key:
return base_record(usageStatusText="Fireworks unavailable", authHelpText=AUTH_HELP)
client = FireworksClient(api_key, api_base_url)
account: dict[str, Any] = {}
if account_id:
account_id = normalize_account_id(account_id)
else:
account_id, account = client.discover_account()
today = datetime.now().astimezone().date()
usage = client.usage(account_id, today - timedelta(days=29), today + timedelta(days=1))
record = base_record(ready=True, hasLocalStats=True)
record.update(summarize_usage(usage, today))
live = live_balance(client, account_id)
if live is not None:
try:
funded = Decimal(str(config.get("fundedAmount") or "0"))
if not funded.is_finite() or funded < 0:
funded = Decimal("0")
except InvalidOperation:
funded = Decimal("0")
record["balance"] = {
"remaining": float(live),
"funded": float(funded),
"spent": float(max(Decimal("0"), funded - live)),
"currency": "USD",
"estimated": False,
}
return record
try:
balance = estimated_balance(client, account_id, account, config)
if balance:
record["balance"] = balance
except FireworksError as error:
record["usageStatusText"] = "Balance unavailable"
record["authHelpText"] = str(error)
return record
def main() -> int:
parser = argparse.ArgumentParser(description="Print the Fireworks usage record as JSON")
# Stats and balance come from the same few API calls, so there is no cache
# to force past and no faster limits-only path. The flags exist so every
# collector accepts the same invocation.
parser.add_argument("--force", action="store_true")
parser.add_argument("--limits-only", action="store_true")
parser.add_argument("--auth-path", default=os.environ.get("FIREWORKS_AUTH_PATH", "~/.fireworks/auth.ini"))
parser.add_argument("--api-base-url", default=os.environ.get("FIREWORKS_API_BASE_URL", API_BASE_URL))
args = parser.parse_args()
try:
record = scan(args.api_base_url, Path(args.auth_path).expanduser())
except FireworksError as error:
record = base_record(usageStatusText="Fireworks unavailable", authHelpText=str(error))
except Exception as error:
record = base_record(usageStatusText="Fireworks unavailable", authHelpText="Fireworks usage scan failed")
print(f"omarchy-agent-usage-fireworks: {type(error).__name__}", file=sys.stderr)
print(json.dumps(record, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+49 -15
View File
@@ -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)
}
+98 -5
View File
@@ -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
+48 -7
View File
@@ -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

+3 -2
View File
@@ -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",
@@ -18,7 +18,7 @@ cat >"$projects/session.jsonl" <<EOF
{"timestamp":"$timestamp","type":"assistant","sessionId":"session-1","uuid":"event-3","message":{"id":"message-2","role":"assistant","model":"claude-test","usage":{"input_tokens":2,"cache_creation_input_tokens":454,"cache_read_input_tokens":28857,"output_tokens":390}}}
EOF
result=$(HOME="$TEST_HOME" XDG_CACHE_HOME="$TEST_HOME/.cache" \
result=$(HOME="$TEST_HOME" XDG_CACHE_HOME="$TEST_HOME/.cache" XDG_DATA_HOME="$TEST_HOME/.local/share" \
"$ROOT/bin/omarchy-agent-usage-claude" --force)
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "58793" ]] ||
@@ -46,9 +46,82 @@ cat >"$HISTORY_HOME/.claude/history.jsonl" <<EOF
{"timestamp":$now_ms,"sessionId":"s2","display":"two"}
EOF
result=$(HOME="$HISTORY_HOME" XDG_CACHE_HOME="$HISTORY_HOME/.cache" \
result=$(HOME="$HISTORY_HOME" XDG_CACHE_HOME="$HISTORY_HOME/.cache" XDG_DATA_HOME="$HISTORY_HOME/.local/share" \
"$ROOT/bin/omarchy-agent-usage-claude" --force)
[[ $(jq -r '(.todayPrompts|tostring) + "/" + (.todaySessions|tostring)' <<<"$result") == "2/2" ]] ||
fail "Claude collector falls back to history.jsonl without a stats-cache" "$result"
pass "Claude collector falls back to history.jsonl without a stats-cache"
# A subscription burned entirely through opencode has no ~/.claude transcripts;
# usage must come from opencode's message database, filtered to Anthropic.
OPENCODE_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$HISTORY_HOME" "$OPENCODE_HOME"' EXIT
python3 - "$OPENCODE_HOME/.local/share/opencode/opencode.db" <<'PY'
import json
import sqlite3
import sys
import time
from pathlib import Path
db = Path(sys.argv[1])
db.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)")
now_ms = int(time.time() * 1000)
def message(id, provider, model, role="assistant", input=0, output=0, reasoning=0, read=0, write=0):
return (id, "ses_1", now_ms, now_ms, json.dumps({
"role": role,
"providerID": provider,
"modelID": model,
"tokens": {"input": input, "output": output, "reasoning": reasoning, "cache": {"read": read, "write": write}},
"time": {"created": now_ms},
}))
conn.executemany("INSERT INTO message VALUES (?, ?, ?, ?, ?)", [
message("msg_1", "anthropic", "claude-opus-5", input=100, output=50, reasoning=7, read=25, write=10),
message("msg_2", "fireworks-ai", "accounts/fireworks/models/kimi-k3", input=999, output=999),
message("msg_3", "openai", "gpt-5.2-codex", input=999, output=999),
message("msg_4", "anthropic", "claude-opus-5", role="user"),
message("msg_5", "anthropic-proxy", "claude-opus-5", input=999, output=999),
])
conn.execute("INSERT INTO message VALUES ('msg_6', 'ses_1', ?, ?, '[\"not\",\"an\",\"object\"]')", (now_ms, now_ms))
conn.commit()
conn.close()
PY
result=$(HOME="$OPENCODE_HOME" XDG_CACHE_HOME="$OPENCODE_HOME/.cache" XDG_DATA_HOME="$OPENCODE_HOME/.local/share" \
"$ROOT/bin/omarchy-agent-usage-claude" --force)
[[ $(jq -r '(.ready|tostring) + "/" + (.todayTotalTokens|tostring)' <<<"$result") == "true/192" ]] ||
fail "Claude collector counts Anthropic usage, reasoning included, from opencode sessions" "$result"
pass "Claude collector counts Anthropic usage, reasoning included, from opencode sessions"
[[ $(jq -c '.modelUsage' <<<"$result") == '{"claude-opus-5":{"cacheCreationInputTokens":10,"cacheReadInputTokens":25,"inputTokens":100,"outputTokens":57}}' ]] ||
fail "Claude collector ignores prefix-colliding providers, user messages, and malformed rows" "$result"
pass "Claude collector ignores prefix-colliding providers, user messages, and malformed rows"
# Pi and omp can both spend a Claude subscription without writing native
# Claude Code transcripts. Their compatible JSONL sessions must be included.
PI_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$HISTORY_HOME" "$OPENCODE_HOME" "$PI_HOME"' EXIT
mkdir -p "$PI_HOME/.pi/agent/sessions/project" "$PI_HOME/.omp/agent/sessions/project"
cat >"$PI_HOME/.pi/agent/sessions/project/pi.jsonl" <<EOF
{"type":"message","id":"pi-1","timestamp":"$timestamp","message":{"role":"assistant","provider":"anthropic","api":"anthropic-messages","model":"claude-pi","usage":{"input":10,"output":4,"cacheRead":3,"cacheWrite":2,"totalTokens":19}}}
{"type":"message","id":"codex-1","timestamp":"$timestamp","message":{"role":"assistant","provider":"openai-codex","model":"gpt-test","usage":{"input":999,"output":999}}}
EOF
cat >"$PI_HOME/.omp/agent/sessions/project/omp.jsonl" <<EOF
{ "type": "message", "id": "omp-1", "timestamp": "$timestamp", "message": { "role": "assistant", "provider": "anthropic", "model": "claude-omp", "usage": { "input": 20, "output": 5, "cacheRead": 4, "cacheWrite": 1, "totalTokens": 30 } } }
EOF
result=$(HOME="$PI_HOME" XDG_CACHE_HOME="$PI_HOME/.cache" XDG_DATA_HOME="$PI_HOME/.local/share" \
"$ROOT/bin/omarchy-agent-usage-claude" --force)
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "49" ]] ||
fail "Claude collector counts usage from pi and omp sessions" "$result"
[[ $(jq -c '.modelUsage' <<<"$result") == '{"claude-omp":{"cacheCreationInputTokens":1,"cacheReadInputTokens":4,"inputTokens":20,"outputTokens":5},"claude-pi":{"cacheCreationInputTokens":2,"cacheReadInputTokens":3,"inputTokens":10,"outputTokens":4}}' ]] ||
fail "Claude collector filters pi and omp sessions to Anthropic providers" "$result"
pass "Claude collector counts pi and omp subscription usage"
+75 -1
View File
@@ -40,7 +40,7 @@ cat >"$session" <<EOF
{"timestamp":"$timestamp","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":180,"cached_input_tokens":110,"output_tokens":30,"reasoning_output_tokens":8,"total_tokens":210},"last_token_usage":{"input_tokens":80,"cached_input_tokens":50,"output_tokens":10,"reasoning_output_tokens":3,"total_tokens":90}}}}
EOF
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" PATH="$TEST_HOME/bin:$PATH" \
result=$(HOME="$TEST_HOME" CODEX_HOME="$TEST_HOME/.codex" XDG_DATA_HOME="$TEST_HOME/.local/share" PATH="$TEST_HOME/bin:$PATH" \
"$ROOT/bin/omarchy-agent-usage-codex")
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "210" ]] ||
@@ -54,3 +54,77 @@ pass "Codex collector does not double-count cache or reasoning tokens"
[[ $(jq -c '.id + "/" + (.limits|tostring)' <<<"$result") == '"codex/[]"' ]] ||
fail "Codex collector identifies itself with an empty limits list" "$result"
pass "Codex collector identifies itself with an empty limits list"
# Pi and omp can both spend a Codex subscription without creating native
# Codex sessions. Their compatible JSONL transcripts must be included.
PI_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME"' EXIT
mkdir -p "$PI_HOME/bin" "$PI_HOME/.pi/agent/sessions/project" "$PI_HOME/.omp/agent/sessions/project"
cp "$TEST_HOME/bin/codex" "$PI_HOME/bin/codex"
cat >"$PI_HOME/.pi/agent/sessions/project/pi.jsonl" <<EOF
{"type":"message","id":"pi-1","timestamp":"$timestamp","message":{"role":"assistant","provider":"openai-codex","api":"openai-codex-responses","model":"gpt-pi","usage":{"input":10,"output":4,"cacheRead":3,"cacheWrite":2,"totalTokens":19}}}
EOF
cat >"$PI_HOME/.omp/agent/sessions/project/omp.jsonl" <<EOF
{ "type": "message", "id": "omp-1", "timestamp": "$timestamp", "message": { "role": "assistant", "provider": "openai-codex", "model": "gpt-omp", "usage": { "input": 20, "output": 5, "cacheRead": 4, "cacheWrite": 1, "totalTokens": 30 } } }
{"type":"message","id":"other-1","timestamp":"$timestamp","message":{"role":"assistant","provider":"anthropic","model":"claude-test","usage":{"input":999,"output":999}}}
EOF
result=$(HOME="$PI_HOME" CODEX_HOME="$PI_HOME/.codex" XDG_DATA_HOME="$PI_HOME/.local/share" \
PATH="$PI_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex")
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "49" ]] ||
fail "Codex collector counts usage from pi and omp sessions" "$result"
[[ $(jq -c '.modelUsage' <<<"$result") == '{"gpt-pi":{"inputTokens":10,"outputTokens":4,"cacheReadInputTokens":3,"cacheCreationInputTokens":2},"gpt-omp":{"inputTokens":20,"outputTokens":5,"cacheReadInputTokens":4,"cacheCreationInputTokens":1}}' ]] ||
fail "Codex collector filters pi and omp sessions to Codex providers" "$result"
pass "Codex collector counts pi and omp subscription usage"
# A subscription burned entirely through opencode has no native session files;
# usage must come from opencode's message database, filtered to OpenAI.
OPENCODE_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME" "$PI_HOME" "$OPENCODE_HOME"' EXIT
mkdir -p "$OPENCODE_HOME/bin"
cp "$TEST_HOME/bin/codex" "$OPENCODE_HOME/bin/codex"
python3 - "$OPENCODE_HOME/.local/share/opencode/opencode.db" <<'PY'
import json
import sqlite3
import sys
import time
from pathlib import Path
db = Path(sys.argv[1])
db.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)")
now_ms = int(time.time() * 1000)
def message(id, provider, model, role="assistant", input=0, output=0, reasoning=0, read=0, write=0):
return (id, "ses_1", now_ms, now_ms, json.dumps({
"role": role,
"providerID": provider,
"modelID": model,
"tokens": {"input": input, "output": output, "reasoning": reasoning, "cache": {"read": read, "write": write}},
"time": {"created": now_ms},
}))
conn.executemany("INSERT INTO message VALUES (?, ?, ?, ?, ?)", [
message("msg_1", "openai", "gpt-5.2-codex", input=80, output=40, reasoning=5, read=30),
message("msg_2", "anthropic", "claude-opus-5", input=999, output=999),
message("msg_3", "openai", "gpt-5.2-codex", role="user"),
message("msg_4", "openai-local", "gpt-5.2-codex", input=999, output=999),
])
conn.execute("INSERT INTO message VALUES ('msg_5', 'ses_1', ?, ?, '[\"not\",\"an\",\"object\"]')", (now_ms, now_ms))
conn.commit()
conn.close()
PY
result=$(HOME="$OPENCODE_HOME" CODEX_HOME="$OPENCODE_HOME/.codex" XDG_DATA_HOME="$OPENCODE_HOME/.local/share" \
PATH="$OPENCODE_HOME/bin:$PATH" "$ROOT/bin/omarchy-agent-usage-codex")
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "155" ]] ||
fail "Codex collector counts OpenAI usage, reasoning included, from opencode sessions" "$result"
pass "Codex collector counts OpenAI usage, reasoning included, from opencode sessions"
[[ $(jq -c '.modelUsage' <<<"$result") == '{"gpt-5.2-codex":{"inputTokens":80,"outputTokens":45,"cacheReadInputTokens":30,"cacheCreationInputTokens":0}}' ]] ||
fail "Codex collector ignores prefix-colliding providers, user messages, and malformed rows" "$result"
pass "Codex collector ignores prefix-colliding providers, user messages, and malformed rows"
+244
View File
@@ -0,0 +1,244 @@
#!/bin/bash
source "$(dirname "$0")/base-test.sh"
require_command jq
require_command python3
TEST_HOME=$(mktemp -d)
trap 'rm -rf "$TEST_HOME"' EXIT
auth_file="$TEST_HOME/auth.ini"
cat >"$auth_file" <<'EOF'
[default]
api_key = fw_test
account_id = example
EOF
mkdir -p "$TEST_HOME/.config/omarchy/agents"
cat >"$TEST_HOME/.config/omarchy/agents/fireworks.json" <<'EOF'
{
"accountId": "example",
"fundedAmount": 20,
"fundedAt": "2026-07-01"
}
EOF
# Without credentials the collector must still print a full, hidden-by-default
# record: the update runner writes whatever valid JSON appears on stdout.
no_key=$(HOME="$TEST_HOME" XDG_CONFIG_HOME="$TEST_HOME/.config" XDG_DATA_HOME="$TEST_HOME/.local/share" \
FIREWORKS_API_KEY="" FIREWORKS_AUTH_PATH="$TEST_HOME/missing.ini" "$ROOT/bin/omarchy-agent-usage-fireworks")
[[ $(jq -r '.id + ":" + (.ready | tostring) + ":" + (.hasPromptStats | tostring)' <<<"$no_key") == "fireworks:false:false" ]] ||
fail "Fireworks collector prints a valid record without credentials" "$no_key"
pass "Fireworks collector prints a valid record without credentials"
result=$(python3 - "$ROOT/bin/omarchy-agent-usage-fireworks" "$auth_file" "$TEST_HOME/.config" "$TEST_HOME/.local/share" <<'PY'
import importlib.machinery
import importlib.util
import json
import os
import sys
import time
from datetime import date
from decimal import Decimal
from pathlib import Path
collector_path = str(Path(sys.argv[1]))
auth_path = Path(sys.argv[2])
os.environ["XDG_CONFIG_HOME"] = sys.argv[3]
os.environ["XDG_DATA_HOME"] = sys.argv[4]
# Bucket dates resolve in local time, so pin the zone or the fixtures below
# would shift by a day depending on where the test runs. The env account id
# would override the config file, so it must not leak in from the runner.
os.environ["TZ"] = "UTC"
time.tzset()
os.environ.pop("FIREWORKS_ACCOUNT_ID", None)
loader = importlib.machinery.SourceFileLoader("fireworks_collector", collector_path)
spec = importlib.util.spec_from_loader(loader.name, loader)
scanner = importlib.util.module_from_spec(spec)
loader.exec_module(scanner)
RealFireworksClient = scanner.FireworksClient
payload = {
"serverlessCosts": [
{
"startTime": "2026-07-31T00:00:00Z",
"promptTokens": "100",
"cachedPromptTokens": "40",
"uncachedPromptTokens": "60",
"completionTokens": "20",
"group": {"model_name": "accounts/fireworks/models/kimi-k2p5"},
},
{
"startTime": "2026-07-30T00:00:00Z",
"promptTokens": "300",
"cachedPromptTokens": "0",
"completionTokens": "50",
"group": {"model_name": "accounts/fireworks/models/deepseek-v3p2"},
},
{
"startTime": "2026-07-20T00:00:00Z",
"promptTokens": "500",
"cachedPromptTokens": "0",
"completionTokens": "100",
"group": {"model_name": "accounts/fireworks/models/kimi-k2p5"},
},
]
}
summary = scanner.summarize_usage(payload, date(2026, 7, 31))
api_key, account_id = scanner.read_auth_file(auth_path)
summary["apiKey"] = api_key
summary["accountId"] = account_id
summary["money"] = float(scanner.money_value({"units": "12", "nanos": 430000000}))
# The opencode key only wins when no explicit key or firectl login exists.
data_home = Path(os.environ["XDG_DATA_HOME"])
opencode_auth = data_home / "opencode" / "auth.json"
opencode_auth.parent.mkdir(parents=True, exist_ok=True)
opencode_auth.write_text(json.dumps({"fireworks-ai": {"type": "api", "key": "fw_opencode"}}))
os.environ.pop("FIREWORKS_API_KEY", None)
opencode_key, _ = scanner.credentials(Path("/nonexistent/auth.ini"), {})
firectl_key, _ = scanner.credentials(auth_path, {})
summary["opencodeFallback"] = opencode_key == "fw_opencode" and firectl_key == "fw_test"
class WorkingClient:
def __init__(self, api_key, base_url):
pass
def request(self, path, query=None, body=None):
raise scanner.FireworksError("The Fireworks API key cannot read billing data")
def usage(self, account_id, start_day, end_day):
return payload
def account(self, account_id):
return {}
def spent(self, account_id, start_at, end_at):
return Decimal("8.60")
class BalanceFailureClient(WorkingClient):
def spent(self, account_id, start_at, end_at):
raise scanner.FireworksError("Billing scope denied")
class LiveBalanceClient(WorkingClient):
def request(self, path, query=None, body=None):
assert path.endswith(":getBalance")
return {"balance": {"units": "12", "nanos": 500000000}}
os.environ["FIREWORKS_API_KEY"] = "fw_test"
scanner.FireworksClient = WorkingClient
record = scanner.scan("https://example.invalid", auth_path)
summary["record"] = {
"schemaVersion": record["schemaVersion"],
"id": record["id"],
"ready": record["ready"],
"hasPromptStats": record["hasPromptStats"],
"scope": record["scope"],
"tierLabel": record["tierLabel"],
"limits": record["limits"],
"balance": record["balance"],
}
# billingUsage pages long ranges; usage() must follow continuation tokens.
pages = {
"": {"serverlessCosts": [{"startTime": "2026-07-30T00:00:00Z"}], "nextPageToken": "p2"},
"p2": {"serverlessCosts": [{"startTime": "2026-07-31T00:00:00Z"}]},
}
paging_client = RealFireworksClient("fw_test", "https://example.invalid")
paging_queries = []
def paged_request(path, query=None, body=None):
paging_queries.append(dict(query or {}))
return pages[str((query or {}).get("pageToken") or "")]
paging_client.request = paged_request
merged = paging_client.usage("example", date(2026, 7, 1), date(2026, 8, 1))
summary["paginationMerges"] = (
len(merged["serverlessCosts"]) == 2
and len(paging_queries) == 2
and paging_queries[0]["startTime"] == "2026-07-01T00:00:00Z"
)
scanner.FireworksClient = LiveBalanceClient
live = scanner.scan("https://example.invalid", auth_path)
summary["liveBalance"] = live["balance"]
scanner.FireworksClient = BalanceFailureClient
scanned = scanner.scan("https://example.invalid", auth_path)
summary["balanceFailurePreservesTokens"] = (
scanned["ready"] is True
and "balance" not in scanned
and scanned["modelUsage"]["kimi-k2.5"]["outputTokens"] == 120
and scanned["usageStatusText"] == "Balance unavailable"
)
# East of Greenwich, a local-midnight bucket starts on the previous UTC date;
# the row must still land on the local day it names, and the query window
# must ask for local midnights expressed in UTC.
os.environ["TZ"] = "Etc/GMT-2"
time.tzset()
summary["bucketDayIsLocal"] = scanner.row_date({"startTime": "2026-07-30T22:00:00Z"}) == "2026-07-31"
summary["windowIsLocalMidnight"] = scanner.local_midnight_utc(date(2026, 7, 31)) == "2026-07-30T22:00:00Z"
print(json.dumps(summary, separators=(",", ":")))
PY
)
[[ $(jq -r '.todayTotalTokens' <<<"$result") == "120" ]] ||
fail "Fireworks collector totals today's uncached, cached, and output tokens once" "$result"
pass "Fireworks collector totals today's token categories once"
[[ $(jq -c '.modelUsage["kimi-k2.5"]' <<<"$result") == '{"inputTokens":560,"outputTokens":120,"cacheReadInputTokens":40,"cacheCreationInputTokens":0}' ]] ||
fail "Fireworks collector keeps cache separate in model totals" "$result"
pass "Fireworks collector keeps cache separate in model totals"
[[ $(jq -r '.recentDays[-1].messageCount' <<<"$result") == "120" ]] ||
fail "Fireworks collector builds the seven-day token series" "$result"
pass "Fireworks collector builds the seven-day token series"
[[ $(jq -r '.activeDays' <<<"$result") == "3" ]] ||
fail "Fireworks collector retains the 30-day model window" "$result"
pass "Fireworks collector retains the 30-day model window"
[[ $(jq -r '.apiKey + ":" + .accountId' <<<"$result") == "fw_test:example" ]] ||
fail "Fireworks collector reads firectl credentials" "$result"
pass "Fireworks collector reads firectl credentials"
[[ $(jq -r '.money' <<<"$result") == "12.43" ]] ||
fail "Fireworks collector parses Money units and nanos" "$result"
pass "Fireworks collector parses Money units and nanos"
[[ $(jq -c '.record | {schemaVersion, id, ready, hasPromptStats, scope, tierLabel, limits}' <<<"$result") == '{"schemaVersion":1,"id":"fireworks","ready":true,"hasPromptStats":false,"scope":"account","tierLabel":"Prepaid","limits":[]}' ]] ||
fail "Fireworks collector prints the display-ready record contract" "$result"
pass "Fireworks collector prints the display-ready record contract"
[[ $(jq -r '.paginationMerges' <<<"$result") == "true" ]] ||
fail "Fireworks collector follows billingUsage continuation tokens" "$result"
pass "Fireworks collector follows billingUsage continuation tokens"
[[ $(jq -r '.windowIsLocalMidnight' <<<"$result") == "true" ]] ||
fail "Fireworks collector requests local-midnight windows in UTC" "$result"
pass "Fireworks collector requests local-midnight windows in UTC"
[[ $(jq -c '.record.balance' <<<"$result") == '{"remaining":11.4,"funded":20.0,"spent":8.6,"currency":"USD","estimated":true}' ]] ||
fail "Fireworks collector estimates the balance from configured funding" "$result"
pass "Fireworks collector estimates the balance from configured funding"
[[ $(jq -c '.liveBalance' <<<"$result") == '{"remaining":12.5,"funded":20.0,"spent":7.5,"currency":"USD","estimated":false}' ]] ||
fail "Fireworks collector prefers the live getBalance ledger when the key can read it" "$result"
pass "Fireworks collector prefers the live getBalance ledger when the key can read it"
[[ $(jq -r '.balanceFailurePreservesTokens' <<<"$result") == "true" ]] ||
fail "Fireworks collector preserves tokens when balance lookup fails" "$result"
pass "Fireworks collector preserves tokens when balance lookup fails"
[[ $(jq -r '.bucketDayIsLocal' <<<"$result") == "true" ]] ||
fail "Fireworks collector dates buckets by local day east of Greenwich" "$result"
pass "Fireworks collector dates buckets by local day east of Greenwich"
[[ $(jq -r '.opencodeFallback' <<<"$result") == "true" ]] ||
fail "Fireworks collector falls back to the opencode key last" "$result"
pass "Fireworks collector falls back to the opencode key last"