Claude: passage au login isolé + refresh (scope user:profile requis par /usage)

This commit is contained in:
2026-06-15 11:03:51 +02:00
parent 789fc92c39
commit 74d33eb693
6 changed files with 150 additions and 38 deletions

View File

@@ -48,7 +48,13 @@ class Config:
height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680")))
# --- Claude ---
claude_token: str = field(default_factory=lambda: _get("MONITORINK_CLAUDE_TOKEN"))
# Chemin du fichier .credentials.json d'un login Claude ISOLÉ dédié à Monitorink
# (CLAUDE_CONFIG_DIR séparé). Le backend y lit/écrit (refresh) sans toucher le
# ~/.claude partagé. L'endpoint /usage exige le scope user:profile -> login complet
# requis (le token `claude setup-token` ne suffit pas, scope insuffisant).
claude_creds_path: str = field(
default_factory=lambda: _get("MONITORINK_CLAUDE_CREDS", "/creds/.credentials.json")
)
claude_ua: str = field(
default_factory=lambda: _get("MONITORINK_CLAUDE_UA", "claude-code/2.1.172")
)

View File

@@ -7,14 +7,21 @@ Validé empiriquement (compte Max 5x) :
réponse: { five_hour:{utilization,resets_at}, seven_day:{...},
seven_day_opus, seven_day_sonnet, extra_usage }
Le token utilisé est un token longue durée dédié généré par `claude setup-token`
(env MONITORINK_CLAUDE_TOKEN). Aucun refresh/écriture n'est effectué ici : en cas de 401,
on remonte un état d'erreur pour affichage (« token à régénérer »).
⚠️ L'endpoint exige le scope OAuth `user:profile`. Le token `claude setup-token` ne l'a PAS
(403). On utilise donc les credentials d'un **login Claude isolé dédié** (CLAUDE_CONFIG_DIR
séparé) : structure `{ claudeAiOauth: { accessToken, refreshToken, expiresAt(ms) } }`.
Le token expire (~8 h) ; on le rafraîchit via platform.claude.com et on réécrit le fichier
isolé de façon atomique. On ne touche jamais le ~/.claude partagé.
"""
from __future__ import annotations
import asyncio
import json
import os
import subprocess
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -23,6 +30,11 @@ import httpx
from config import config
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
REFRESH_URL = "https://platform.claude.com/v1/oauth/token"
CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # client public Claude Code
EXPIRY_BUFFER_MS = 120_000 # rafraîchit 2 min avant expiration
_refresh_lock = asyncio.Lock()
@dataclass
@@ -77,20 +89,85 @@ def _parse_window(raw: dict | None) -> Window | None:
return Window(utilization=util, resets_at=dt)
def _load_creds() -> dict:
with open(config.claude_creds_path) as f:
return json.load(f)
def _write_creds(data: dict) -> None:
"""Écriture atomique du fichier de credentials isolé (mode 0600)."""
path = config.claude_creds_path
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
async def _refresh(data: dict) -> str:
"""Rafraîchit l'access token, réécrit le fichier isolé, renvoie le nouvel access token."""
o = data["claudeAiOauth"]
body = {
"grant_type": "refresh_token",
"refresh_token": o["refreshToken"],
"client_id": CLIENT_ID,
}
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
REFRESH_URL, json=body,
headers={"Content-Type": "application/json", "User-Agent": config.claude_ua},
)
resp.raise_for_status()
tok = resp.json()
o["accessToken"] = tok["access_token"]
o["refreshToken"] = tok["refresh_token"]
o["expiresAt"] = int(time.time() * 1000) + int(tok["expires_in"]) * 1000
_write_creds(data)
return o["accessToken"]
async def _valid_token() -> str:
"""Renvoie un access token valide, en rafraîchissant si nécessaire (sérialisé)."""
data = _load_creds()
o = data["claudeAiOauth"]
if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > EXPIRY_BUFFER_MS:
return o["accessToken"]
async with _refresh_lock:
# Re-lecture après acquisition du lock : un autre coroutine a peut-être déjà rafraîchi.
data = _load_creds()
o = data["claudeAiOauth"]
if int(o.get("expiresAt", 0)) - int(time.time() * 1000) > EXPIRY_BUFFER_MS:
return o["accessToken"]
return await _refresh(data)
async def _get_usage(token: str) -> httpx.Response:
headers = {
"Authorization": f"Bearer {token}",
"anthropic-beta": "oauth-2025-04-20",
"User-Agent": config.claude_ua,
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=15) as client:
return await client.get(USAGE_URL, headers=headers)
def _burn_rate_from_ccusage() -> float | None:
"""Burn rate du bloc 5h actif via `ccusage blocks --json` (best-effort)."""
try:
proc = subprocess.run(
["ccusage", "blocks", "--active", "--json"],
capture_output=True,
text=True,
timeout=20,
capture_output=True, text=True, timeout=20,
)
if proc.returncode != 0:
return None
data = json.loads(proc.stdout)
blocks = data.get("blocks") or []
for b in blocks:
for b in data.get("blocks") or []:
if b.get("isActive"):
bp = b.get("burnRate") or {}
rate = bp.get("tokensPerMinute") or bp.get("tokensPerMinuteForIndicator")
@@ -101,23 +178,31 @@ def _burn_rate_from_ccusage() -> float | None:
async def fetch_usage() -> ClaudeUsage:
if not config.claude_token:
return ClaudeUsage(ok=False, error="MONITORINK_CLAUDE_TOKEN manquant")
if not os.path.exists(config.claude_creds_path):
return ClaudeUsage(ok=False, error="credentials Claude absents — login isolé requis")
headers = {
"Authorization": f"Bearer {config.claude_token}",
"anthropic-beta": "oauth-2025-04-20",
"User-Agent": config.claude_ua,
"Content-Type": "application/json",
}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(USAGE_URL, headers=headers)
token = await _valid_token()
except (OSError, KeyError, json.JSONDecodeError) as exc:
return ClaudeUsage(ok=False, error=f"credentials illisibles: {exc}")
except httpx.HTTPError as exc:
return ClaudeUsage(ok=False, error=f"refresh échoué: {exc}")
try:
resp = await _get_usage(token)
# Token rejeté malgré le buffer -> une tentative de refresh forcé.
if resp.status_code == 401:
data = _load_creds()
async with _refresh_lock:
token = await _refresh(data)
resp = await _get_usage(token)
except httpx.HTTPError as exc:
return ClaudeUsage(ok=False, error=f"réseau: {exc}")
if resp.status_code == 401:
return ClaudeUsage(ok=False, error="token expiré — relancer `claude setup-token`")
return ClaudeUsage(ok=False, error="auth invalide — relancer le login isolé")
if resp.status_code == 403:
return ClaudeUsage(ok=False, error="scope insuffisant (login complet requis)")
if resp.status_code != 200:
return ClaudeUsage(ok=False, error=f"HTTP {resp.status_code}")