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:
parent
762b394c8a
commit
9d5c68b093
4 changed files with 186 additions and 0 deletions
45
app.py
45
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)
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
76
store.py
76
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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue