Codex: vraies jauges 5h/hebdo via wham/usage (token Hermes monte ro)

This commit is contained in:
jerem
2026-06-15 15:49:58 +02:00
parent 319ff3f552
commit 0178f596ef
5 changed files with 107 additions and 86 deletions

View File

@@ -26,9 +26,9 @@ 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). # Codex (optionnel) — usage ChatGPT/Codex via wham/usage. Token lu dans le auth.json de
# Laisser vide pour masquer la section Codex. # Hermes monté en lecture seule (cf. docker-compose.yml). Laisser vide pour masquer Codex.
MONITORINK_HERMES_URL=http://192.168.0.43:9119 MONITORINK_CODEX_TOKEN_FILE=/hermes-auth.json
# 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

View File

@@ -77,8 +77,12 @@ 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) --- # --- Codex (usage ChatGPT/Codex via wham/usage) ---
hermes_url: str = field(default_factory=lambda: _get("MONITORINK_HERMES_URL")) # 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 / rafraîchissement serveur ---
cache_ttl_seconds: int = field( cache_ttl_seconds: int = field(

View File

@@ -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 La réponse expose `rate_limit.primary_window` (5 h) et `secondary_window` (hebdo) avec
n'arrive que dans les headers d'un appel génératif). On affiche donc, depuis l'API du `used_percent` + `reset_*`, soit l'équivalent du `/usage` de Claude → on en fait des
dashboard Hermes : jauges « % restant ».
- 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`). 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 from __future__ import annotations
import re import base64
import binascii
import json
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
import httpx import httpx
from config import config from config import config
_TOKEN_RE = re.compile(r'__HERMES_SESSION_TOKEN__="([^"]+)"') USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
_RESETS_RE = re.compile(r"'resets_at':\s*(\d+)")
_CODEX_PROVIDER = "openai-codex"
@dataclass
class CodexGauge:
name: str
remaining: float
resets_in: str
@dataclass @dataclass
class CodexStatus: class CodexStatus:
ok: bool ok: bool
error: str | None = None error: str | None = None
api_calls: int = 0 plan_type: str = ""
total_tokens: int = 0
cost: float = 0.0
days: int = 7
limited: bool = False limited: bool = False
resets_in_human: str = "" gauges: list[CodexGauge] = field(default_factory=list)
@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: def _human_delay(seconds: int) -> str:
seconds = max(0, int(seconds))
h, m = seconds // 3600, (seconds % 3600) // 60 h, m = seconds // 3600, (seconds % 3600) // 60
if h >= 24: if h >= 24:
return f"{h // 24}j {h % 24}h" return f"{h // 24}j {h % 24}h"
@@ -51,66 +48,76 @@ def _human_delay(seconds: int) -> str:
return f"{m}min" return f"{m}min"
async def _session_token(client: httpx.AsyncClient, base: str) -> str | None: def _read_token() -> str | None:
"""Récupère le jeton de session injecté dans la page du dashboard Hermes.""" """Lit le token openai-codex dans le auth.json de Hermes (pool actif, sinon provider)."""
resp = await client.get(base + "/") try:
m = _TOKEN_RE.search(resp.text) with open(config.codex_token_file) as f:
return m.group(1) if m else None 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: async def fetch_status() -> CodexStatus:
base = config.hermes_url if not config.codex_token_file:
if not base: return CodexStatus(ok=False, error="Codex désactivé")
return CodexStatus(ok=False, error="Hermes désactivé") token = _read_token()
base = base.rstrip("/") 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: try:
async with httpx.AsyncClient(timeout=15) as client: async with httpx.AsyncClient(timeout=15) as client:
token = await _session_token(client, base) resp = await client.get(USAGE_URL, headers=headers)
if not token: if resp.status_code != 200:
return CodexStatus(ok=False, error="jeton Hermes introuvable") return CodexStatus(ok=False, error=f"HTTP {resp.status_code}")
headers = {"Authorization": f"Bearer {token}"} data = resp.json()
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: except httpx.HTTPError:
return CodexStatus(ok=False, error="injoignable") return CodexStatus(ok=False, error="injoignable")
calls = tokens = 0 rl = data.get("rate_limit") or {}
cost = 0.0 gauges = [
for m in models.get("models") or []: g for g in (
if m.get("provider") != _CODEX_PROVIDER: _gauge(rl.get("primary_window"), "Session (5 h)"),
continue _gauge(rl.get("secondary_window"), "Hebdo (7 j)"),
calls += int(m.get("api_calls", 0) or 0) ) if g is not None
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( return CodexStatus(
ok=True, ok=True,
api_calls=calls, plan_type=str(data.get("plan_type") or ""),
total_tokens=tokens, limited=bool(rl.get("limit_reached")),
cost=round(cost, 2), gauges=gauges,
limited=limited,
resets_in_human=resets_in_human,
) )

View File

@@ -71,7 +71,7 @@
/* Liste NAS (valeurs larges -> une colonne pleine largeur, sans soulignements) */ /* Liste NAS (valeurs larges -> une colonne pleine largeur, sans soulignements) */
.nas-list { display: flex; flex-direction: column; gap: 18px; } .nas-list { display: flex; flex-direction: column; gap: 18px; }
.nas-list .ha-item { border-bottom: 0; padding-bottom: 0; } .nas-list .ha-item { border-bottom: 0; padding-bottom: 0; }
.nas-list .bad { font-weight: 800; } .bad { font-weight: 800; }
/* Grille Home Assistant */ /* Grille Home Assistant */
.ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; } .ha-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 44px; }
@@ -159,11 +159,19 @@
{% if codex.ok %} {% if codex.ok %}
<div class="section"> <div class="section">
<div class="title">Codex</div> <div class="title">Codex{% if codex.plan_type %} · {{ codex.plan_type | capitalize }}{% endif %}{% if codex.limited %} · <span class="bad">⚠ limite atteinte</span>{% endif %}</div>
<div class="nas-list"> {% for g in codex.gauges %}
<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="gauge">
<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 class="row">
<span class="name">{{ g.name }}</span>
<span class="pct">{{ g.remaining | round | int }}<small>% restant</small></span>
</div> </div>
<div class="bar {% if g.remaining < 20 %}low{% endif %}">
<div class="fill" style="width: {{ (100 - g.remaining) | round(1) }}%;"></div>
</div>
<div class="sub">{{ (100 - g.remaining) | round | int }}% utilisé · reset dans {{ g.resets_in }}</div>
</div>
{% endfor %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -12,6 +12,8 @@ services:
# Login Claude ISOLÉ dédié à Monitorink (lecture/écriture pour le refresh du token). # 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 # Créé via: CLAUDE_CONFIG_DIR=/home/jerem/.monitorink-claude claude auth login
- /home/jerem/.monitorink-claude:/creds:rw - /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). # Optionnel : burn rate via ccusage (lecture seule des logs Claude Code principaux).
# Décommenter + MONITORINK_CCUSAGE=1. # Décommenter + MONITORINK_CCUSAGE=1.
# - /home/jerem/.claude/projects:/root/.claude/projects:ro # - /home/jerem/.claude/projects:/root/.claude/projects:ro