From 9d5c68b09359237f7a0380465a0ca8fac31b14f4 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Sat, 22 Aug 2026 17:17:14 -0700 Subject: [PATCH] Track grading spend per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attribution comes from the proxy's basic-auth username, which nginx already forwards. Recorded as a ledger of billed calls rather than a column on the grade: a regrade overwrites the grade row, so a per-row cost would forget what earlier runs cost, and deleting a card would erase that it was ever paid for. Splits server-key spend (money the host actually owes) from own-key spend (billed to the visitor) — the two must never be summed. Attribution only, never authorization: the app is reachable directly on the LAN, so these headers are not trustworthy for access control. --- app.py | 45 ++++++++++++++++++++++++++++ static/app.js | 40 +++++++++++++++++++++++++ static/index.html | 25 ++++++++++++++++ store.py | 76 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+) diff --git a/app.py b/app.py index e8c81a6..a7f9f8f 100644 --- a/app.py +++ b/app.py @@ -234,6 +234,34 @@ class Handler(BaseHTTPRequestHandler): self.close_connection = True self._json({"error": message}, 413) + def _username(self): + """Who the reverse proxy authenticated, or None. + + Two sources, because which one is available depends on the proxy's + config: an explicit X-Auth-User (nginx's $remote_user) if it's been + set, otherwise the Basic credentials nginx forwards upstream by + default. Only the username is ever read — the password half is + discarded without being looked at, since the proxy already validated + it and this app has no business re-checking it. + + For ATTRIBUTION ONLY. Anything on the LAN can reach this app + directly (see docker-compose.yml) and set either header freely, so + this must never gate access to anything — it answers "who ran up + this bill", not "who is allowed in". + """ + header = (self.headers.get("X-Auth-User") or "").strip() + if header: + return header[:64] + auth = (self.headers.get("Authorization") or "").strip() + if auth.lower().startswith("basic "): + try: + decoded = base64.b64decode(auth[6:], validate=True).decode("utf-8", "replace") + except Exception: + return None + name = decoded.split(":", 1)[0].strip() + return name[:64] or None + return None + def _route(self): path = urllib.parse.urlparse(self.path).path # nginx forwards the full "/cards/..." URI through unchanged (same @@ -283,6 +311,10 @@ class Handler(BaseHTTPRequestHandler): return self._json(_public_settings()) if route == "/api/vision-models": return self._json(vision.price_guide()) + if route == "/api/usage": + data = store.usage_by_user() + data["you"] = self._username() + return self._json(data) if route == "/api/history": return self._json({"grades": store.list_grades()}) if route.startswith("/api/history/"): @@ -385,6 +417,10 @@ class Handler(BaseHTTPRequestHandler): ) print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True) + # Surfaced so the caller can log who paid — the whole point of the + # split in the spend ledger. + self._last_used_own_key = bool(caller_key) + return { "image_count": len(images), "closeups": result["closeups"], @@ -433,6 +469,12 @@ class Handler(BaseHTTPRequestHandler): grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"), source_images=images) + # Logged even when save=false: the call was still billed, and a + # ledger that only counted saved cards would under-report spend. + store.log_grade_event(grade, username=self._username(), grade_id=grade_id, + own_key=getattr(self, "_last_used_own_key", False), + kind="grade") + return self._json({"grade": grade, "grade_id": grade_id}, 201) def _regrade_card(self, grade_id, body): @@ -471,6 +513,9 @@ class Handler(BaseHTTPRequestHandler): return self._error(str(exc), 502) thumbnail = _make_thumbnail(images[0][0]) + store.log_grade_event(grade, username=self._username(), grade_id=grade_id, + own_key=getattr(self, "_last_used_own_key", False), + kind="regrade") grade = store.update_grade_result( grade_id, grade, thumbnail=thumbnail, source_images=images if newly_supplied else None) diff --git a/static/app.js b/static/app.js index be45d1c..b4997db 100644 --- a/static/app.js +++ b/static/app.js @@ -301,6 +301,7 @@ async function runGrade() { status.hidden = true; $('#grade-label').value = ''; loadHistory().catch(() => {}); + loadUsage().catch(() => {}); } catch (err) { status.textContent = `Grading failed: ${err.message}`; } finally { @@ -419,6 +420,43 @@ function renderHistory() { `).join(''); } +/* ----------------------------------------------------------------- spend */ + +function money(v) { + const n = Number(v || 0); + // Sub-cent totals are the norm early on; rounding those to $0.00 would + // make the panel look broken rather than cheap. + return n > 0 && n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`; +} + +async function loadUsage() { + let data; + try { + data = await api('/api/usage'); + } catch (_) { + return; // never let this block the rest of the page + } + const users = data.users || []; + const panel = $('#usage-panel'); + panel.hidden = users.length === 0; + if (!users.length) return; + + const t = data.totals || {}; + $('#usage-total').textContent = + `${t.grades || 0} grading call(s) · ${money(t.server_cost).replace('<', 'under ')} on the server key`; + + $('#usage-body').innerHTML = users.map((u) => { + const isYou = data.you && u.username === data.you; + return ` + ${esc(u.username)}${isYou ? ' you' : ''} + ${u.grades} + ${money(u.server_cost)} + ${money(u.own_cost)} + ${esc(fmtWhen(u.last_used))} + `; + }).join(''); +} + function renderEntryModal(g) { $('#entry-modal').innerHTML = `
@@ -473,6 +511,7 @@ async function runRegrade(id, body) { try { const data = await api(`/api/history/${id}/regrade`, { method: 'POST', body }); applyRegradeResult(data.grade); + loadUsage().catch(() => {}); // a regrade is billed too banner('Regraded.'); } catch (err) { banner(`Regrade failed: ${err.message}`, true); @@ -677,6 +716,7 @@ async function load() { try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; } updateModelCostHint(); await loadHistory(); + loadUsage().catch(() => {}); if (!state.settings.server_key_configured && !myApiKey()) { banner('Add your Anthropic API key in Settings before grading a card.'); } diff --git a/static/index.html b/static/index.html index 580165e..4a4183d 100644 --- a/static/index.html +++ b/static/index.html @@ -112,6 +112,31 @@ gets saved here, with the full category breakdown.

+ + + diff --git a/store.py b/store.py index 4a4285a..7abda43 100644 --- a/store.py +++ b/store.py @@ -50,6 +50,29 @@ CREATE TABLE IF NOT EXISTS grades ( ); CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC); + +-- One row per paid vision call, including every regrade. Deliberately NOT a +-- column on `grades`: a regrade overwrites that row, so a per-row cost would +-- silently forget what the earlier runs cost, and deleting a card would erase +-- the fact that it was ever paid for. Spend is a ledger, not a property of the +-- current result. +-- grade_id is only a breadcrumb (no foreign key) — the event must outlive the +-- grade it came from. +CREATE TABLE IF NOT EXISTS grade_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + grade_id INTEGER, -- the row it produced, if it was saved + username TEXT, -- from the proxy's basic auth, or NULL + model TEXT, + input_tokens INTEGER, + output_tokens INTEGER, + cost REAL, + own_key INTEGER NOT NULL DEFAULT 0, -- 1 = caller paid, 0 = server key + kind TEXT -- 'grade' | 'regrade' +); + +CREATE INDEX IF NOT EXISTS idx_events_user ON grade_events(username); +CREATE INDEX IF NOT EXISTS idx_events_created ON grade_events(created_at DESC); """ DEFAULT_SETTINGS = { @@ -270,6 +293,59 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None): return get_grade(grade_id) +def log_grade_event(grade, username=None, grade_id=None, own_key=False, kind="grade"): + """Record one paid vision call. Called for first grades AND regrades.""" + usage = grade.get("usage") or {} + conn = connect() + try: + conn.execute( + "INSERT INTO grade_events " + "(created_at, grade_id, username, model, input_tokens, " + " output_tokens, cost, own_key, kind) " + "VALUES (?,?,?,?,?,?,?,?,?)", + (now(), grade_id, username, usage.get("model"), + usage.get("input_tokens"), usage.get("output_tokens"), + grade.get("estimated_cost"), 1 if own_key else 0, kind), + ) + conn.commit() + finally: + conn.close() + + +def usage_by_user(limit=100): + """Per-user grading spend, most expensive first. + + Splits by whose key paid: `server_cost` is what the host is actually out + of pocket for, `own_cost` is what someone spent on their own key. Only + the first is money the owner of this instance ever sees a bill for, so + the two must never be added together and shown as one number. + """ + conn = connect() + try: + rows = conn.execute( + "SELECT COALESCE(username, '(unknown)') AS username, " + " COUNT(*) AS grades, " + " SUM(CASE WHEN own_key = 0 THEN COALESCE(cost, 0) ELSE 0 END) AS server_cost, " + " SUM(CASE WHEN own_key = 1 THEN COALESCE(cost, 0) ELSE 0 END) AS own_cost, " + " MAX(created_at) AS last_used " + "FROM grade_events GROUP BY COALESCE(username, '(unknown)') " + "ORDER BY server_cost DESC, grades DESC LIMIT ?", + (limit,), + ).fetchall() + totals = conn.execute( + "SELECT COUNT(*) AS grades, " + " SUM(CASE WHEN own_key = 0 THEN COALESCE(cost, 0) ELSE 0 END) AS server_cost, " + " SUM(CASE WHEN own_key = 1 THEN COALESCE(cost, 0) ELSE 0 END) AS own_cost " + "FROM grade_events" + ).fetchone() + finally: + conn.close() + return { + "users": [dict(r) for r in rows], + "totals": dict(totals) if totals else {}, + } + + def prune_source_images(days=None): """Drop stored photos older than the retention window. Returns the count.