Files
member-dashboard/app/main.py
T

727 lines
30 KiB
Python

"""Member Dashboard — read-only usage view + API-key management.
Security model:
- Authentication is done by Cloudron's proxyAuth wall (SSO). Cloudron injects
the authenticated user's identity via the X-Remote-User header (username).
- This app holds NO privileged credentials. It calls the member portal's
broker endpoints, authenticated with a shared BROKER_SECRET, and scopes
every request to the logged-in member.
Environment (all provided by Cloudron env vars):
PORTAL_BASE — base URL of the member portal (e.g. https://portal.inference.coop)
BROKER_SECRET — shared secret for the portal's broker endpoints
"""
import os
import logging
import re
import httpx
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from app import oidc
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("member-dashboard")
PORTAL_BASE = os.environ.get("PORTAL_BASE", "").rstrip("/")
BROKER_SECRET = os.environ.get("BROKER_SECRET", "")
app = FastAPI()
# ---------------------------------------------------------------------------
# Identity — Cloudron OIDC session cookie (username). proxyAuth header is the
# legacy fallback during the transition.
# ---------------------------------------------------------------------------
def get_user_identity(request: Request) -> str:
"""Return the logged-in user's identity (Cloudron username).
Reads the validated OIDC session cookie. No header fallback — the app no
longer trusts a client-supplied identity header (that path existed only
during the proxyAuth → OIDC transition and is now removed).
"""
if not oidc.is_oidc_configured():
return ""
token = request.cookies.get(oidc.SESSION_COOKIE)
if not token:
return ""
return oidc.read_session(token) or ""
# ---------------------------------------------------------------------------
# Broker calls (to the member portal)
# ---------------------------------------------------------------------------
def broker_headers(identity: str) -> dict:
return {"X-Broker-Secret": BROKER_SECRET, "X-Member-User": identity}
async def broker_get(identity: str, path: str) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.get(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
if r.status_code == 401:
raise HTTPException(500, "Portal rejected broker credentials")
if r.status_code == 403:
raise HTTPException(403, "Not an active member")
if r.status_code != 200:
raise HTTPException(502, f"Portal error {r.status_code}")
return r.json()
async def broker_post(identity: str, path: str, payload: dict | None = None) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.post(f"{PORTAL_BASE}{path}", headers=broker_headers(identity), json=payload or {})
if r.status_code in (401, 403):
raise HTTPException(r.status_code, r.text)
if r.status_code not in (200, 201):
raise HTTPException(502, f"Portal error {r.status_code}: {r.text}")
return r.json()
async def broker_delete(identity: str, path: str) -> dict:
async with httpx.AsyncClient(timeout=20.0) as client:
r = await client.delete(f"{PORTAL_BASE}{path}", headers=broker_headers(identity))
if r.status_code in (401, 403):
raise HTTPException(r.status_code, r.text)
if r.status_code not in (200, 204):
raise HTTPException(502, f"Portal error {r.status_code}")
return r.json() if r.content else {}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
@app.get("/")
async def index(request: Request):
identity = get_user_identity(request)
if not identity:
if oidc.is_oidc_configured():
return RedirectResponse("/auth/openid/login", status_code=302)
return HTMLResponse(
"<h1>Not authenticated</h1><p>Please log in via the dashboard login.</p>",
status_code=401,
)
return HTMLResponse(render_page(identity))
@app.get("/api/usage")
async def api_usage(request: Request):
identity = get_user_identity(request)
if not identity:
return JSONResponse({"error": "unauthenticated"}, status_code=401)
try:
data = await broker_get(identity, "/broker/usage")
keys = await broker_get(identity, "/broker/keys")
data["keys"] = keys.get("keys", [])
# Co-op-wide model popularity (aggregate, anonymized) for the leaderboard.
try:
board = await broker_get(identity, "/broker/model-leaderboard?days=30")
data["coop_models"] = board.get("models", [])
except HTTPException:
data["coop_models"] = [] # best-effort; never block the main view
return data
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.post("/api/keys")
async def api_create_key(request: Request):
identity = get_user_identity(request)
if not identity:
return JSONResponse({"error": "unauthenticated"}, status_code=401)
body = await request.json()
name = (body.get("name") or "").strip()
if not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", name):
return JSONResponse({"error": "Invalid key name"}, status_code=400)
try:
return await broker_post(identity, "/broker/keys", {"name": name})
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
@app.delete("/api/keys/{name}")
async def api_revoke_key(name: str, request: Request):
identity = get_user_identity(request)
if not identity:
return JSONResponse({"error": "unauthenticated"}, status_code=401)
try:
return await broker_delete(identity, f"/broker/keys/{name}")
except HTTPException as e:
return JSONResponse({"error": e.detail}, status_code=e.status_code)
# ---------------------------------------------------------------------------
# OIDC routes — authorization-code flow against Cloudron's OIDC provider.
# Active only when the `oidc` addon is configured (CLOUDRON_OIDC_* present).
# During the transition the legacy proxyAuth header still works as a fallback.
# ---------------------------------------------------------------------------
@app.get("/auth/openid/login")
async def oidc_login(request: Request):
if not oidc.is_oidc_configured():
raise HTTPException(404, "OIDC not configured")
login_url, state, nonce = oidc.build_login_url(request.headers.get("host", ""))
resp = RedirectResponse(login_url, status_code=302)
resp.set_cookie("oidc_state", state, max_age=600, httponly=True, samesite="lax")
resp.set_cookie("oidc_nonce", nonce, max_age=600, httponly=True, samesite="lax")
return resp
@app.get("/auth/openid/callback")
async def oidc_callback(request: Request):
if not oidc.is_oidc_configured():
raise HTTPException(404, "OIDC not configured")
code = request.query_params.get("code")
state = request.query_params.get("state")
expected_state = request.cookies.get("oidc_state")
nonce = request.cookies.get("oidc_nonce")
if not code or not state or not expected_state or not nonce:
return HTMLResponse("<h1>Login failed</h1><p>Incomplete OIDC callback.</p>", status_code=400)
if state != expected_state:
return HTMLResponse("<h1>Login failed</h1><p>State mismatch (possible CSRF).</p>", status_code=400)
try:
identity = await oidc.exchange_code(code, request.headers.get("host", ""), nonce)
except ValueError as e:
logger.warning("OIDC login failed: %s", e)
return HTMLResponse("<h1>Login failed</h1><p>Could not complete sign-in.</p>", status_code=400)
session = oidc.write_session(identity)
resp = RedirectResponse("/", status_code=302)
resp.set_cookie(oidc.SESSION_COOKIE, session, max_age=oidc.SESSION_MAX_AGE, httponly=True, samesite="lax")
resp.delete_cookie("oidc_state")
resp.delete_cookie("oidc_nonce")
return resp
@app.get("/logout")
async def logout(request: Request):
"""Log out: clear our session, then show an explicit logged-out page.
Cloudron's OIDC provider removed its RP-initiated logout endpoint (forum:
"no app supports this properly"), so we cannot end the SSO session
programmatically. Redirecting to / would silently re-authenticate through
the still-valid SSO cookie — the "logout does nothing" bug. Instead we
clear our cookies and show a page with a manual link to sign out of
Cloudron SSO (my.inference.coop/logout) for a full sign-out.
"""
html = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Logged out — Inference Cooperative</title>
<style>
body { font-family: 'Figtree', -apple-system, system-ui, sans-serif; background: #faf8f5;
color: #2d3327; display: flex; align-items: center; justify-content: center;
min-height: 100vh; margin: 0; }
.card { background: #fff; border: 1px solid #e4e0d8; border-radius: 14px;
padding: 32px 40px; max-width: 440px; text-align: center; }
h1 { font-size: 20px; margin: 0 0 12px; color: #2d3327; }
p { font-size: 14px; line-height: 1.6; color: #4a5442; margin: 0 0 16px; }
a.btn { display: inline-block; background: #5b8c5a; color: #fff; text-decoration: none;
padding: 9px 18px; border-radius: 8px; font-weight: 600; font-size: 14px; }
a.btn:hover { background: #4e7a4d; }
a.link { color: #3f6e3e; font-size: 13px; }
</style></head>
<body><div class="card">
<h1>You're logged out</h1>
<p>Your dashboard session has been cleared.</p>
<p>Note: you may still be signed in to Inference Cooperative SSO
(chat, portal, forum share that sign-in). To fully sign out everywhere,
log out of your Cloudron account too.</p>
<p><a class="btn" href="https://my.inference.coop/logout">Sign out of SSO</a></p>
<p><a class="link" href="/">Log back in</a></p>
</div></body></html>"""
resp = HTMLResponse(html)
resp.delete_cookie(oidc.SESSION_COOKIE)
resp.delete_cookie("oidc_state")
resp.delete_cookie("oidc_nonce")
return resp
# ---------------------------------------------------------------------------
# UI (brand-matched, self-contained, no external requests)
# ---------------------------------------------------------------------------
def _esc(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def render_page(identity: str) -> str:
return PAGE_HTML.replace("@@IDENTITY@@", _esc(identity))
PAGE_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inference Cooperative · Your membership</title>
<style>
:root {
--cream: #faf8f5;
--ink: #2d3327;
--muted: #6b7a62;
--green-deep: #5b8c5a;
--green-mid: #6ba86b;
--green-light: #7ab87a;
--border: #e4e0d8;
--danger: #b3543a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--cream);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
display: flex; flex-direction: column; min-height: 100vh;
}
.wrap { width: 100%; max-width: 760px; margin: 0 auto; padding: 32px 24px 24px; flex: 1; }
header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 8px; }
.logo svg { display: block; }
.brand { flex: 1; }
.brand h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
.brand .sub { color: var(--muted); font-size: 14px; }
.identity { margin-top: 6px; font-size: 13px; color: var(--muted); }
.identity code {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
background: #f4f2ec; padding: 1px 6px; border-radius: 5px; font-size: 12px;
}
.header-actions { display: flex; gap: 14px; align-items: center; flex-shrink: 0; }
a.link { color: var(--green-deep); text-decoration: none; font-size: 14px; font-weight: 600; }
a.link:hover { text-decoration: underline; }
.card {
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
padding: 24px 28px;
margin-top: 20px;
}
.card h2 { font-size: 15px; font-weight: 600; margin-bottom: 14px; }
.balance { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.balance .big { font-size: 34px; font-weight: 700; color: var(--green-deep); }
.balance .lbl { color: var(--muted); font-size: 15px; }
.stats { display: flex; gap: 28px; margin-top: 12px; flex-wrap: wrap; }
.stat .num { font-size: 18px; font-weight: 700; color: var(--ink); }
.stat .lbl { font-size: 12px; color: var(--muted); }
.bar { height: 10px; background: #eef0ea; border-radius: 6px; margin-top: 16px; overflow: hidden; }
.bar .fill { height: 100%; background: var(--green-mid); border-radius: 6px; transition: width .3s ease; }
.bar .fill.warn { background: #d99a3e; }
.meta { color: var(--muted); font-size: 13px; margin-top: 8px; }
.models { margin-top: 16px; }
.model-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 9px 0; border-top: 1px solid #f0ede6;
}
.model-name { font-weight: 600; font-size: 13px; }
.model-sub { color: var(--muted); font-size: 12px; }
.model-spend { font-size: 13px; font-weight: 600; color: var(--ink); text-align: right; }
/* Usage mode switch */
.usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.usage-header h2 { margin-bottom: 0; }
.mode-switch { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.mode-btn {
font-family: inherit; cursor: pointer; border: none; background: #fff; color: var(--muted);
font-size: 12px; font-weight: 600; padding: 6px 12px; border-radius: 0;
}
.mode-btn.active { background: var(--green-deep); color: #fff; }
.docs-link { display: inline-block; margin-top: 14px; color: var(--green-deep); font-size: 13px; font-weight: 600; text-decoration: none; }
.docs-link:hover { text-decoration: underline; }
/* Leaderboard */
.board-note { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
.board-row {
display: flex; align-items: flex-start; gap: 14px;
padding: 12px 0; border-top: 1px solid #f0ede6;
}
.board-rank {
width: 26px; height: 26px; border-radius: 50%;
background: #eaf3ea; color: #2f5c30; font-weight: 700; font-size: 13px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
margin-top: 2px;
}
.board-row:first-child .board-rank { background: var(--green-deep); color: #fff; }
.board-main { flex: 1; min-width: 0; }
.board-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.board-cost { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.board-name { font-weight: 600; font-size: 13px; margin-bottom: 5px; }
.board-bar { height: 6px; background: #eef0ea; border-radius: 4px; overflow: hidden; }
.board-fill { height: 100%; background: var(--green-mid); border-radius: 4px; }
.board-sub { color: var(--muted); font-size: 11px; margin-top: 4px; }
.keys { margin-top: 8px; }
.key-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 14px 0; border-bottom: 1px solid var(--border);
}
.key-row:last-child { border-bottom: none; }
.key-name { font-weight: 600; font-size: 14px; }
.key-meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
.key-token {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px; color: var(--muted);
background: #f4f2ec; padding: 4px 8px; border-radius: 6px;
word-break: break-all; max-width: 280px;
}
button {
font-family: inherit; cursor: pointer;
border-radius: 8px; font-size: 14px; font-weight: 600;
padding: 8px 16px; border: none; transition: background .15s ease, opacity .15s ease;
}
button.primary { background: var(--green-deep); color: #fff; }
button.primary:hover { background: #4e7a4d; }
button.ghost { background: transparent; color: var(--danger); }
button.ghost:hover { background: #f6e9e5; }
button:disabled { opacity: .5; cursor: not-allowed; }
.new-key { display: flex; gap: 10px; margin-top: 16px; }
.new-key input {
flex: 1; font-family: inherit; font-size: 14px;
padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
background: #fff; color: var(--ink);
}
.new-key input:focus { outline: none; border-color: var(--green-mid); }
.hint { color: var(--muted); font-size: 12px; margin-top: 10px; }
.key-reveal {
margin-top: 16px; padding: 16px;
background: #eaf3ea; border: 1px solid var(--green-mid); border-radius: 10px;
}
.key-reveal-title { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.key-reveal-name { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
.key-reveal-value {
display: block; font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px; background: #fff; padding: 8px 10px; border-radius: 6px;
word-break: break-all; margin-bottom: 10px; user-select: all;
}
.empty { color: var(--muted); font-size: 14px; padding: 12px 0; }
.flash {
padding: 12px 16px; border-radius: 8px; margin-top: 16px; font-size: 14px;
display: none;
}
.flash.show { display: block; }
.flash.ok { background: #eaf3ea; color: #2f5c30; }
.flash.err { background: #f6e9e5; color: var(--danger); }
footer {
border-top: 1px solid var(--border);
padding: 20px 24px; margin-top: 32px;
text-align: center; font-size: 13px; color: var(--muted);
}
footer a { color: var(--green-deep); text-decoration: none; margin: 0 8px; }
footer a:hover { text-decoration: underline; }
/* ---------- Mobile (≤600px) ---------- */
@media (max-width: 600px) {
.wrap { padding: 20px 14px 16px; }
header { flex-direction: column; gap: 8px; }
.header-actions { width: 100%; justify-content: flex-start; }
.card { padding: 18px 16px; border-radius: 12px; margin-top: 16px; }
.balance .big { font-size: 28px; }
.stats { gap: 14px 22px; }
.stat { min-width: 40%; } /* two columns of stats instead of cramped one-row wrap */
.model-row { flex-wrap: wrap; }
.model-spend { text-align: left; width: 100%; padding-left: 2px; }
.key-row { flex-wrap: wrap; }
.key-token { max-width: 100%; }
.new-key { flex-direction: column; }
.new-key button { width: 100%; }
.key-reveal-value { font-size: 11px; }
footer { padding: 16px 14px; }
footer a { display: inline-block; margin: 4px 8px; }
}
/* Very narrow phones: keep key/token text from overflowing */
@media (max-width: 380px) {
.balance .big { font-size: 24px; }
.stats { gap: 12px 16px; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">
<svg width="44" height="44" viewBox="0 0 130 130" fill="none" aria-label="logo">
<circle cx="65" cy="14" r="5" fill="#5b8c5a"/>
<circle cx="58" cy="30" r="6" fill="#7ab87a"/>
<circle cx="74" cy="30" r="4" fill="#6ba86b"/>
<circle cx="51.5" cy="46" r="6.5" fill="#5b8c5a"/>
<circle cx="65" cy="46" r="5" fill="#7ab87a"/>
<circle cx="81" cy="46" r="4" fill="#6ba86b"/>
<circle cx="44" cy="62" r="7" fill="#6ba86b"/>
<circle cx="61" cy="62" r="5.5" fill="#5b8c5a"/>
<circle cx="76" cy="62" r="4.5" fill="#7ab87a"/>
<circle cx="89.5" cy="62" r="3.5" fill="#6ba86b"/>
<circle cx="36.5" cy="78" r="7.5" fill="#5b8c5a"/>
<circle cx="54" cy="78" r="6" fill="#7ab87a"/>
<circle cx="70" cy="78" r="5" fill="#6ba86b"/>
<circle cx="84" cy="78" r="4" fill="#5b8c5a"/>
<circle cx="97.5" cy="78" r="3.5" fill="#7ab87a"/>
<circle cx="44" cy="94" r="6.5" fill="#6ba86b"/>
<circle cx="61" cy="94" r="5.5" fill="#5b8c5a"/>
<circle cx="76" cy="94" r="4.5" fill="#7ab87a"/>
<circle cx="89.5" cy="94" r="3.5" fill="#6ba86b"/>
<circle cx="51.5" cy="110" r="5.5" fill="#5b8c5a"/>
<circle cx="65" cy="110" r="4.5" fill="#7ab87a"/>
<circle cx="81" cy="110" r="3.5" fill="#6ba86b"/>
</svg>
</div>
<div class="brand">
<h1>Inference Cooperative</h1>
<div class="sub">Your membership</div>
<div class="identity" id="identity">Signed in as <code>@@IDENTITY@@</code></div>
</div>
<div class="header-actions">
<a class="link" href="https://my.inference.coop">Member Home</a>
<a class="link" href="/logout">Log out</a>
</div>
</header>
<div class="card">
<div class="usage-header">
<h2>Usage</h2>
<span class="mode-switch">
<button class="mode-btn active" id="mode-mine" onclick="setUsageMode('mine')">My usage</button>
<button class="mode-btn" id="mode-coop" onclick="setUsageMode('coop')">Co-op usage</button>
</span>
</div>
<div id="view-mine">
<div class="balance">
<span class="big" id="spend">—</span>
<span class="lbl" id="of">of your current balance used</span>
</div>
<div class="stats">
<div class="stat"><div class="num" id="remaining">—</div><div class="lbl">remaining now</div></div>
<div class="stat"><div class="num" id="allowance">—</div><div class="lbl">allowance left</div></div>
<div class="stat"><div class="num" id="credits">—</div><div class="lbl">credits (never expire)</div></div>
<div class="stat"><div class="num" id="alltime">—</div><div class="lbl">total spend, all time</div></div>
<div class="stat"><div class="num" id="tokens">—</div><div class="lbl">tokens, all time</div></div>
<div class="stat"><div class="num" id="calls">—</div><div class="lbl">requests, all time</div></div>
</div>
<div class="bar"><div class="fill" id="fill" style="width:0%"></div></div>
<div class="meta" id="meta">Shared across chat and API.</div>
<div class="models" id="models"></div>
</div>
<div id="view-coop" style="display:none;">
<div class="board-note">Usage across the co-op, last 30 days</div>
<div class="board" id="coop-board"></div>
</div>
<a class="docs-link" href="https://git.inference.coop/co-op/docs/src/content/docs/models.md" target="_blank">About these models →</a>
</div>
<div class="card">
<h2>API keys</h2>
<p class="hint">Keys let you use the co-op's AI from your own tools. They draw from the same balance as chat.</p>
<div class="keys" id="keys"><div class="empty">No API keys yet.</div></div>
<div class="new-key">
<input id="new-name" type="text" placeholder="Key name (e.g. my-script)" maxlength="64" />
<button class="primary" id="create-btn" onclick="createKey()">Create key</button>
</div>
<div class="flash" id="flash"></div>
<div class="key-reveal" id="key-reveal" style="display:none;">
<div class="key-reveal-title">Copy your key now — it won't be shown again</div>
<div class="key-reveal-name" id="key-reveal-name"></div>
<code class="key-reveal-value" id="key-reveal-value"></code>
<button class="ghost" onclick="$('key-reveal').style.display='none';">Dismiss</button>
</div>
</div>
</div>
<footer>
<a href="https://git.inference.coop/co-op/docs/src/branch/main/README.md">Docs</a> ·
<a href="https://git.inference.coop/co-op/docs/src/branch/main/terms-of-service.md">Terms</a> ·
<a href="https://git.inference.coop/co-op/docs/src/branch/main/privacy-policy.md">Privacy</a>
<div style="margin-top:6px;">The Inference Cooperative · inference.coop</div>
</footer>
<script>
const $ = (id) => document.getElementById(id);
function flash(msg, ok) {
const f = $('flash');
f.textContent = msg;
f.className = 'flash show ' + (ok ? 'ok' : 'err');
setTimeout(() => { f.className = 'flash'; }, 6000);
}
function fmtMoney(n) { return '$' + (Number(n) || 0).toFixed(2); }
async function load() {
try {
const r = await fetch('/api/usage');
if (r.status === 401) { location.reload(); return; }
const d = await r.json();
if (d.error) { flash(d.error, false); return; }
const balance = d.balance ?? 0;
const spend = d.spend ?? 0;
const remaining = d.remaining ?? Math.max(0, balance - spend);
const pct = balance > 0 ? Math.min(100, (spend / balance) * 100) : 0;
// Two buckets: monthly allowance (resets) + purchased credits (never expire).
const allowance = d.allowance ?? 15;
const credits = d.credits ?? 0;
const spentThisCycle = Math.min(spend, allowance);
const allowanceLeft = Math.max(0, allowance - spentThisCycle);
$('spend').textContent = fmtMoney(spend);
$('of').textContent = 'of ' + fmtMoney(balance) + ' current balance used';
$('remaining').textContent = fmtMoney(remaining);
$('allowance').textContent = fmtMoney(allowanceLeft);
$('credits').textContent = fmtMoney(credits);
$('fill').style.width = pct + '%';
$('fill').className = 'fill' + (pct > 80 ? ' warn' : '');
// Reset date (when the monthly allowance renews)
if (d.reset_at) {
const d2 = new Date(d.reset_at);
const opts = { year: 'numeric', month: 'long', day: 'numeric' };
let meta = 'Shared across chat and API · Allowance renews ' + d2.toLocaleDateString(undefined, opts);
if (credits > 0) meta += ' · Credits carry over and never expire';
$('meta').textContent = meta + '.';
}
const tokens = d.total_tokens ?? 0;
const calls = (d.models || []).reduce((s, m) => s + (m.calls || 0), 0);
$('alltime').textContent = fmtMoney(d.all_time_spend ?? 0);
$('tokens').textContent = tokens.toLocaleString();
$('calls').textContent = calls.toLocaleString();
renderModels(d.models || []);
renderBoard(d.coop_models || []);
renderKeys(d.keys || []);
} catch (e) {
flash('Could not load usage: ' + e.message, false);
}
}
function renderModels(models) {
const el = $('models');
if (!models.length) { el.innerHTML = ''; return; }
const rows = [...models].sort((a, b) => (b.calls || 0) - (a.calls || 0));
el.innerHTML = rows.map(m => {
const sub = (m.calls || 0).toLocaleString() + ' requests · ' + (m.tokens || 0).toLocaleString() + ' tokens';
return '<div class="model-row">' +
'<div><div class="model-name">' + escapeHtml(m.model) + '</div>' +
'<div class="model-sub">' + escapeHtml(sub) + '</div></div>' +
'<div class="model-spend">' + fmtMoney(m.spend) + '</div>' +
'</div>';
}).join('');
}
let usageMode = 'mine'; // 'mine' | 'coop'
function setUsageMode(mode) {
usageMode = mode;
$('mode-mine').className = 'mode-btn' + (mode === 'mine' ? ' active' : '');
$('mode-coop').className = 'mode-btn' + (mode === 'coop' ? ' active' : '');
$('view-mine').style.display = mode === 'mine' ? '' : 'none';
$('view-coop').style.display = mode === 'coop' ? '' : 'none';
}
function renderBoard(models) {
const el = $('coop-board');
if (!models.length) {
el.innerHTML = '<div class="empty">Not enough usage data yet — check back soon.</div>';
return;
}
// Strictly descending by usage (requests); bar widths relative to the top.
const rows = [...models].sort((a, b) => (b.requests || 0) - (a.requests || 0));
const top = Math.max(...rows.map(m => m.requests || 0), 1);
el.innerHTML = rows.map((m, i) => {
const req = m.requests || 0;
const pct = Math.round((req / top) * 100);
return '<div class="board-row">' +
'<div class="board-rank">' + (i + 1) + '</div>' +
'<div class="board-main">' +
'<div class="board-line">' +
'<div class="board-name">' + escapeHtml(m.model) + '</div>' +
'<div class="board-cost">' + fmtMoney(m.spend || 0) + ' total</div>' +
'</div>' +
'<div class="board-bar"><div class="board-fill" style="width:' + pct + '%"></div></div>' +
'<div class="board-sub">' + req.toLocaleString() + ' requests</div>' +
'</div>' +
'</div>';
}).join('');
}
function renderKeys(keys) {
const el = $('keys');
if (!keys.length) {
el.innerHTML = '<div class="empty">No API keys yet. Create one below.</div>';
return;
}
el.innerHTML = keys.map(k => {
const fp = k.fingerprint || '';
const created = k.created_at ? ' · created ' + k.created_at.slice(0,10) : '';
return '<div class="key-row">' +
'<div><div class="key-name">' + escapeHtml(k.name) + '</div>' +
'<div class="key-meta">' + escapeHtml(created) +
(fp ? ' · ends …' + escapeHtml(fp) : '') + '</div>' +
'</div>' +
'<button class="ghost" data-name="' + escapeHtml(k.name) + '" onclick="revokeKey(this.dataset.name)">Revoke</button>' +
'</div>';
}).join('');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function createKey() {
const name = $('new-name').value.trim();
if (!name) { flash('Enter a name for the key', false); return; }
$('create-btn').disabled = true;
try {
const r = await fetch('/api/keys', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ name })
});
const d = await r.json();
if (d.error) { flash(d.error, false); }
else {
const token = d.sk_token || '';
if (token) {
// Reveal the key ONCE, inline, copyable. It is never shown again.
const box = $('key-reveal');
box.style.display = 'block';
$('key-reveal-value').textContent = token;
$('key-reveal-name').textContent = name;
navigator.clipboard?.writeText(token).catch(() => {});
}
$('new-name').value = '';
load();
}
} catch (e) { flash(e.message, false); }
$('create-btn').disabled = false;
}
async function revokeKey(name) {
if (!confirm('Revoke key "' + name + '"?')) return;
try {
const r = await fetch('/api/keys/' + encodeURIComponent(name), { method: 'DELETE' });
const d = await r.json();
if (d.error) { flash(d.error, false); }
else { flash('Key revoked.', true); load(); }
} catch (e) { flash(e.message, false); }
}
load();
</script>
</body>
</html>"""
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
if exc.status_code == 403:
return JSONResponse({"error": exc.detail}, status_code=403)
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)