Add Fireworks balance usage panel (#6488)
* Add a Fireworks balance collector and teach the agents panel prepaid ledgers The omarchy-agent-usage-fireworks collector reads serverless token usage from the Fireworks billing API, grouped by day and model for the last 30 days, and reshapes it into the shared record contract. Fireworks does not expose its prepaid ledger through the documented API, so the record carries an estimated balance instead of rate limits: credits configured in ~/.config/omarchy/agents/fireworks.json minus rated account costs since the funding date. Credentials come from FIREWORKS_API_KEY/FIREWORKS_ACCOUNT_ID, the auth.ini that firectl set-api-key writes, or — last, so an explicit login wins — the key opencode stores for its fireworks-ai provider. The panel gains two generic capabilities any agent record can use: a balance object draws a BALANCE section — remaining credit, a fuel-gauge meter that drains toward empty and lights the bar alarm below 10%, and funded-versus-spent detail — and hasPromptStats: false keeps prompt and session counts out of today's tooltip for agents whose billing API only ever reports tokens, on this machine and through synced snapshots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Feed Claude and Codex usage from pi, omp, and opencode sessions A subscription burned entirely through another coding agent leaves no native Claude Code transcripts and no Codex session files, so the panel showed nothing for it. pi and omp write compatible JSONL sessions, and opencode records per-message provider, model, and token usage in its message database; the claude and codex collectors now scan all three — filtered to Anthropic and OpenAI providers respectively — and merge those numbers into their local stats. Fireworks stays out on purpose: its billing API already sees that traffic server-side, and a local scan would count the same tokens twice. The collector tests pin XDG_DATA_HOME so a developer's real opencode history cannot leak into fixture runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b85ae70ebd
commit
77cf58ccfe
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user