diff --git a/.env.example b/.env.example index af2eb92..e113207 100644 --- a/.env.example +++ b/.env.example @@ -26,9 +26,9 @@ MONITORINK_LON=2.3522 # Laisser vide pour masquer la section NAS. Depuis le conteneur, viser l'IP LAN du host. MONITORINK_NAS_URL=http://192.168.0.43:8766/api/status -# Codex / Hermes (optionnel) — base URL du dashboard Hermes (usage Codex via /api/analytics). -# Laisser vide pour masquer la section Codex. -MONITORINK_HERMES_URL=http://192.168.0.43:9119 +# Codex (optionnel) — usage ChatGPT/Codex via wham/usage. Token lu dans le auth.json de +# Hermes monté en lecture seule (cf. docker-compose.yml). Laisser vide pour masquer Codex. +MONITORINK_CODEX_TOKEN_FILE=/hermes-auth.json # Home Assistant (optionnel) — laisser vide pour désactiver MONITORINK_HA_URL=http://homeassistant.local:8123 diff --git a/backend/config.py b/backend/config.py index 2a9fd40..ff4f786 100644 --- a/backend/config.py +++ b/backend/config.py @@ -77,8 +77,12 @@ class Config: # --- NAS (moniteur maison nas_monitor, endpoint /api/status) --- nas_url: str = field(default_factory=lambda: _get("MONITORINK_NAS_URL")) - # --- Codex / Hermes (dashboard agent maison, base URL ex http://host:9119) --- - hermes_url: str = field(default_factory=lambda: _get("MONITORINK_HERMES_URL")) + # --- Codex (usage ChatGPT/Codex via wham/usage) --- + # Fichier auth.json de Hermes monté en lecture seule : Hermes y maintient un token + # openai-codex frais. Monitorink le relit à chaque rendu (aucun refresh côté Monitorink). + codex_token_file: str = field( + default_factory=lambda: _get("MONITORINK_CODEX_TOKEN_FILE", "/hermes-auth.json") + ) # --- Cache / rafraîchissement serveur --- cache_ttl_seconds: int = field( diff --git a/backend/integrations/codex.py b/backend/integrations/codex.py index 91abb8b..177b904 100644 --- a/backend/integrations/codex.py +++ b/backend/integrations/codex.py @@ -1,48 +1,45 @@ -"""Usage Codex (OpenAI via ChatGPT) exposé par l'agent maison Hermes. +"""Usage Codex (ChatGPT) via l'endpoint `GET https://chatgpt.com/backend-api/wham/usage`. -Le plan ChatGPT/Codex ne fournit pas de quota « % restant » récupérable (le rate-limit -n'arrive que dans les headers d'un appel génératif). On affiche donc, depuis l'API du -dashboard Hermes : - - la conso Codex sur 7 jours (`/api/analytics/models`, provider `openai-codex`) ; - - un statut limite déduit des 429 `usage_limit_reached` loggés (`/api/logs`). +La réponse expose `rate_limit.primary_window` (5 h) et `secondary_window` (hebdo) avec +`used_percent` + `reset_*`, soit l'équivalent du `/usage` de Claude → on en fait des +jauges « % restant ». + +Le token d'accès est lu dans le `auth.json` de l'agent maison Hermes (monté en lecture +seule), qui le maintient frais. Monitorink ne fait que le relire (aucun refresh ici). """ from __future__ import annotations -import re +import base64 +import binascii +import json import time -from dataclasses import dataclass +from dataclasses import dataclass, field import httpx from config import config -_TOKEN_RE = re.compile(r'__HERMES_SESSION_TOKEN__="([^"]+)"') -_RESETS_RE = re.compile(r"'resets_at':\s*(\d+)") -_CODEX_PROVIDER = "openai-codex" +USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" + + +@dataclass +class CodexGauge: + name: str + remaining: float + resets_in: str @dataclass class CodexStatus: ok: bool error: str | None = None - api_calls: int = 0 - total_tokens: int = 0 - cost: float = 0.0 - days: int = 7 + plan_type: str = "" limited: bool = False - resets_in_human: str = "" - - @property - def tokens_human(self) -> str: - t = self.total_tokens - if t >= 1_000_000: - return f"{t / 1_000_000:.1f}".replace(".", ",") + " M" - if t >= 1_000: - return f"{t // 1000} k" - return str(t) + gauges: list[CodexGauge] = field(default_factory=list) def _human_delay(seconds: int) -> str: + seconds = max(0, int(seconds)) h, m = seconds // 3600, (seconds % 3600) // 60 if h >= 24: return f"{h // 24}j {h % 24}h" @@ -51,66 +48,76 @@ def _human_delay(seconds: int) -> str: return f"{m}min" -async def _session_token(client: httpx.AsyncClient, base: str) -> str | None: - """Récupère le jeton de session injecté dans la page du dashboard Hermes.""" - resp = await client.get(base + "/") - m = _TOKEN_RE.search(resp.text) - return m.group(1) if m else None +def _read_token() -> str | None: + """Lit le token openai-codex dans le auth.json de Hermes (pool actif, sinon provider).""" + try: + with open(config.codex_token_file) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + pool = (((data.get("credential_pool") or {}).get("openai-codex") or [{}]) or [{}])[0] + token = pool.get("access_token") + if not token: + token = ((data.get("providers") or {}).get("openai-codex") or {}).get("tokens", {}).get("access_token") + return token or None + + +def _account_id(jwt: str) -> str | None: + """Extrait chatgpt_account_id du payload du JWT (header `chatgpt-account-id` requis).""" + try: + payload = jwt.split(".")[1] + payload += "=" * (-len(payload) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (IndexError, ValueError, binascii.Error, json.JSONDecodeError): + return None + return (claims.get("https://api.openai.com/auth") or {}).get("chatgpt_account_id") + + +def _gauge(window: dict | None, name: str) -> CodexGauge | None: + if not window: + return None + used = float(window.get("used_percent", 0) or 0) + resets = window.get("reset_after_seconds") + if resets is None and window.get("reset_at"): + resets = int(window["reset_at"]) - int(time.time()) + return CodexGauge(name=name, remaining=max(0.0, 100 - used), resets_in=_human_delay(resets or 0)) async def fetch_status() -> CodexStatus: - base = config.hermes_url - if not base: - return CodexStatus(ok=False, error="Hermes désactivé") - base = base.rstrip("/") + if not config.codex_token_file: + return CodexStatus(ok=False, error="Codex désactivé") + token = _read_token() + if not token: + return CodexStatus(ok=False, error="token codex introuvable") + + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": "Monitorink/1.0", + } + acct = _account_id(token) + if acct: + headers["chatgpt-account-id"] = acct try: async with httpx.AsyncClient(timeout=15) as client: - token = await _session_token(client, base) - if not token: - return CodexStatus(ok=False, error="jeton Hermes introuvable") - headers = {"Authorization": f"Bearer {token}"} - models_resp = await client.get( - f"{base}/api/analytics/models", params={"days": 7}, headers=headers, - ) - logs_resp = await client.get( - f"{base}/api/logs", - params={"file": "agent", "search": "usage_limit_reached", "lines": 300}, - headers=headers, - ) - if models_resp.status_code != 200: - return CodexStatus(ok=False, error=f"HTTP {models_resp.status_code}") - models = models_resp.json() + resp = await client.get(USAGE_URL, headers=headers) + if resp.status_code != 200: + return CodexStatus(ok=False, error=f"HTTP {resp.status_code}") + data = resp.json() except httpx.HTTPError: return CodexStatus(ok=False, error="injoignable") - calls = tokens = 0 - cost = 0.0 - for m in models.get("models") or []: - if m.get("provider") != _CODEX_PROVIDER: - continue - calls += int(m.get("api_calls", 0) or 0) - tokens += int(m.get("input_tokens", 0) or 0) + int(m.get("output_tokens", 0) or 0) - cost += float(m.get("estimated_cost", 0) or 0) - - # Statut limite : un 429 dont le reset est encore dans le futur => limité. - limited = False - resets_in_human = "" - if logs_resp.status_code == 200: - now = int(time.time()) - future_resets = [ - int(ts) for ts in _RESETS_RE.findall("\n".join(logs_resp.json().get("lines") or [])) - if int(ts) > now - ] - if future_resets: - limited = True - resets_in_human = _human_delay(max(future_resets) - now) - + rl = data.get("rate_limit") or {} + gauges = [ + g for g in ( + _gauge(rl.get("primary_window"), "Session (5 h)"), + _gauge(rl.get("secondary_window"), "Hebdo (7 j)"), + ) if g is not None + ] return CodexStatus( ok=True, - api_calls=calls, - total_tokens=tokens, - cost=round(cost, 2), - limited=limited, - resets_in_human=resets_in_human, + plan_type=str(data.get("plan_type") or ""), + limited=bool(rl.get("limit_reached")), + gauges=gauges, ) diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html index 15f6408..7d88ea6 100644 --- a/backend/templates/dashboard.html +++ b/backend/templates/dashboard.html @@ -71,7 +71,7 @@ /* Liste NAS (valeurs larges -> une colonne pleine largeur, sans soulignements) */ .nas-list { display: flex; flex-direction: column; gap: 18px; } .nas-list .ha-item { border-bottom: 0; padding-bottom: 0; } - .nas-list .bad { font-weight: 800; } + .bad { font-weight: 800; } /* Grille Home Assistant */ .ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; } @@ -159,11 +159,19 @@ {% if codex.ok %}
-
Codex
-
-
Conso 7 j{{ codex.api_calls }} appels · {{ codex.tokens_human }} tok{% if codex.cost %} · {{ '%.2f'|format(codex.cost) }}€{% endif %}
-
Statut{% if codex.limited %}⚠ limite · reset {{ codex.resets_in_human }}{% else %}OK{% endif %}
+
Codex{% if codex.plan_type %} · {{ codex.plan_type | capitalize }}{% endif %}{% if codex.limited %} · ⚠ limite atteinte{% endif %}
+ {% for g in codex.gauges %} +
+
+ {{ g.name }} + {{ g.remaining | round | int }}% restant +
+
+
+
+
{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}
+ {% endfor %}
{% endif %} diff --git a/docker-compose.yml b/docker-compose.yml index 9109a1b..a993333 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: # Login Claude ISOLÉ dédié à Monitorink (lecture/écriture pour le refresh du token). # Créé via: CLAUDE_CONFIG_DIR=/home/jerem/.monitorink-claude claude auth login - /home/jerem/.monitorink-claude:/creds:rw + # Token openai-codex maintenu frais par Hermes (lecture seule) -> usage Codex. + - /home/jerem/.hermes/auth.json:/hermes-auth.json:ro # Optionnel : burn rate via ccusage (lecture seule des logs Claude Code principaux). # Décommenter + MONITORINK_CCUSAGE=1. # - /home/jerem/.claude/projects:/root/.claude/projects:ro