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.
+
+
+
+
Grading spend
+
+
+
+ Counts every grading call, including regrades, and keeps counting
+ after a card is deleted. Only the server-key column is money the owner of this
+ instance actually pays — anything on someone's own key is billed to them.
+
+
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.