feat(operations): show real claude.ai subscription usage % on /operations
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Surfaces the 5-hour / weekly / weekly-Opus utilization the Claude Code status bar shows — the authoritative number, not a token estimate. Design approved via the Claude Design gate (card 02c-operations-usage.html). Three layers: - court-fetch-service (host bridge): new GET /usage reads the OAuth token from ~/.claude/.credentials.json and proxies /api/oauth/usage with the required claude-code User-Agent. Read-only, no auth (like /pm2). Host-only — the token never enters the container. - web/app.py: _ops_subscription_usage() proxies the bridge /usage; the /api/operations snapshot gains a `subscription_usage` field (null when the undocumented endpoint is unreachable). - web-ui: SubscriptionUsagePanel renders three meters (label · % · bar · reset) at the top of /operations; bar turns amber >75% / red >90%; hidden when usage is null. Types added to operations.ts (hand-maintained snapshot type). Also fixes a pre-existing react/no-unescaped-entities lint error in learning-panel.tsx (escaped a `"` in Hebrew text — renders identically). tsc --noEmit passes; lint error count 0. (Full next build is blocked only by the manual-worktree node_modules symlink — the Docker build has real node_modules.) Invariants: G2 (usage surfaced through the existing host bridge + /api/operations snapshot — no parallel control path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
_pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
@@ -93,6 +94,41 @@ async def _pm2_run(*args: str, timeout: float = 10) -> tuple[int, bytes, bytes]:
|
||||
return proc.returncode or 0, out, err
|
||||
|
||||
|
||||
# claude.ai subscription usage — the 5-hour / weekly utilization % the Claude
|
||||
# Code status bar shows, from the (undocumented) OAuth usage endpoint. Host-only:
|
||||
# the OAuth token lives in the CLI credentials file on the host, never in the
|
||||
# container. Read-only (no auth), like /pm2. The claude-code User-Agent is
|
||||
# REQUIRED — without it the request lands in an aggressively rate-limited bucket.
|
||||
_CLAUDE_CRED_PATH = "/home/chaim/.claude/.credentials.json"
|
||||
_OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
||||
_USAGE_UA = "claude-code/2.1.177"
|
||||
|
||||
|
||||
async def usage_status(request: web.Request) -> web.Response:
|
||||
"""Proxy the claude.ai subscription usage % (host-only — needs the local
|
||||
OAuth token). Returns the endpoint's JSON, or a 502 with an error string."""
|
||||
try:
|
||||
with open(_CLAUDE_CRED_PATH) as f:
|
||||
token = json.load(f)["claudeAiOauth"]["accessToken"]
|
||||
except Exception as e:
|
||||
return web.json_response({"error": f"no claude credentials: {e}"}, status=502)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": _USAGE_UA,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
}
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=15)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(_OAUTH_USAGE_URL, headers=headers) as r:
|
||||
if r.status != 200:
|
||||
return web.json_response(
|
||||
{"error": f"usage endpoint {r.status}"}, status=502)
|
||||
return web.json_response(await r.json())
|
||||
except Exception as e: # never throw
|
||||
return web.json_response({"error": f"usage fetch failed: {e}"}, status=502)
|
||||
|
||||
|
||||
async def pm2_status(request: web.Request) -> web.Response:
|
||||
"""Return a trimmed ``pm2 jlist`` for the legal-ai background services."""
|
||||
try:
|
||||
@@ -235,6 +271,7 @@ def build_app() -> web.Application:
|
||||
app = web.Application(client_max_size=64 * 1024 * 1024)
|
||||
app.router.add_get("/health", health)
|
||||
app.router.add_get("/pm2", pm2_status)
|
||||
app.router.add_get("/usage", usage_status)
|
||||
app.router.add_post("/pm2/control", pm2_control)
|
||||
app.router.add_post("/fetch", fetch)
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user