Track grading spend per user

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.
This commit is contained in:
Barely Removable 2026-08-22 17:17:14 -07:00
parent 762b394c8a
commit 9d5c68b093
4 changed files with 186 additions and 0 deletions

45
app.py
View file

@ -234,6 +234,34 @@ class Handler(BaseHTTPRequestHandler):
self.close_connection = True self.close_connection = True
self._json({"error": message}, 413) 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): def _route(self):
path = urllib.parse.urlparse(self.path).path path = urllib.parse.urlparse(self.path).path
# nginx forwards the full "/cards/..." URI through unchanged (same # nginx forwards the full "/cards/..." URI through unchanged (same
@ -283,6 +311,10 @@ class Handler(BaseHTTPRequestHandler):
return self._json(_public_settings()) return self._json(_public_settings())
if route == "/api/vision-models": if route == "/api/vision-models":
return self._json(vision.price_guide()) 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": if route == "/api/history":
return self._json({"grades": store.list_grades()}) return self._json({"grades": store.list_grades()})
if route.startswith("/api/history/"): if route.startswith("/api/history/"):
@ -385,6 +417,10 @@ class Handler(BaseHTTPRequestHandler):
) )
print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True) 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 { return {
"image_count": len(images), "image_count": len(images),
"closeups": result["closeups"], "closeups": result["closeups"],
@ -433,6 +469,12 @@ class Handler(BaseHTTPRequestHandler):
grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"), grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"),
source_images=images) 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) return self._json({"grade": grade, "grade_id": grade_id}, 201)
def _regrade_card(self, grade_id, body): def _regrade_card(self, grade_id, body):
@ -471,6 +513,9 @@ class Handler(BaseHTTPRequestHandler):
return self._error(str(exc), 502) return self._error(str(exc), 502)
thumbnail = _make_thumbnail(images[0][0]) 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 = store.update_grade_result(
grade_id, grade, thumbnail=thumbnail, grade_id, grade, thumbnail=thumbnail,
source_images=images if newly_supplied else None) source_images=images if newly_supplied else None)

View file

@ -301,6 +301,7 @@ async function runGrade() {
status.hidden = true; status.hidden = true;
$('#grade-label').value = ''; $('#grade-label').value = '';
loadHistory().catch(() => {}); loadHistory().catch(() => {});
loadUsage().catch(() => {});
} catch (err) { } catch (err) {
status.textContent = `Grading failed: ${err.message}`; status.textContent = `Grading failed: ${err.message}`;
} finally { } finally {
@ -419,6 +420,43 @@ function renderHistory() {
</tr>`).join(''); </tr>`).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 `<tr>
<td class="cardcell-name">${esc(u.username)}${isYou ? ' <span class="pill pill-raw">you</span>' : ''}</td>
<td class="num">${u.grades}</td>
<td class="num">${money(u.server_cost)}</td>
<td class="num cardcell-meta">${money(u.own_cost)}</td>
<td class="cardcell-meta cell-when">${esc(fmtWhen(u.last_used))}</td>
</tr>`;
}).join('');
}
function renderEntryModal(g) { function renderEntryModal(g) {
$('#entry-modal').innerHTML = ` $('#entry-modal').innerHTML = `
<div class="panel-head"> <div class="panel-head">
@ -473,6 +511,7 @@ async function runRegrade(id, body) {
try { try {
const data = await api(`/api/history/${id}/regrade`, { method: 'POST', body }); const data = await api(`/api/history/${id}/regrade`, { method: 'POST', body });
applyRegradeResult(data.grade); applyRegradeResult(data.grade);
loadUsage().catch(() => {}); // a regrade is billed too
banner('Regraded.'); banner('Regraded.');
} catch (err) { } catch (err) {
banner(`Regrade failed: ${err.message}`, true); banner(`Regrade failed: ${err.message}`, true);
@ -677,6 +716,7 @@ async function load() {
try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; } try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; }
updateModelCostHint(); updateModelCostHint();
await loadHistory(); await loadHistory();
loadUsage().catch(() => {});
if (!state.settings.server_key_configured && !myApiKey()) { if (!state.settings.server_key_configured && !myApiKey()) {
banner('Add your Anthropic API key in Settings before grading a card.'); banner('Add your Anthropic API key in Settings before grading a card.');
} }

View file

@ -112,6 +112,31 @@
gets saved here, with the full category breakdown.</p> gets saved here, with the full category breakdown.</p>
</section> </section>
<!-- -------------------------------------------------------- spend -->
<section class="panel" id="usage-panel" hidden>
<div class="panel-head">
<h2>Grading spend</h2>
<span class="hint" id="usage-total"></span>
</div>
<div class="table-scroll">
<table class="grid" id="usage-table">
<thead>
<tr>
<th>Who</th>
<th class="num">Grades</th>
<th class="num">On the server key</th>
<th class="num">On their own key</th>
<th>Last graded</th>
</tr>
</thead>
<tbody id="usage-body"></tbody>
</table>
</div>
<div class="note">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.</div>
</section>
</main> </main>
<!-- ----------------------------------------------------------- settings --> <!-- ----------------------------------------------------------- settings -->

View file

@ -50,6 +50,29 @@ CREATE TABLE IF NOT EXISTS grades (
); );
CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC); 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 = { DEFAULT_SETTINGS = {
@ -270,6 +293,59 @@ def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
return get_grade(grade_id) 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): def prune_source_images(days=None):
"""Drop stored photos older than the retention window. Returns the count. """Drop stored photos older than the retention window. Returns the count.