Claude: passage au login isolé + refresh (scope user:profile requis par /usage)
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
# ── Monitorink — copier en .env et compléter (ne jamais versionner .env) ──
|
# ── Monitorink — copier en .env et compléter (ne jamais versionner .env) ──
|
||||||
|
|
||||||
# Token Claude longue durée, généré par `claude setup-token` sur le homelab.
|
# Credentials Claude d'un login ISOLÉ dédié à Monitorink (scope user:profile requis par
|
||||||
MONITORINK_CLAUDE_TOKEN=sk-ant-oat01-xxxxxxxx
|
# /usage -> login complet obligatoire, `claude setup-token` ne suffit PAS).
|
||||||
|
# Créer via : CLAUDE_CONFIG_DIR=/home/jerem/.monitorink-claude claude auth login
|
||||||
|
# Le backend lit/écrit ce fichier (refresh) ; il est monté dans le conteneur sur /creds.
|
||||||
|
MONITORINK_CLAUDE_CREDS=/creds/.credentials.json
|
||||||
# User-Agent attendu par l'endpoint /usage (doit ressembler à claude-code/<version>)
|
# User-Agent attendu par l'endpoint /usage (doit ressembler à claude-code/<version>)
|
||||||
MONITORINK_CLAUDE_UA=claude-code/2.1.172
|
MONITORINK_CLAUDE_UA=claude-code/2.1.172
|
||||||
# Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1
|
# Burn rate via ccusage (nécessite ccusage installé + ~/.claude/projects monté). 0/1
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -29,9 +29,11 @@ de tokens — Anthropic n'expose pas de compteur absolu côté abonnement. Monit
|
|||||||
le **% restant** (`100 − utilisation`) et le temps avant reset. *(ChatGPT : hors scope, aucune
|
le **% restant** (`100 − utilisation`) et le temps avant reset. *(ChatGPT : hors scope, aucune
|
||||||
API officielle de quota restant.)*
|
API officielle de quota restant.)*
|
||||||
|
|
||||||
Le backend s'authentifie avec un **token longue durée dédié** généré par `claude setup-token`
|
L'endpoint `/usage` exige le scope OAuth `user:profile`, que le token `claude setup-token`
|
||||||
(env `MONITORINK_CLAUDE_TOKEN`) — il ne touche jamais aux credentials de Claude Code et ne fait
|
**n'a pas** (403). Le backend utilise donc les credentials d'un **login Claude isolé dédié**
|
||||||
aucun refresh (le token est valide ~1 an).
|
à Monitorink (`CLAUDE_CONFIG_DIR` séparé) : scopes complets + lignée de refresh token propre,
|
||||||
|
sans aucun conflit avec le Claude Code du Mac ou du homelab. Le backend lit/rafraîchit
|
||||||
|
**uniquement ce fichier isolé** (`/creds/.credentials.json`), jamais le `~/.claude` partagé.
|
||||||
|
|
||||||
## Démarrage backend (dev, sur Mac)
|
## Démarrage backend (dev, sur Mac)
|
||||||
|
|
||||||
@@ -46,8 +48,10 @@ cp ../.env.example ../.env # puis compléter
|
|||||||
|
|
||||||
## Déploiement (homelab, Docker + Caddy)
|
## Déploiement (homelab, Docker + Caddy)
|
||||||
|
|
||||||
1. Générer le token sur le homelab : `ssh -t homelab claude setup-token`.
|
1. Login Claude isolé dédié (scopes complets) sur le homelab :
|
||||||
2. Créer `.env` (depuis `.env.example`) avec le token, les coords météo, et la config HA.
|
`CLAUDE_CONFIG_DIR=/home/jerem/.monitorink-claude claude auth login`
|
||||||
|
2. Créer `.env` (depuis `.env.example`) : coords météo, config HA. Le compose monte
|
||||||
|
`~/.monitorink-claude` sur `/creds` (lecture/écriture pour le refresh du token).
|
||||||
3. `docker compose up -d --build` (le service s'expose via caddy-docker-proxy sur
|
3. `docker compose up -d --build` (le service s'expose via caddy-docker-proxy sur
|
||||||
`monitorink.homelab.nestor-server.fr`).
|
`monitorink.homelab.nestor-server.fr`).
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ class Config:
|
|||||||
height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680")))
|
height: int = field(default_factory=lambda: int(_get("MONITORINK_HEIGHT", "1680")))
|
||||||
|
|
||||||
# --- Claude ---
|
# --- 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(
|
claude_ua: str = field(
|
||||||
default_factory=lambda: _get("MONITORINK_CLAUDE_UA", "claude-code/2.1.172")
|
default_factory=lambda: _get("MONITORINK_CLAUDE_UA", "claude-code/2.1.172")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,14 +7,21 @@ Validé empiriquement (compte Max 5x) :
|
|||||||
réponse: { five_hour:{utilization,resets_at}, seven_day:{...},
|
réponse: { five_hour:{utilization,resets_at}, seven_day:{...},
|
||||||
seven_day_opus, seven_day_sonnet, extra_usage }
|
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`
|
⚠️ L'endpoint exige le scope OAuth `user:profile`. Le token `claude setup-token` ne l'a PAS
|
||||||
(env MONITORINK_CLAUDE_TOKEN). Aucun refresh/écriture n'est effectué ici : en cas de 401,
|
(403). On utilise donc les credentials d'un **login Claude isolé dédié** (CLAUDE_CONFIG_DIR
|
||||||
on remonte un état d'erreur pour affichage (« token à régénérer »).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -23,6 +30,11 @@ import httpx
|
|||||||
from config import config
|
from config import config
|
||||||
|
|
||||||
USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
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
|
@dataclass
|
||||||
@@ -77,20 +89,85 @@ def _parse_window(raw: dict | None) -> Window | None:
|
|||||||
return Window(utilization=util, resets_at=dt)
|
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:
|
def _burn_rate_from_ccusage() -> float | None:
|
||||||
"""Burn rate du bloc 5h actif via `ccusage blocks --json` (best-effort)."""
|
"""Burn rate du bloc 5h actif via `ccusage blocks --json` (best-effort)."""
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
["ccusage", "blocks", "--active", "--json"],
|
["ccusage", "blocks", "--active", "--json"],
|
||||||
capture_output=True,
|
capture_output=True, text=True, timeout=20,
|
||||||
text=True,
|
|
||||||
timeout=20,
|
|
||||||
)
|
)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return None
|
return None
|
||||||
data = json.loads(proc.stdout)
|
data = json.loads(proc.stdout)
|
||||||
blocks = data.get("blocks") or []
|
for b in data.get("blocks") or []:
|
||||||
for b in blocks:
|
|
||||||
if b.get("isActive"):
|
if b.get("isActive"):
|
||||||
bp = b.get("burnRate") or {}
|
bp = b.get("burnRate") or {}
|
||||||
rate = bp.get("tokensPerMinute") or bp.get("tokensPerMinuteForIndicator")
|
rate = bp.get("tokensPerMinute") or bp.get("tokensPerMinuteForIndicator")
|
||||||
@@ -101,23 +178,31 @@ def _burn_rate_from_ccusage() -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
async def fetch_usage() -> ClaudeUsage:
|
async def fetch_usage() -> ClaudeUsage:
|
||||||
if not config.claude_token:
|
if not os.path.exists(config.claude_creds_path):
|
||||||
return ClaudeUsage(ok=False, error="MONITORINK_CLAUDE_TOKEN manquant")
|
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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=15) as client:
|
token = await _valid_token()
|
||||||
resp = await client.get(USAGE_URL, headers=headers)
|
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:
|
except httpx.HTTPError as exc:
|
||||||
return ClaudeUsage(ok=False, error=f"réseau: {exc}")
|
return ClaudeUsage(ok=False, error=f"réseau: {exc}")
|
||||||
|
|
||||||
if resp.status_code == 401:
|
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:
|
if resp.status_code != 200:
|
||||||
return ClaudeUsage(ok=False, error=f"HTTP {resp.status_code}")
|
return ClaudeUsage(ok=False, error=f"HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Sonde de l'endpoint /usage Claude pour valider le parser de Monitorink.
|
"""Sonde de l'endpoint /usage Claude pour valider le parser de Monitorink.
|
||||||
|
|
||||||
Usage (sur le homelab, après `claude setup-token`) :
|
Lit l'access token depuis un fichier .credentials.json (login Claude isolé) puis appelle
|
||||||
MONITORINK_CLAUDE_TOKEN="sk-ant-oat01-..." python3 dev/probe_usage.py
|
/usage. Affiche la structure JSON brute (uniquement des % d'usage, aucun secret) +
|
||||||
|
l'interprétation de Monitorink. Sortie partageable sans risque.
|
||||||
|
|
||||||
Affiche la structure JSON brute renvoyée par l'API (uniquement des % d'usage, aucun secret)
|
Usage (sur le homelab, après le login isolé) :
|
||||||
+ l'interprétation que Monitorink en fait. Sortie partageable sans risque.
|
python3 dev/probe_usage.py /home/jerem/.monitorink-claude/.credentials.json
|
||||||
|
ou via env :
|
||||||
|
MONITORINK_CLAUDE_CREDS=/home/jerem/.monitorink-claude/.credentials.json python3 dev/probe_usage.py
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -14,9 +17,17 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
URL = "https://api.anthropic.com/api/oauth/usage"
|
URL = "https://api.anthropic.com/api/oauth/usage"
|
||||||
token = os.environ.get("MONITORINK_CLAUDE_TOKEN", "").strip()
|
|
||||||
if not token:
|
path = (sys.argv[1] if len(sys.argv) > 1 else "") or os.environ.get("MONITORINK_CLAUDE_CREDS", "")
|
||||||
sys.exit("MONITORINK_CLAUDE_TOKEN manquant (export-le ou préfixe la commande)")
|
path = path.strip()
|
||||||
|
if not path:
|
||||||
|
sys.exit("Chemin du .credentials.json requis (arg ou MONITORINK_CLAUDE_CREDS)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
creds = json.load(open(path))
|
||||||
|
token = creds["claudeAiOauth"]["accessToken"]
|
||||||
|
except (OSError, KeyError, json.JSONDecodeError) as e:
|
||||||
|
sys.exit(f"Lecture credentials impossible: {e}")
|
||||||
|
|
||||||
req = urllib.request.Request(URL, headers={
|
req = urllib.request.Request(URL, headers={
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ services:
|
|||||||
container_name: monitorink
|
container_name: monitorink
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
# Optionnel : burn rate via ccusage (lecture seule des logs Claude Code du homelab).
|
volumes:
|
||||||
# Décommenter et passer MONITORINK_CCUSAGE=1 si voulu.
|
# Login Claude ISOLÉ dédié à Monitorink (lecture/écriture pour le refresh du token).
|
||||||
# volumes:
|
# Créé via: CLAUDE_CONFIG_DIR=/home/jerem/.monitorink-claude claude auth login
|
||||||
# - /home/jerem/.claude/projects:/root/.claude/projects:ro
|
- /home/jerem/.monitorink-claude:/creds:rw
|
||||||
|
# Optionnel : burn rate via ccusage (lecture seule des logs Claude Code principaux).
|
||||||
|
# Décommenter + MONITORINK_CCUSAGE=1.
|
||||||
|
# - /home/jerem/.claude/projects:/root/.claude/projects:ro
|
||||||
networks:
|
networks:
|
||||||
- nestorr
|
- nestorr
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
Reference in New Issue
Block a user