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._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)

View file

@ -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() {
</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) {
$('#entry-modal').innerHTML = `
<div class="panel-head">
@ -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.');
}

View file

@ -112,6 +112,31 @@
gets saved here, with the full category breakdown.</p>
</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>
<!-- ----------------------------------------------------------- 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);
-- 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.