Codex: vraies jauges 5h/hebdo via wham/usage (token Hermes monte ro)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 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>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user