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.

Dashboard webowy pokazujący czterech agentów z paskami użycia sesji i tygodnia, statusami oraz 14-dniowym wykresem Codex.

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

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 GetSubscriptionsubscription.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.

Stream Deck+ z czterema monitorami użycia agentów AI na pasku dotykowym: Z.AI, Kimi, Codex i Claude z paskami sesji oraz tygodnia.

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:

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

  1. 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.

  2. 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.

  3. 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.

  4. Spodziewaj się pomyłek w jednostkach czasu Jedne resety są w sekundach, inne w milisekundach (Z-AI). Konwertuj je raz, w warstwie adaptera.

  5. Uwzględnij realia storage przeglądarki Firefox 79+ używa plików SQLite LSNG per origin, a nie tylko starego webappsstore.sqlite.

  6. 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.

  7. 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ć

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.