Record what every model call spends, and show it

Nothing anywhere recorded what a model call cost: the harness used a
provider's usage numbers only to decide whether a turn had finished, then
dropped them. So a reply could not say what it took to make, one model
could not be compared with another on price, and there was no way to ask
what a day, a week or a month of the crew's work came to.

- `token_usages` records one row per call: user, bot, workspace, session,
  job and message, the harness, the provider and model that actually
  answered, the prompt and completion counts, and the cost when the
  provider reports one.
- `harness-usage-scope.ts` carries those ids in an AsyncLocalStorage
  scope that `run-bot-job.ts` enters once, so a run's turns, its session
  summary, its transcriptions, images and embeddings are all attributed
  without threading ids through every harness function. Nested runs merge
  rather than replace, and a null genuinely clears a field (a bot's own
  chat has no workspace).
- Work with no run behind it is charged to its owner instead:
  `run-with-fallback-scope.ts` reaches the workspace's creator else the
  bot's, and deliberately does not override a real run, so an ingest a
  bot asked for is not misattributed to the bot's owner.
- All six harnesses record through `withCandidates`'s `on_success`.
  Recording never throws: a model that answered must not lose its answer
  to a failed insert.
- A job's calls land before its message exists, so `complete-bot-job.ts`
  back-fills `message_id` once the message is inserted.
- The message's actions row shows what it cost, beside the timestamp and
  the play button, with the breakdown in its title.
- `/admin/usage` totals the install's spending for 7, 30 or 90 days: the
  total against the previous period, a day-by-day chart, a split per kind
  of call, and a table per model with the price per thousand tokens
  worked out from cost and tokens, which is what makes two models
  comparable. Crew managers only, since it is the whole install's work
  rather than one user's conversation. It sits with Routines and Activity
  in the user popover rather than under Settings, because it is a record
  of what happened, not something to configure.

Days are grouped in local time (`date(created_at / 1000, 'unixepoch',
'localtime')`), matching the local midnights the window builds and the
`YYYY-MM-DD` buckets it looks up. Grouping by the bare UTC day put a late
evening's calls under a name no bucket carried, so they vanished and the
chart's total silently disagreed with the totals card; a test pins it.

