diff --git a/bin/omarchy-agent-usage-claude b/bin/omarchy-agent-usage-claude index b0a72ec6..92535b07 100755 --- a/bin/omarchy-agent-usage-claude +++ b/bin/omarchy-agent-usage-claude @@ -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) diff --git a/bin/omarchy-agent-usage-codex b/bin/omarchy-agent-usage-codex index b8d172e0..63229f0d 100755 --- a/bin/omarchy-agent-usage-codex +++ b/bin/omarchy-agent-usage-codex @@ -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 = { diff --git a/bin/omarchy-agent-usage-fireworks b/bin/omarchy-agent-usage-fireworks new file mode 100755 index 00000000..e2c9b632 --- /dev/null +++ b/bin/omarchy-agent-usage-fireworks @@ -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()) diff --git a/shell/plugins/agents/Main.qml b/shell/plugins/agents/Main.qml index ce75613d..cb8e8c2d 100644 --- a/shell/plugins/agents/Main.qml +++ b/shell/plugins/agents/Main.qml @@ -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) } diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index 5076980a..d85a9aa6 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -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 diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index 38120b11..efc2dd31 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -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. diff --git a/shell/plugins/agents/assets/fireworks.svg b/shell/plugins/agents/assets/fireworks.svg new file mode 100644 index 00000000..a659bed9 --- /dev/null +++ b/shell/plugins/agents/assets/fireworks.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/shell/plugins/agents/manifest.json b/shell/plugins/agents/manifest.json index 86e04239..1743a798 100644 --- a/shell/plugins/agents/manifest.json +++ b/shell/plugins/agents/manifest.json @@ -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", diff --git a/test/shell.d/agent-usage-claude-scanner-test.sh b/test/shell.d/agent-usage-claude-scanner-test.sh index 37ee4418..92da91eb 100644 --- a/test/shell.d/agent-usage-claude-scanner-test.sh +++ b/test/shell.d/agent-usage-claude-scanner-test.sh @@ -18,7 +18,7 @@ cat >"$projects/session.jsonl" <"$HISTORY_HOME/.claude/history.jsonl" <"$PI_HOME/.pi/agent/sessions/project/pi.jsonl" <"$PI_HOME/.omp/agent/sessions/project/omp.jsonl" <"$session" <"$PI_HOME/.pi/agent/sessions/project/pi.jsonl" <"$PI_HOME/.omp/agent/sessions/project/omp.jsonl" <"$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"