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.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)
|
||||||
|
|
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
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);
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue