From 6f3c3963a4b33180561f1a951062316eda06dfc2 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 13 Jun 2026 10:34:50 +0000 Subject: [PATCH] feat(operations): show real claude.ai subscription usage % on /operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../legal_mcp/court_fetch_service/server.py | 37 ++++++++++++ web-ui/src/app/operations/page.tsx | 59 +++++++++++++++++++ .../components/training/learning-panel.tsx | 2 +- web-ui/src/lib/api/operations.ts | 17 ++++++ web/app.py | 17 ++++++ 5 files changed, 131 insertions(+), 1 deletion(-) diff --git a/mcp-server/src/legal_mcp/court_fetch_service/server.py b/mcp-server/src/legal_mcp/court_fetch_service/server.py index c1c6c40..96fb429 100644 --- a/mcp-server/src/legal_mcp/court_fetch_service/server.py +++ b/mcp-server/src/legal_mcp/court_fetch_service/server.py @@ -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 diff --git a/web-ui/src/app/operations/page.tsx b/web-ui/src/app/operations/page.tsx index 2486a27..daa0c22 100644 --- a/web-ui/src/app/operations/page.tsx +++ b/web-ui/src/app/operations/page.tsx @@ -33,12 +33,68 @@ import { type OperationsSnapshot, type PipelineStats, type AgentRun, + type SubscriptionUsage, + type UsageWindow, } from "@/lib/api/operations"; function mb(bytes: number): string { return `${Math.round((bytes || 0) / 1024 / 1024)}MB`; } +// claude.ai subscription usage — the fuel gauge for every agent/drain. +// Bar turns amber > 75% and red > 90%; the halacha drain auto-pauses at 100% +// and resumes on its own when a window resets. +function usageResetLabel(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + return `איפוס ${d.toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" })}`; +} + +function UsageMeter({ label, w }: { label: string; w?: UsageWindow | null }) { + const util = w?.utilization; + const active = typeof util === "number"; + const pct = active ? Math.min(100, Math.max(0, util as number)) : 0; + const fill = pct >= 90 ? "bg-danger" : pct >= 75 ? "bg-warn" : "bg-gold"; + return ( +
+
+ {label} + {active ? ( + {Math.round(pct)}% + ) : ( + + )} +
+
+ +
+
+ {active ? usageResetLabel(w?.resets_at ?? null) : "לא פעיל כרגע"} +
+
+ ); +} + +function SubscriptionUsagePanel({ usage }: { usage: SubscriptionUsage }) { + if (!usage) return null; // undocumented endpoint unreachable — show nothing + return ( + + +
+ + + +
+
+ המנוי המשותף שמפעיל את כל הסוכנים והדריינרים. הפס נצבע בענבר מעל 75% + ובאדום מעל 90%. דריינר-ההלכות משתהה אוטומטית כשחלון מגיע ל-100% וממשיך + לבד כשהוא מתאפס. +
+
+
+ ); +} + // mockup 02: every region opens with a navy section heading (h2, 18px) — the // page reads as a sequence of titled sections rather than a stack of cards. function SectionHeader({ children }: { children: React.ReactNode }) { @@ -777,6 +833,9 @@ export default function OperationsPage() { ) : ( <> + מכסת claude.ai + + סוכנים פעילים diff --git a/web-ui/src/components/training/learning-panel.tsx b/web-ui/src/components/training/learning-panel.tsx index 0c460c6..2887fd5 100644 --- a/web-ui/src/components/training/learning-panel.tsx +++ b/web-ui/src/components/training/learning-panel.tsx @@ -106,7 +106,7 @@ function ProposalReview({ pairId }: { pairId: string }) { return (

- הצעת הדיסטילציה (סגנון/שיטה בלבד — INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו"ר). + הצעת הדיסטילציה (סגנון/שיטה בלבד — INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו"ר).

{data.overall_assessment && (

{data.overall_assessment}

diff --git a/web-ui/src/lib/api/operations.ts b/web-ui/src/lib/api/operations.ts index 83baf6f..26c0a39 100644 --- a/web-ui/src/lib/api/operations.ts +++ b/web-ui/src/lib/api/operations.ts @@ -42,9 +42,26 @@ export type PipelineStats = { by_status: Record; // raw counts, for the curious }; +/** One claude.ai usage window (5-hour / weekly / weekly-per-model). */ +export type UsageWindow = { + utilization: number | null; // 0-100; null when the window is inactive + resets_at: string | null; +}; + +/** claude.ai subscription usage — the same %s the Claude Code status bar shows, + * via the (undocumented) OAuth usage endpoint proxied by the host bridge. + * null when the endpoint is unreachable. */ +export type SubscriptionUsage = { + five_hour?: UsageWindow | null; + seven_day?: UsageWindow | null; + seven_day_opus?: UsageWindow | null; + seven_day_sonnet?: UsageWindow | null; +} | null; + export type OperationsSnapshot = { services: OpsService[]; services_error: string | null; + subscription_usage: SubscriptionUsage; pipelines: { court_fetch: PipelineStats & { recent: CourtFetchJob[] }; metadata_extraction: PipelineStats; diff --git a/web/app.py b/web/app.py index 097a0d5..749845a 100644 --- a/web/app.py +++ b/web/app.py @@ -6525,6 +6525,21 @@ async def _ops_pm2_services() -> dict: return {"services": [], "error": f"לא ניתן להגיע לשירות-המארח: {e}"} +async def _ops_subscription_usage() -> dict | None: + """Proxy the host court-fetch-service /usage — the claude.ai subscription + utilization % (5-hour / weekly). Host-only (the OAuth token lives on the + host). Returns the endpoint JSON, or None if unavailable (undocumented + endpoint — the dashboard shows nothing when this is None).""" + try: + async with httpx.AsyncClient(timeout=18.0) as client: + r = await client.get(f"{_COURT_FETCH_SERVICE_URL}/usage") + if r.status_code == 200: + return r.json() + except Exception: + pass + return None + + async def _ops_pm2_control(name: str, action: str) -> dict: """Proxy a mutating pm2 action to the host bridge (Bearer-authenticated).""" secret = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip() @@ -6636,6 +6651,7 @@ async def operations_snapshot(): ] pm2 = await _ops_pm2_services() + subscription_usage = await _ops_subscription_usage() controls = await db.get_drain_controls() bursts = await db.get_drain_bursts() for svc in pm2["services"]: @@ -6652,6 +6668,7 @@ async def operations_snapshot(): return { "services": pm2["services"], "services_error": pm2["error"], + "subscription_usage": subscription_usage, "pipelines": { "court_fetch": { **_norm_pipeline( -- 2.49.1