Vibe-coding dashboardu użycia agentów AI
Jeśli płacisz za tokeny przez API, dostawcy tacy jak OpenAI czy Anthropic dają udokumentowane endpointy użycia, nagłówki z limitami, a czasem nawet eksport do CSV. Ale jeśli korzystasz z subskrypcji typu ChatGPT Plus/Pro, Claude Pro/Max, Kimi Code albo Z-AI, dostajesz pasek postępu schowany gdzieś w webowym panelu i niewiele więcej. Nie ma endpointu REST, webhooka ani prostego sposobu, żeby programowo zapytać: „ile zostało mi do końca tygodnia?”. Zostaje otworzyć cztery dashboardy i wpatrywać się w cztery różne paski. Chciałem mieć to w jednym miejscu.
Tak powstał AgentsUsageDashboard: jeden webowy dashboard oraz plugin do Stream Deck+, który pokazuje użycie subskrypcji, czas do resetu i informacje o planie dla wszystkich czterech usług.
Najtrudniejszy nie był sam dashboard. Najwięcej czasu zajęło odtwarzanie nieudokumentowanych endpointów. Ustalenie, że scope w Kimi musi być tablicą, a nie stringiem, że znaczniki czasu w Z-AI są w milisekundach, że lista organizacji Claude może zawierać elementy, które nie są słownikami: każda z tych rzeczy kosztowała mnie godziny debugowania. Ten wpis zbiera te godziny w jednym miejscu, żeby nie trzeba było przechodzić przez to samo.
⚠️ Ważne: to rozwiązanie opiera się na nieoficjalnych, odtworzonych endpointach. Trzeba zakładać, że mogą zmienić się w każdej chwili: ścieżki, nagłówki, sposób autoryzacji i pola w odpowiedziach.
Architektura w skrócie
- Backend: Python/Flask, odpytywanie co 5 minut.
- Strategia sesji: jeden zalogowany Firefox w Dockerze (noVNC), potem ponowne użycie cookies i localStorage.
- Transport:
curl_cffiz podszyciem się pod Firefoksa, żeby uzyskać przeglądarkowy odcisk TLS. - Frontend: jeden dashboard w czystym HTML/CSS/JS (paski sesji i tygodnia, status, odliczanie, 14-dniowy wykres Codex).
- Infra: dwa kontenery (Firefox + Dashboard) ze współdzielonym wolumenem
firefox_data. - Warstwa sprzętowa: plugin Stream Deck+ do szybkiego podglądu użycia na żywo.
Docker Compose
Cała infrastruktura to dwa kontenery. Firefox uruchamia przeglądarkę z noVNC do ręcznego logowania. Dashboard montuje ten sam wolumen tylko do odczytu i wyciąga z niego cookies.
services:
firefox:
image: jlesage/firefox:latest
ports:
- "5800:5800"
volumes:
- firefox_data:/config
environment:
- TZ=Europe/Warsaw
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:5800 || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
dashboard:
image: agent-stats-dashboard:latest
ports:
- "8777:8777"
volumes:
- firefox_data:/firefox:ro
environment:
- TZ=Europe/Warsaw
- REFRESH_INTERVAL=300
- ZAI_API_KEY=${ZAI_API_KEY:-}
depends_on:
firefox:
condition: service_healthy
restart: unless-stopped
volumes:
firefox_data:
Otwierasz localhost:5800, logujesz się w Firefoksie do ChatGPT, Kimi i Claude, a dashboard sam korzysta z tych sesji. Z-AI używa zamiast tego klucza API przekazanego przez zmienną środowiskową.
Bezpieczne czytanie cookies z Firefoksa
Firefox blokuje swoje bazy SQLite, kiedy działa. Sztuczka jest prosta: skopiować bazę razem z plikami WAL i SHM do /tmp, a potem odpytywać kopię.
def _copy_sqlite(src_path, tmp_name):
"""Copy SQLite DB + WAL + SHM to /tmp for safe reading."""
tmp_dir = Path(f"/tmp/{tmp_name}")
tmp_dir.mkdir(exist_ok=True)
tmp_db = tmp_dir / src_path.name
shutil.copy2(src_path, tmp_db)
for suffix in ["-wal", "-shm"]:
wal = src_path.parent / f"{src_path.name}{suffix}"
if wal.exists():
shutil.copy2(wal, tmp_dir / f"{src_path.name}{suffix}")
return tmp_db
def _read_cookies(domain):
profile = _find_profile() # auto-detect jlesage or standard
tmp_db = _copy_sqlite(profile / "cookies.sqlite", "cookie_read")
conn = sqlite3.connect(str(tmp_db))
cur = conn.execute(
"SELECT name, value FROM moz_cookies WHERE host LIKE ?",
(f"%{domain}%",),
)
cookies = cur.fetchall()
conn.close()
return cookies
W przypadku localStorage (potrzebnego jako fallback dla Z-AI) Firefox 79+ trzyma bazy SQLite per origin w innej ścieżce:
{profile}/storage/default/https+++chat.z.ai/ls/data.sqlite
Tabela data, kolumny key i value (blob UTF-8). Tu działa ten sam schemat: najpierw kopia, potem odczyt.
Autoryzacja: cztery usługi, cztery strategie
Każda usługa wymaga innej autoryzacji. Owinąłem je w adaptery fetch_*(), które zwracają ten sam znormalizowany kształt danych.
Codex. Wymiana cookie na bearer token:
GET https://chatgpt.com/api/auth/session
Cookie: __Secure-next-auth.session-token=...
→ { "accessToken": "eyJhb..." }
Kimi. Cookie kimi-auth jest już bearer tokenem, ale endpoint używa protokołu Connect:
POST https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages
Headers: connect-protocol-version: 1, x-msh-platform: web
Body: { "scope": ["FEATURE_CODING"] } ← musi być tablicą, nie stringiem
Claude. Pełny string cookies, najpierw z krokiem wyszukania organizacji:
GET https://claude.ai/api/organizations → znajdź org z capability "chat"
GET https://claude.ai/api/organizations/{org_id}/usage
Headers: anthropic-client-platform: web_claude.ai
Z-AI. Klucz API w formacie id.secret, opakowany w JWT:
def _zai_jwt(api_key: str) -> str:
kid, secret = api_key.split(".", 1)
now_ms = int(time.time() * 1000)
payload = {
"api_key": kid,
"exp": now_ms + 3600 * 1000,
"timestamp": now_ms,
}
return jwt.encode(payload, secret, algorithm="HS256",
headers={"alg": "HS256", "sign_type": "SIGN"})
Wszystkie wywołania HTTP przechodzą przez curl_cffi z impersonate="ff120", żeby dopasować odcisk TLS do przeglądarki. Bez tego Cloudflare blokuje żądania.
Mapowanie odpowiedzi: z formatu dostawcy do wspólnego modelu
Każda usługa zwraca dane w innym kształcie. Zadaniem adaptera jest przemapować je do wewnętrznego schematu. Weźmy Codex jako przykład. Surowa odpowiedź z /backend-api/wham/usage wygląda tak:
{
"plan_type": "plus",
"rate_limit": {
"primary_window": {
"used_percent": 42.0,
"reset_after_seconds": 14400,
"reset_at": 1772143992
},
"secondary_window": {
"used_percent": 88.0,
"reset_after_seconds": 68976,
"reset_at": 1772206105
}
}
}
Po normalizacji:
{
"status": "ok",
"plan": "plus",
"session": { "usage_pct": 42.0, "remaining_seconds": 14400 },
"weekly": { "usage_pct": 88.0, "remaining_seconds": 68976 },
"error": null
}
primary_window przechodzi na session, a secondary_window na weekly. Pole used_percent mapuję do usage_pct (Codex czasem zwraca też usage_percent, więc obsługuję obie wersje). Znaczniki czasu są zamieniane na remaining_seconds już w adapterze, dzięki czemu frontend nie musi liczyć czasu.
Kimi. GetUsages zwraca zagnieżdżoną strukturę, w której każda wartość liczbowa jest stringiem:
{
"usages": [{
"detail": {
"limit": "500",
"used": "123",
"remaining": "377",
"resetTime": "2026-03-06T00:00:00Z"
},
"limits": [{
"detail": {
"limit": "30",
"remaining": "28",
"resetTime": "2026-02-27T14:35:00Z"
}
}]
}]
}
usages[0].detail to limit tygodniowy (liczba użytych requestów względem limitu planu). usages[0].limits[0].detail to rate limit, czyli pięciominutowe okno przesuwne. Wszystkie wartości są stringami, więc trzeba je rzutować przez int(). Procent wyliczam jako used / limit * 100. Nazwa planu pochodzi z osobnego wywołania GetSubscription → subscription.goods.title (np. „Allegretto”).
Claude. Endpoint użycia zwraca okna nazwane zakresem czasu oraz rozbicie per model:
{
"five_hour": {
"utilization": 35.2,
"resets_at": "2026-02-27T19:30:00Z"
},
"seven_day": {
"utilization": 62.1,
"resets_at": "2026-03-06T14:30:00Z"
},
"seven_day_sonnet": { "utilization": 45.0, "resets_at": "..." },
"seven_day_opus": { "utilization": 12.0, "resets_at": "..." }
}
five_hour przechodzi na session, a seven_day na weekly. Pole utilization mapuję do usage_pct, resets_at jest w ISO-8601. Haczyk: każde z tych pól może być None zamiast słownika. Nie brakujące, tylko obecne i ustawione na None. Dlatego usage.get("five_hour", {}) nadal potrafi się wywrócić, bo dostajesz None, a nie wartość domyślną. Potrzebna jest jawna osłona: val if isinstance(val, dict) else {}.
Z-AI. Zwraca tablicę obiektów limitów z różnymi kombinacjami type i unit:
{
"success": true,
"data": {
"level": "premium",
"limits": [
{
"type": "TOKENS_LIMIT",
"unit": 3,
"percentage": 42.0,
"nextResetTime": 1772143992000
},
{
"type": "TOKENS_LIMIT",
"unit": 6,
"percentage": 88.0,
"nextResetTime": 1772606105000
},
{
"type": "TIME_LIMIT",
"percentage": 15.0,
"nextResetTime": 1772137200000
}
]
}
}
Dekodowanie opiera się na parze type + unit: TOKENS_LIMIT z unit=3 oznacza pięciogodzinne okno sesji, a unit=6 oznacza tydzień. TIME_LIMIT to godzinny limit requestów i może służyć jako fallback dla sesji. Pole percentage jest już procentem użycia w skali od 0 do 100, więc nie należy liczyć go z usage/limit. nextResetTime to Unix timestamp w milisekundach, nie sekundach, więc przed konwersją trzeba podzielić go przez 1000.
Każdy dostawca trafia ostatecznie do tego samego kształtu { status, plan, session, weekly, error }: różne pola źródłowe, jeden format wyjściowy.
Kontrakt /api/data
Backend trzyma w cache wszystkie znormalizowane wyniki i udostępnia je pod GET /api/data:
{
"codex": {
"status": "ok",
"plan": "plus",
"session": { "usage_pct": 42.0, "remaining_seconds": 14400 },
"weekly": { "usage_pct": 88.0, "remaining_seconds": 68976 },
"error": null,
"last_success": "2026-02-27T14:30:00+00:00"
},
"kimi": { "status": "ok", "session": { "..." }, "weekly": { "..." } },
"claude": { "status": "ok", "session": { "..." }, "weekly": { "..." } },
"zai": { "status": "stale", "error": "timeout", "..." },
"last_fetch": "2026-02-27T14:30:00+00:00",
"next_refresh_at": "2026-02-27T14:35:00+00:00"
}
Status może mieć wartość ok, error, offline albo stale (poprzednie dane są dostępne, ale ostatnie pobranie się nie udało). Frontend używa next_refresh_at do planowania kolejnego odpytywania, więc nie ma sztywnego interwału po stronie przeglądarki. Po prostu synchronizuje się z cyklem backendu.
Polling i cache
Backend uruchamia wątek daemon, który co 5 minut odpytuje wszystkich dostawców po kolei. Cache jest bezpieczny wątkowo dzięki dwóm blokadom: jednej do odczytu/zapisu (_lock) i drugiej do zapobiegania nakładającym się pobraniom (_fetch_lock).
REFRESH_INTERVAL = int(os.environ.get("REFRESH_INTERVAL", "300"))
def _do_fetch():
if not _fetch_lock.acquire(blocking=False):
return # skip if already running
try:
results = {}
for name, fetcher in FETCHERS:
try:
results[name] = fetcher()
except Exception as e:
# graceful degradation: keep stale data
prev = _cache.get(name)
if prev and prev.get("last_success"):
results[name] = {**prev, "status": "stale", "error": str(e)}
else:
results[name] = {"status": "error", "error": str(e)}
results["last_fetch"] = datetime.now(timezone.utc).isoformat()
results["next_refresh_at"] = (
datetime.now(timezone.utc) + timedelta(seconds=REFRESH_INTERVAL)
).isoformat()
with _lock:
_cache.update(results)
finally:
_fetch_lock.release()
W razie błędu, jeśli istnieją wcześniejsze poprawne dane, usługa przechodzi w stan stale zamiast znikać z widoku. Frontend pokazuje kropkę statusu, więc od razu widać, co jest aktualne, a co pochodzi z cache.
Plugin Stream Deck+
Zbudowałem też osobny plugin do Stream Deck+, żeby widzieć użycie bez przełączania okien. Każde z czterech pokręteł z ekranem pokazuje jedną usługę, dwa paski postępu i status oznaczony kolorem.
Architektura: plugin w Node.js używający SDK @elgato/streamdeck. Odpytuje ten sam endpoint /api/data, więc backend dashboardu pozostaje jedynym źródłem prawdy.
Trzy widoki na każde pokrętło, przełączane obrotem:
- Domyślny: nazwa usługi, pasek sesji, pasek tygodnia, etykieta planu
- Szczegóły sesji: duży procent, pasek, odliczanie do resetu
- Szczegóły tygodnia: ten sam układ, ale dla danych tygodniowych
Układ to własna definicja JSON dla paska dotykowego:
{
"id": "agent-default",
"items": [
{ "key": "agent-icon", "type": "pixmap", "rect": [4, 2, 20, 20] },
{ "key": "agent-name", "type": "text", "rect": [28, 0, 120, 22] },
{ "key": "status-dot", "type": "pixmap", "rect": [180, 4, 16, 16] },
{ "key": "session-label", "type": "text", "rect": [4, 26, 58, 20] },
{ "key": "session-bar", "type": "bar", "rect": [66, 30, 130, 10] },
{ "key": "weekly-label", "type": "text", "rect": [4, 52, 58, 20] },
{ "key": "weekly-bar", "type": "bar", "rect": [66, 56, 130, 10] }
]
}
Progi kolorów są takie same jak w dashboardzie webowym: zielony poniżej 40%, bursztynowy od 40 do 70%, czerwony powyżej 70%. Wciśnięcie odświeża dane, dotknięcie otwiera dashboard w przeglądarce.
Rady, żeby nie powtarzać moich błędów
-
Normalizuj dane dostawców jak najwcześniej Zdefiniuj jeden wewnętrzny schemat (
usage_pct,remaining_seconds,plan) i mapuj do niego każdego dostawcę. Nie pozwól, żeby specyficzne formaty odpowiedzi przeciekały do UI. -
Traktuj autoryzację jako adapter zależny od dostawcy Każda usługa ma zupełnie inny flow autoryzacji. Trzymaj je osobno, żeby awaria jednej nie psuła pozostałych.
-
Parsuj defensywnie, zawsze Część pól może zniknąć albo przyjąć
None(szczególnie w Claude). Pilnuj dostępu do słowników i dodawaj rozsądne fallbacki. -
Spodziewaj się pomyłek w jednostkach czasu Jedne resety są w sekundach, inne w milisekundach (Z-AI). Konwertuj je raz, w warstwie adaptera.
-
Uwzględnij realia storage przeglądarki Firefox 79+ używa plików SQLite LSNG per origin, a nie tylko starego
webappsstore.sqlite. -
Unikaj bólu z blokadami SQLite Jeśli Firefox zapisuje w trakcie odczytu, pojawiają się blokady WAL. Najpierw kopiuj pliki DB do
/tmp, dopiero potem odpytywanie kopii. -
Zostaw vibe, dodaj barierki Vibe coding świetnie daje rozpęd, ale przy niestabilnych API nadal potrzebujesz granic adapterów, retry i sensownych logów. Najlepszy efekt daje szybka iteracja połączona z defensywną inżynierią.
Pułapki endpointów, które warto znać
- Codex: pola użycia mogą nazywać się
used_percentalbousage_percent; obsłuż oba warianty. - Kimi:
scopedla użycia musi być tablicą (["FEATURE_CODING"]), nie stringiem. - Kimi:
GetUsagesiGetSubscriptionsą pod różnymi pakietami usług (billing.v1orazorder.v1). - Claude: znajdź organizację z capability
"chat", cache’uj UUID organizacji i zabezpiecz się przed rekordami, które nie są słownikami. - Z-AI:
nextResetTimejest w milisekundach; błędy tokena/autoryzacji przychodzą jakosuccess: false+401. - Z-AI: typy limitów dekodują się tak:
TOKENS_LIMITunit 3 = pięciogodzinna sesja, unit 6 = tydzień.
Jeśli budujesz coś podobnego, projektuj pod zmienność: wersjonuj adaptery per dostawca, zapisuj surowe odpowiedzi i zakładaj, że jutrzejszy payload nie musi wyglądać tak jak dzisiejszy.
Cały projekt zajął mi dzień vibe-codingu, ale większość tego czasu poszła na reverse engineering: przechwytywanie requestów w DevTools, zgadywanie kombinacji nagłówków i rozgryzanie nieudokumentowanych formatów błędów. Sam dashboard był szybki, kiedy już wiedziałem, co wywołać i jak to zmapować.
Najbardziej użyteczny nie jest tu więc sam kod, bo taki dashboard można napisać po swojemu. Ważniejsza jest mapa: endpointy, kruczki autoryzacji, niespójne nazwy pól i pułapki z milisekundami zamiast sekund. To są rzeczy, które zjadają godziny. Teraz są zebrane w jednym miejscu.
Pełny kod źródłowy (backend, frontend, konfiguracja Dockera i plugin Stream Deck+) jest na GitHubie: AgentsUsageDashboard.