Ajout section Codex (conso 7j + statut limite) via dashboard Hermes
This commit is contained in:
@@ -26,6 +26,10 @@ MONITORINK_LON=2.3522
|
|||||||
# Laisser vide pour masquer la section NAS. Depuis le conteneur, viser l'IP LAN du host.
|
# 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
|
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
|
# Home Assistant (optionnel) — laisser vide pour désactiver
|
||||||
MONITORINK_HA_URL=http://homeassistant.local:8123
|
MONITORINK_HA_URL=http://homeassistant.local:8123
|
||||||
MONITORINK_HA_TOKEN=
|
MONITORINK_HA_TOKEN=
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ class Config:
|
|||||||
# --- NAS (moniteur maison nas_monitor, endpoint /api/status) ---
|
# --- NAS (moniteur maison nas_monitor, endpoint /api/status) ---
|
||||||
nas_url: str = field(default_factory=lambda: _get("MONITORINK_NAS_URL"))
|
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 / rafraîchissement serveur ---
|
||||||
cache_ttl_seconds: int = field(
|
cache_ttl_seconds: int = field(
|
||||||
default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120"))
|
default_factory=lambda: int(_get("MONITORINK_CACHE_TTL", "120"))
|
||||||
|
|||||||
116
backend/integrations/codex.py
Normal file
116
backend/integrations/codex.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -13,7 +13,7 @@ from PIL import Image
|
|||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
from config import config
|
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"
|
TEMPLATES = Path(__file__).parent / "templates"
|
||||||
|
|
||||||
@@ -54,11 +54,12 @@ def _gauges(usage: claude_usage.ClaudeUsage) -> list[dict]:
|
|||||||
|
|
||||||
async def build_context() -> dict:
|
async def build_context() -> dict:
|
||||||
"""Récupère toutes les sources en parallèle et assemble le contexte du template."""
|
"""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(),
|
claude_usage.fetch_usage(),
|
||||||
weather.fetch_weather(),
|
weather.fetch_weather(),
|
||||||
homeassistant.fetch_states(),
|
homeassistant.fetch_states(),
|
||||||
nas.fetch_status(),
|
nas.fetch_status(),
|
||||||
|
codex.fetch_status(),
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(ZoneInfo(config.timezone))
|
now = datetime.now(ZoneInfo(config.timezone))
|
||||||
@@ -73,6 +74,7 @@ async def build_context() -> dict:
|
|||||||
"gauges": _gauges(usage),
|
"gauges": _gauges(usage),
|
||||||
"ha_states": ha,
|
"ha_states": ha,
|
||||||
"nas": nas_status,
|
"nas": nas_status,
|
||||||
|
"codex": codex_status,
|
||||||
"updated": now.strftime("%H:%M"),
|
"updated": now.strftime("%H:%M"),
|
||||||
"stale": False,
|
"stale": False,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if codex.ok %}
|
||||||
|
<div class="section">
|
||||||
|
<div class="title">Codex</div>
|
||||||
|
<div class="nas-list">
|
||||||
|
<div class="ha-item"><span class="k">Conso 7 j</span><span class="v">{{ codex.api_calls }} appels · {{ codex.tokens_human }} tok{% if codex.cost %} · {{ '%.2f'|format(codex.cost) }}€{% endif %}</span></div>
|
||||||
|
<div class="ha-item"><span class="k">Statut</span><span class="v">{% if codex.limited %}<span class="bad">⚠ limite · reset {{ codex.resets_in_human }}</span>{% else %}OK{% endif %}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if ha_states %}
|
{% if ha_states %}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="title">Maison</div>
|
<div class="title">Maison</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user