fix(operations): cache the usage endpoint (avoid 429) + show weekly-Sonnet not Opus
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s

Two follow-ups to the usage-% feature:

1. The /api/oauth/usage endpoint 429s when polled often — /operations refreshes
   every 5s and each refresh hit it (plus the supervisor + ad-hoc calls). Cache
   the last good payload on the host bridge for 60s and serve it; only re-fetch
   when stale, so Anthropic sees ~1 req/min regardless of dashboard polling. On
   a fetch failure (e.g. a transient 429) serve the last good payload instead of
   blanking the card. The 5-hour window moves slowly, so 60s stays fresh.

2. The third meter showed weekly-Opus, which is null on this account (the
   per-model weekly cap that's actually populated is Sonnet). Switched the
   display to seven_day_sonnet / "שבועי · Sonnet". (The supervisor keeps gating
   on seven_day_opus — the halacha drain runs Opus, so the Opus cap is the
   correct gate even when it's null/inactive.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:43:57 +00:00
parent 17460044ac
commit 693126484b
2 changed files with 27 additions and 6 deletions

View File

@@ -33,6 +33,7 @@ import json
import logging import logging
import os import os
import sys import sys
import time
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
@@ -102,16 +103,31 @@ async def _pm2_run(*args: str, timeout: float = 10) -> tuple[int, bytes, bytes]:
_CLAUDE_CRED_PATH = "/home/chaim/.claude/.credentials.json" _CLAUDE_CRED_PATH = "/home/chaim/.claude/.credentials.json"
_OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage" _OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
_USAGE_UA = "claude-code/2.1.177" _USAGE_UA = "claude-code/2.1.177"
# /operations polls every 5s; the usage endpoint 429s if hit that often (it's
# meant for a status bar, not a poll loop). Cache the last good payload and only
# re-fetch when older than this — Anthropic sees ~1 req/min regardless of how
# many dashboards poll. The 5-hour window moves slowly, so 60s is plenty fresh.
_USAGE_TTL_SEC = 60.0
_usage_cache: dict = {"ts": 0.0, "data": None}
async def usage_status(request: web.Request) -> web.Response: async def usage_status(request: web.Request) -> web.Response:
"""Proxy the claude.ai subscription usage % (host-only — needs the local """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.""" OAuth token), cached for _USAGE_TTL_SEC. On a fetch failure (e.g. the
endpoint's own 429) serve the last good payload if we have one, so a
transient limit doesn't blank the dashboard."""
now = time.monotonic()
if _usage_cache["data"] is not None and (now - _usage_cache["ts"]) < _USAGE_TTL_SEC:
return web.json_response(_usage_cache["data"])
try: try:
with open(_CLAUDE_CRED_PATH) as f: with open(_CLAUDE_CRED_PATH) as f:
token = json.load(f)["claudeAiOauth"]["accessToken"] token = json.load(f)["claudeAiOauth"]["accessToken"]
except Exception as e: except Exception as e:
if _usage_cache["data"] is not None:
return web.json_response(_usage_cache["data"])
return web.json_response({"error": f"no claude credentials: {e}"}, status=502) return web.json_response({"error": f"no claude credentials: {e}"}, status=502)
headers = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"User-Agent": _USAGE_UA, "User-Agent": _USAGE_UA,
@@ -122,12 +138,17 @@ async def usage_status(request: web.Request) -> web.Response:
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(_OAUTH_USAGE_URL, headers=headers) as r: async with session.get(_OAUTH_USAGE_URL, headers=headers) as r:
if r.status != 200: if r.status != 200:
return web.json_response( raise RuntimeError(f"usage endpoint {r.status}")
{"error": f"usage endpoint {r.status}"}, status=502) data = await r.json()
return web.json_response(await r.json()) except Exception as e: # never throw — serve stale if we have it
except Exception as e: # never throw if _usage_cache["data"] is not None:
return web.json_response(_usage_cache["data"])
return web.json_response({"error": f"usage fetch failed: {e}"}, status=502) return web.json_response({"error": f"usage fetch failed: {e}"}, status=502)
_usage_cache["ts"] = now
_usage_cache["data"] = data
return web.json_response(data)
async def pm2_status(request: web.Request) -> web.Response: async def pm2_status(request: web.Request) -> web.Response:
"""Return a trimmed ``pm2 jlist`` for the legal-ai background services.""" """Return a trimmed ``pm2 jlist`` for the legal-ai background services."""

View File

@@ -83,7 +83,7 @@ function SubscriptionUsagePanel({ usage }: { usage: SubscriptionUsage }) {
<div className="grid gap-6 sm:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-3">
<UsageMeter label="חלון 5-שעות" w={usage.five_hour} /> <UsageMeter label="חלון 5-שעות" w={usage.five_hour} />
<UsageMeter label="שבועי · כל המודלים" w={usage.seven_day} /> <UsageMeter label="שבועי · כל המודלים" w={usage.seven_day} />
<UsageMeter label="שבועי · Opus" w={usage.seven_day_opus} /> <UsageMeter label="שבועי · Sonnet" w={usage.seven_day_sonnet} />
</div> </div>
<div className="mt-3 border-t border-rule-soft pt-2.5 text-[11.5px] text-ink-muted"> <div className="mt-3 border-t border-rule-soft pt-2.5 text-[11.5px] text-ink-muted">
המנוי המשותף שמפעיל את כל הסוכנים והדריינרים. הפס נצבע בענבר מעל 75% המנוי המשותף שמפעיל את כל הסוכנים והדריינרים. הפס נצבע בענבר מעל 75%