`canSeeRailItem` now decides who sees a popover or rail entry. The two
each filtered with their own inline rule and only one understood
`can_view`, so a gated page could be offered in the popover to everyone.
This commit is contained in:
2026-10-23 15:41:41 +01:00
parent 0048324d8a
commit d9aea8e9c8
75 changed files with 2916 additions and 66 deletions
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import formatTokens from "./format-tokens";
describe("formatTokens", () => {
test("counts under a thousand are exact", () => {
expect(formatTokens({ tokens: 0 })).toBe("0");
expect(formatTokens({ tokens: 842 })).toBe("842");
expect(formatTokens({ tokens: 999 })).toBe("999");
});
test("counts under ten thousand keep a decimal, without a trailing zero", () => {
expect(formatTokens({ tokens: 1000 })).toBe("1K");
expect(formatTokens({ tokens: 1240 })).toBe("1.2K");
expect(formatTokens({ tokens: 1240.7 })).toBe("1.2K");
expect(formatTokens({ tokens: 9999 })).toBe("10K");
});
test("counts under a million are whole thousands", () => {
expect(formatTokens({ tokens: 12_400 })).toBe("12K");
expect(formatTokens({ tokens: 841_600 })).toBe("842K");
expect(formatTokens({ tokens: 999_999 })).toBe("1000K");
});
test("counts past a million are millions", () => {
expect(formatTokens({ tokens: 1_000_000 })).toBe("1M");
expect(formatTokens({ tokens: 1_240_000 })).toBe("1.2M");
expect(formatTokens({ tokens: 24_800_000 })).toBe("24.8M");
});
test("a missing count reads as nothing spent", () => {
expect(formatTokens({ tokens: null })).toBe("0");
expect(formatTokens({ tokens: undefined })).toBe("0");
expect(formatTokens({ tokens: Number.NaN })).toBe("0");
});
});
+19
View File
@@ -0,0 +1,19 @@
type Params = {
tokens: number | null | undefined;
};
/**
* A token count as a person reads it: exact up to a thousand, then
* rounded to a decimal under ten thousand ("12.4K"), whole thousands up to
* a million ("842K") and millions after it ("1.2M"). A long run's total is
* never a wall of digits.
*/
export default function formatTokens({ tokens }: Params) {
const count = Number(tokens) || 0;
if (count < 1000) return String(count);
if (count < 10_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}K`;
if (count < 1_000_000) return `${Math.round(count / 1000)}K`;
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test";
import canSeeRailItem from "./can-see-rail-item";
import canManageCrew from "./can-manage-crew";
import canListUsers from "./can-list-users";
import { RailPages, UsagePath } from "@/src/dict/rail-pages-dict";
function types(...user_types: string[]) {
return user_types.map((user_type) => ({ user_type })) as never;
}
const super_admin = types("super_admin");
const admin = types("member", "admin");
const member = types("member");
const read_only = types("read_only");
describe("canSeeRailItem", () => {
test("a page anyone may open shows to everyone", () => {
const page = {};
expect(canSeeRailItem({ item: page, is_super_admin: true, user_types: super_admin })).toBe(true);
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: member })).toBe(true);
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: read_only })).toBe(true);
});
test("a super admin page shows to super admins only", () => {
const page = { super_admin_only: true };
expect(canSeeRailItem({ item: page, is_super_admin: true, user_types: super_admin })).toBe(true);
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: admin })).toBe(false);
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: member })).toBe(false);
});
test("an RBAC gate is asked, not guessed from super admin", () => {
const page = { can_view: canManageCrew };
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: super_admin })).toBe(true);
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: admin })).toBe(true);
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: member })).toBe(false);
});
test("a can_view rule wins over super_admin_only, so a gate is never widened by it", () => {
/**
* The two together should follow the narrower rule the code asked
* for. No page sets both today, and this keeps a future one from
* quietly showing to every super admin when its rule says no.
*/
const page = { can_view: canListUsers, super_admin_only: true };
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: member })).toBe(false);
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: read_only })).toBe(false);
expect(canSeeRailItem({ item: page, is_super_admin: false, user_types: admin })).toBe(true);
});
});
describe("RailPages", () => {
test("usage is a rail page, gated to the crew's managers", () => {
const usage = RailPages.find((page) => page.url == UsagePath);
expect(usage?.title).toBe("Usage");
expect(usage?.can_view).toBeDefined();
expect(usage?.super_admin_only).toBeFalsy();
});
test("activity stays super admin only", () => {
const activity = RailPages.find((page) => page.url == "/admin/logs");
expect(activity?.super_admin_only).toBe(true);
});
test("a member is offered routines, files and help but not usage or activity", () => {
const shown = RailPages.filter((page) =>
canSeeRailItem({ item: page, is_super_admin: false, user_types: member }),
).map((page) => page.title);
expect(shown).toEqual(["Routines", "Files", "Help"]);
});
test("an admin is offered usage but still not activity", () => {
const shown = RailPages.filter((page) =>
canSeeRailItem({ item: page, is_super_admin: false, user_types: admin }),
).map((page) => page.title);
expect(shown).toEqual(["Routines", "Files", "Usage", "Help"]);
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
type Params = {
item: {
/**
* Who sees it, when that's an RBAC rule rather than super admins only.
*/
can_view?: (params: {
user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null;
}) => boolean;
super_admin_only?: boolean;
};
is_super_admin: boolean;
user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null;
};
/**
* Whether a rail destination (a settings sub page, a popover page) shows
* to this user: its own RBAC rule when it has one, else super admins only
* when it's a super admin page, else everyone.
*
* Shared by the rail lists and the user popover, so a page gated in one is
* never offered in the other.
*/
export default function canSeeRailItem({ item, is_super_admin, user_types }: Params): boolean {
if (item.can_view) return item.can_view({ user_types });
return is_super_admin || !item.super_admin_only;
}