From 319ff3f552643121ab2681ed2a8b3cee63215243 Mon Sep 17 00:00:00 2001 From: jerem Date: Mon, 15 Jun 2026 15:27:13 +0200 Subject: [PATCH] Ajout section Codex (conso 7j + statut limite) via dashboard Hermes --- .env.example | 4 ++ backend/config.py | 3 + backend/integrations/codex.py | 116 +++++++++++++++++++++++++++++++ backend/render.py | 6 +- backend/templates/dashboard.html | 10 +++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 backend/integrations/codex.py diff --git a/.env.example b/.env.example index 9b3ad58..af2eb92 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,10 @@ 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 + # Home Assistant (optionnel) — laisser vide pour désactiver MONITORINK_HA_URL=http://homeassistant.local:8123 MONITORINK_HA_TOKEN= diff --git a/backend/config.py b/backend/config.py index 19234c1..2a9fd40 100644 --- a/backend/config.py +++ b/backend/config.py @@ -77,6 +77,9 @@ 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")) + # --- Cache / rafraîchissement serveur --- cache_ttl_seconds: int = field( default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120")) diff --git a/backend/integrations/codex.py b/backend/integrations/codex.py new file mode 100644 index 0000000..91abb8b --- /dev/null +++ b/backend/integrations/codex.py @@ -0,0 +1,116 @@ +"""Usage Codex (OpenAI via ChatGPT) exposé par l'agent maison Hermes. + +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`). +""" +from __future__ import annotations + +import re +import time +from dataclasses import dataclass + +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" + + +@dataclass +class CodexStatus: + ok: bool + error: str | None = None + api_calls: int = 0 + total_tokens: int = 0 + cost: float = 0.0 + days: int = 7 + 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) + + +def _human_delay(seconds: int) -> str: + h, m = seconds // 3600, (seconds % 3600) // 60 + if h >= 24: + return f"{h // 24}j {h % 24}h" + if h: + return f"{h}h{m:02d}" + 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 + + +async def fetch_status() -> CodexStatus: + base = config.hermes_url + if not base: + return CodexStatus(ok=False, error="Hermes désactivé") + base = base.rstrip("/") + + 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() + 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) + + return CodexStatus( + ok=True, + api_calls=calls, + total_tokens=tokens, + cost=round(cost, 2), + limited=limited, + resets_in_human=resets_in_human, + ) diff --git a/backend/render.py b/backend/render.py index 6b67929..b1b909f 100644 --- a/backend/render.py +++ b/backend/render.py @@ -13,7 +13,7 @@ from PIL import Image from playwright.async_api import async_playwright from config import config -from integrations import claude_usage, homeassistant, nas, weather +from integrations import claude_usage, codex, homeassistant, nas, weather TEMPLATES = Path(__file__).parent / "templates" @@ -54,11 +54,12 @@ def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]: async def build_context() -> dict: """Récupère toutes les sources en parallèle et assemble le contexte du template.""" - usage, wx, ha, nas_status = await asyncio.gather( + usage, wx, ha, nas_status, codex_status = await asyncio.gather( claude_usage.fetch_usage(), weather.fetch_weather(), homeassistant.fetch_states(), nas.fetch_status(), + codex.fetch_status(), ) now = datetime.now(ZoneInfo(config.timezone)) @@ -73,6 +74,7 @@ async def build_context() -> dict: "gauges": _gauges(usage), "ha_states": ha, "nas": nas_status, + "codex": codex_status, "updated": now.strftime("%H:%M"), "stale": False, } diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html index 60371cc..15f6408 100644 --- a/backend/templates/dashboard.html +++ b/backend/templates/dashboard.html @@ -157,6 +157,16 @@ {% endif %} + {% 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 %}
+
+
+ {% endif %} + {% if ha_states %}
Maison