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

@@ -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,
)