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

@ -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 -->