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

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.