What your chats have cost, at each model\'s published rates. Runs on an account you connected yourself are listed too, and never count against a limit here.
' + '' + - 'Could not read the ledger.
'; return; } + renderToday(u); + + var instance = u.instance || { byModel: [], calls: 0, tokens: 0, costUsd: 0 }; + var connected = u.connected || { byModel: [], calls: 0, tokens: 0, costUsd: 0 }; + byId("usageInstanceLabel").textContent = + "On this instance \u00b7 " + money(instance.costUsd) + ", last " + u.days + " days"; + renderTotals(host, instance.byModel || []); + + // The connected side only appears once there is one. An instance where + // nobody has connected anything should not carry an empty room around. + if (connected.calls > 0) { + var label = byId("usageConnectedLabel"); + label.hidden = false; + // Their provider bills them, not us: the dollars here are what the same + // tokens would list at, and a subscription (a ChatGPT account, say) has + // no list price at all. The token count is the honest number. + label.textContent = "On your own accounts \u00b7 " + sideSummary(connected, u.days); + renderTotals(byId("usageConnected"), connected.byModel || []); + } + // Everyone's spending is an admin's business; a 403 here simply means the // section stays away. try { - var res = await fetch("/api/usage?days=30&scope=instance"); + var res = await fetch("/api/usage?days=30&scope=everyone"); if (!res.ok) return; var all = await res.json(); renderTotals(byId("usageByUser"), all.byUser || [], byId("usageEveryoneLabel")); diff --git a/src/http.ts b/src/http.ts index 841ead6..585e6a9 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1179,30 +1179,49 @@ export function apiRoutes(deps: { store: Store; blobs: BlobStore; kick?: () => v /** * What has been spent, and what is left to spend. * - * Yours by default; `?scope=instance` is everyone's, and needs admin. The - * ledger records both payers, so this is also where someone sees that the - * account they connected is the one paying. + * Split by who paid, because the two numbers answer different questions. + * The instance's side is what the operator is buying and what a budget + * bounds; the connected side is what someone's own account covered, which + * nobody rations and which is the whole reward for connecting one. + * + * Yours by default. `?scope=everyone` is the instance's ledger across + * people, and needs admin. */ "/api/usage": { GET: (req: Bun.BunRequest<"/api/usage">) => { const url = new URL(req.url); const sub = whoami(req, store); const role = requestRole(req, store); - const instance = url.searchParams.get("scope") === "instance"; - if (instance) { + const everyone = url.searchParams.get("scope") === "everyone"; + if (everyone) { const denied = requireOwner(req, store); if (denied) return denied; } const days = Math.min(Math.max(Number(url.searchParams.get("days")) || 30, 1), 365); const since = Date.now() - days * DAY_MS; - const scope = instance ? {} : { sub }; + const whose = everyone ? {} : { sub }; + const side = (payer: "instance" | "user") => { + const byModel = store.usageTotals({ since, groupBy: "model", payer, ...whose }); + return { + byModel, + calls: byModel.reduce((n, r) => n + r.calls, 0), + tokens: byModel.reduce((n, r) => n + r.inputTokens + r.outputTokens, 0), + costUsd: byModel.reduce((n, r) => n + r.costUsd, 0), + }; + }; return Response.json({ days, role, ...budgetStatus(store, sub, role), - byModel: store.usageTotals({ since, groupBy: "model", ...scope }), - byDay: store.usageTotals({ since, groupBy: "day", ...scope }), - ...(instance ? { byUser: store.usageTotals({ since, groupBy: "sub" }) } : {}), + instance: side("instance"), + connected: side("user"), + byDay: store.usageTotals({ since, groupBy: "day", ...whose }), + // Only what the instance paid for. What someone's own account covered + // is between them and their provider, and an admin reading this is + // asking about the bill they get. + ...(everyone + ? { byUser: store.usageTotals({ since, groupBy: "sub", payer: "instance" }) } + : {}), }); }, }, diff --git a/tests/roles.test.ts b/tests/roles.test.ts index b20cf9f..b0986c2 100644 --- a/tests/roles.test.ts +++ b/tests/roles.test.ts @@ -594,30 +594,50 @@ test("a guest paying for themselves is not bounded by the instance's budget", as expect(res.status).toBe(202); }); -test("the ledger is yours to read, and everyone's only for an admin", async () => { +test("the ledger is yours to read, split by who paid, and everyone's only for an admin", async () => { configureBudget({ tokensPerDay: 10_000 }); const { base, store } = freshApp(); charge(store, GUEST, 0.25, 4000); charge(store, OWNER, 1, 9000); + // The same guest, on an account they connected themselves. + store.recordUsage({ + ts: Date.now(), + sub: GUEST, + payer: "user", + service: "inference", + providerId: "acme", + modelRef: "acme/spendy", + inputTokens: 80_000, + outputTokens: 20_000, + costUsd: 7, + }); const mine = (await (await fetch(`${base}/api/usage`, { headers: as(store, GUEST) })).json()) as { - ok: boolean; spentUsd: number; spentTokens: number; - byModel: Array<{ key: string; costUsd: number }>; + instance: { costUsd: number; tokens: number; byModel: Array<{ key: string }> }; + connected: { costUsd: number; tokens: number; byModel: Array<{ key: string }> }; byUser?: unknown; }; + // What the instance paid, and what the budget is measured against. expect(mine.spentUsd).toBeCloseTo(0.25, 10); expect(mine.spentTokens).toBe(4000); - expect(mine.byModel[0]?.key).toBe("acme/cheap"); + expect(mine.instance.byModel.map((m) => m.key)).toEqual(["acme/cheap"]); + // Their own account, tracked beside it and counted against nothing. + expect(mine.connected.costUsd).toBeCloseTo(7, 10); + expect(mine.connected.tokens).toBe(100_000); + expect(mine.connected.byModel.map((m) => m.key)).toEqual(["acme/spendy"]); expect(mine.byUser).toBeUndefined(); // nobody else's spending, not even in passing expect( - (await fetch(`${base}/api/usage?scope=instance`, { headers: as(store, GUEST) })).status, + (await fetch(`${base}/api/usage?scope=everyone`, { headers: as(store, GUEST) })).status, ).toBe(403); const all = (await ( - await fetch(`${base}/api/usage?scope=instance`, { headers: as(store, OWNER) }) + await fetch(`${base}/api/usage?scope=everyone`, { headers: as(store, OWNER) }) ).json()) as { byUser: Array<{ key: string; costUsd: number }> }; + // Everyone, but only what this instance paid for — a guest's own account is + // between them and their provider. expect(all.byUser.map((u) => u.key).sort()).toEqual([GUEST, OWNER].sort()); + expect(all.byUser.reduce((n, u) => n + u.costUsd, 0)).toBeCloseTo(1.25, 10); });