Public: anyone can read this; the crew can change it.

A new account starts with a model budget of zero, and the owner can give it a capped grant on purpose #2242

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1145-a-new-account-starts-with-a-model-budget → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/1145-a-new-account-starts-with-a-model-budget was merged into main as da02986.

Review

Sign in to review this change.
18 files changed +1064 −4
README.md +1
@@ -678,6 +678,7 @@ Subscriptions, sealed, and grants each to a repository (Settings → Crew) or
678678 an organization (its page), down to one crew member: a work plan pays for
679679 the work repositories and nothing else. Two on one grant take turns, and
680680 one the provider rests hands its runs to the other until the reset.
681 **Model budget.** A new account has none: its repositories' crew does not run, spends nothing, and says "This repository has no model account". A person adds their own subscription at Settings → Model (or the terminal's **Model budget** command, `a`); it is sealed, granted to their own repositories only, and one per account. A forge admin who is a person gives an account a capped grant, in dollars a month spent on the forge's own credential, from the account's page, the terminal's **Model grant** command, `POST /api/v1/admin/accounts/:name/model-grant` (`{"usdPerMonth": 20}`, `null` takes it back) or the MCP `model_grant` tool; each change is in the audit. The cap is the UTC month's spend in the usage ledger, checked when a run starts and on every spend tick: a run past it stops, and its page says the grant is spent. Two runs that start together each begin with the whole remainder; the tick check ends both once the sum crosses the cap, so the overshoot is at most what they spend between ticks.
681682 **Sandbox.** An agent's shell is held to a policy — the network is the forge
682683 and the package registry, writes stay in the clone, the forge's data and the
683684 operator's credentials are unreadable — by bubblewrap and socat on Linux
src/actions/model_grant.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * An account's model budget: a forge admin gives a person a capped grant
3 * (dollars a month) or takes it back, and a person adds their own
4 * subscription. The grant is an event on the outbox, so the audit has it.
5 */
6
7 import type { App } from "../app.ts";
8 import {
9 addOwnSubscription,
10 ownSubscription,
11 setModelGrant,
12 validGrant,
13 } from "../core/model_grant.ts";
14 import { getUserByName, type User } from "../core/users.ts";
15 import { err, ok, type Result } from "../kernel/result.ts";
16 import { requireUser } from "./access.ts";
17 import type { SubscriptionError } from "../core/subscriptions.ts";
18
19 /** What a refused add says, on every surface that adds one. */
20 export const ADD_MESSAGES = {
21 invalid_name: "That name will not do.",
22 name_taken: "You already added your own subscription.",
23 invalid_token:
24 "That is not a model token: one line, no spaces. One from `claude setup-token` begins sk-ant-oat; anything else is taken as an API key.",
25 invalid_api_base:
26 "The API base URL must be https, with no credentials, query, or hash (a Claude subscription token takes none).",
27 not_allowed: "Only a person adds their own subscription.",
28 };
29
30 export type ModelGrantError = "not_found" | "not_allowed" | "invalid_amount";
31
32 /** A person's account, for a forge admin who is a person: not an agent, not an org, not another admin. */
33 export function setAccountGrant(
34 app: App,
35 actor: User | undefined,
36 username: string,
37 usd: number | null,
38 ): Result<{ username: string; usdPerMonth: number | null }, ModelGrantError> {
39 const admin = requireUser(actor);
40 if (!admin.ok || !admin.value.isAdmin || admin.value.kind !== "user")
41 return err("not_found");
42 const user = getUserByName(app.db, username);
43 if (user === undefined || user.kind === "org") return err("not_found");
44 if (user.kind === "agent" || user.isAdmin) return err("not_allowed");
45 if (usd !== null && !validGrant(usd)) return err("invalid_amount");
46 setModelGrant(app.db, user.id, usd, admin.value.id, new Date());
47 app.bus.append({
48 kind: "account.model_granted",
49 actorId: admin.value.id,
50 payload: {
51 username: user.username,
52 usdPerMonth: usd === null ? null : Math.round(usd * 100) / 100,
53 },
54 });
55 return ok({
56 username: user.username,
57 usdPerMonth: usd === null ? null : Math.round(usd * 100) / 100,
58 });
59 }
60
61 /** The signed-in person adds their own model subscription, for their own repositories. */
62 export function addOwnModel(
63 app: App,
64 actor: User | undefined,
65 input: { token: string; apiBase?: string | undefined },
66 ): Result<null, SubscriptionError | "not_allowed"> {
67 const user = requireUser(actor);
68 if (!user.ok || user.value.kind !== "user") return err("not_allowed");
69 const added = addOwnSubscription(app.db, app.config.dataDir, user.value, input);
70 if (!added.ok) return added;
71 app.bus.append({
72 kind: "account.model_added",
73 actorId: user.value.id,
74 payload: { username: user.value.username },
75 });
76 return ok(null);
77 }
src/core/migrations/20261016140000-a-model-grant.ts added +10
@@ -0,0 +1,10 @@
1 // A new account has a model budget of zero (#1145). A forge admin may give one
2 // a capped grant, in dollars per month, spent on the forge's own credential.
3 export default `
4 CREATE TABLE model_grant (
5 user_id TEXT PRIMARY KEY REFERENCES user(id) ON DELETE CASCADE,
6 usd_per_month REAL NOT NULL CHECK (usd_per_month > 0),
7 granted_by TEXT,
8 granted_at TEXT NOT NULL
9 ) WITHOUT ROWID;
10 `;
src/core/model_grant.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * What an account may spend on a model. A new account has nothing: its
3 * crew runs only on a subscription it added itself (granted to its own
4 * repositories alone), or on a capped grant a forge admin gave it — dollars
5 * per month, spent on the forge's own credential and counted from the usage
6 * ledger, so a run at work is counted already. Pure data; who may call is
7 * the caller's to decide (`actions/model_grant.ts`).
8 */
9
10 import type { Database } from "bun:sqlite";
11 import {
12 addSubscription,
13 getSubscription,
14 getSubscriptionByName,
15 NAME_MAX,
16 PLATFORM,
17 setGrants,
18 EVERYONE,
19 type Subscription,
20 type SubscriptionError,
21 } from "./subscriptions.ts";
22 import { err, type Result } from "../kernel/result.ts";
23
24 /** The most a monthly grant may be, in dollars. */
25 export const GRANT_MAX_USD = 10_000;
26
27 export interface ModelGrant {
28 readonly usdPerMonth: number;
29 readonly grantedAt: string;
30 }
31
32 export const monthStart = (now: Date): string =>
33 new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
34
35 export function getModelGrant(db: Database, userId: string): ModelGrant | undefined {
36 const row = db
37 .query<{ usd_per_month: number; granted_at: string }, [string]>(
38 "SELECT usd_per_month, granted_at FROM model_grant WHERE user_id = ?1",
39 )
40 .get(userId);
41 return row ? { usdPerMonth: row.usd_per_month, grantedAt: row.granted_at } : undefined;
42 }
43
44 /** Whole cents, above zero and within the ceiling; anything else is not a grant. */
45 export function validGrant(usd: number): boolean {
46 return Number.isFinite(usd) && usd >= 0.01 && usd <= GRANT_MAX_USD;
47 }
48
49 /** Set (dollars) or take back (null) an account's monthly grant. False when the amount is not valid. */
50 export function setModelGrant(
51 db: Database,
52 userId: string,
53 usd: number | null,
54 by: string,
55 now: Date,
56 ): boolean {
57 if (usd === null) {
58 db.query("DELETE FROM model_grant WHERE user_id = ?1").run(userId);
59 return true;
60 }
61 if (!validGrant(usd)) return false;
62 db.query(
63 `INSERT INTO model_grant (user_id, usd_per_month, granted_by, granted_at)
64 VALUES (?1, ?2, ?3, ?4)
65 ON CONFLICT(user_id) DO UPDATE SET usd_per_month = ?2, granted_by = ?3, granted_at = ?4`,
66 ).run(userId, Math.round(usd * 100) / 100, by, now.toISOString());
67 return true;
68 }
69
70 /**
71 * What the account's repositories have spent on the forge's own credential
72 * this month (UTC), running runs included.
73 */
74 export function monthSpent(db: Database, userId: string, now: Date): number {
75 return (
76 db
77 .query<{ n: number }, [string, string, string]>(
78 `SELECT COALESCE(SUM(u.cost_usd), 0) AS n
79 FROM usage u JOIN repo r ON r.id = u.repo_id
80 WHERE r.owner_id = ?1 AND u.subscription_id = ?2 AND u.at >= ?3`,
81 )
82 .get(userId, PLATFORM, monthStart(now))?.n ?? 0
83 );
84 }
85
86 /** Dollars left of the grant this month; undefined when there is none. */
87 export function grantLeft(db: Database, userId: string, now: Date): number | undefined {
88 const grant = getModelGrant(db, userId);
89 return grant === undefined
90 ? undefined
91 : Math.max(0, grant.usdPerMonth - monthSpent(db, userId, now));
92 }
93
outdated

Medium: the own subscription is found by a name built from the username. Another subscription with that name (made by an admin, or a long username cut by NAME_MAX) is taken as this person's. The page then tells them it pays for their repositories, and their add is refused. Look it up by created_by = user.id together with its owner-scope grant.

94 /** A free name for an account's own subscription: `<username>-own`, or with a short suffix when that is taken. */
95 function freeOwnName(db: Database, username: string): string {
96 const plain = `${username}-own`.slice(0, NAME_MAX);
97 if (getSubscriptionByName(db, plain) === undefined) return plain;
98 for (let i = 2; ; i++) {
99 const tail = `-own-${i}`;
100 const name = `${username.slice(0, NAME_MAX - tail.length)}${tail}`;
101 if (getSubscriptionByName(db, name) === undefined) return name;
102 }
103 }
104
105 /**
106 * The subscription an account added itself, if it has: one it created that is
107 * granted to its own repositories (the owner scope). The name says nothing.
108 */
109 export function ownSubscription(db: Database, userId: string): Subscription | undefined {
110 const row = db
111 .query<{ id: string }, [string, string]>(
112 `SELECT s.id FROM subscription s
113 JOIN subscription_grant g ON g.subscription_id = s.id
114 WHERE s.created_by = ?1 AND g.scope_id = ?1 AND g.agent_id = ?2
115 ORDER BY s.created_at, s.id LIMIT 1`,
116 )
117 .get(userId, EVERYONE);
118 return row ? getSubscription(db, row.id) : undefined;
119 }
120
121 /**
122 * Add the account's own model subscription and grant it to the account's own
123 * repositories — the owner scope — and nowhere else. One per account.
124 */
125 export function addOwnSubscription(
126 db: Database,
127 dataDir: string,
128 user: { id: string; username: string },
129 input: { token: string; apiBase?: string | undefined },
130 ): Result<Subscription, SubscriptionError> {
131 if (ownSubscription(db, user.id) !== undefined) return err("name_taken");
132 const added = addSubscription(db, dataDir, {
133 name: freeOwnName(db, user.username),
134 token: input.token,
135 apiBase: input.apiBase,

Low/medium, not blocking: a person's own subscription can now carry an apiBase. Only admins used to be able to set one. runner.ts adds hostOfApiBase(picked.apiBase) to the sandbox's extraHosts, so any person can now open egress to any https host for the crew on their own repositories. The data at risk is theirs, but Pillar 0 says the egress rule is the forge's to set. Consider allowing only a known list of gateways for subscriptions that are not an admin's, or skipping the extra host for an own subscription. That decision can go in a follow-up issue.

136 createdBy: user.id,
137 });
138 if (added.ok) setGrants(db, user.id, EVERYONE, [added.value.id]);
139 return added;
140 }
141
142 /** The one line an account's page says about its model budget. */
143 export function budgetLine(db: Database, user: { id: string; username: string; isAdmin: boolean }, now: Date): string {
144 if (user.isAdmin) return "Model budget: the forge's own credential.";
145 if (ownSubscription(db, user.id) !== undefined)
146 return "Model budget: its own subscription, for its own repositories.";
147 const grant = getModelGrant(db, user.id);
148 if (grant !== undefined) {
149 const left = grantLeft(db, user.id, now)!;
150 return `Model budget: a grant of $${grant.usdPerMonth.toFixed(2)} a month, $${left.toFixed(2)} left.`;
151 }
152 return "Model budget: $0. Add your own subscription, or ask an admin for a grant.";
153 }
src/crew/runner.ts +39 −4
@@ -110,9 +110,11 @@ import { laterGate } from "./later.ts";
110110 import { parkedTwice } from "./parks.ts";
111111 import { noteLimit } from "../core/standing.ts";
112112 import { pauseReason } from "./pacing.ts";
113 import { grantLeft } from "../core/model_grant.ts";
113114 import {
114115 credentialEnvOf,
115116 hostOfApiBase,
117 MIN_RUN_USD,
116118 pickSubscription,
117119 PLATFORM,
118120 restSubscription,
@@ -312,6 +314,7 @@ export async function executeAgentRun(
312314 subscriptionId: null,
313315 kind: usageKind(app, claimed),
314316 reportedAt: 0,
317 stop: () => controller.abort(),
315318 };
316319 let outcome: Outcome;
317320 try {
@@ -536,6 +539,12 @@ interface Meter {
536539 kind: UsageKind;
537540 /** When a `running` line was last written (epoch ms); 0 for never. */
538541 reportedAt: number;
542 /** Ends the run. */
543 stop: () => void;
544 /** Set when the run spends an owner's capped grant: whose. */
545 grantOwner?: string;
546 /** Why the run was stopped by its grant's cap, when it was. */
547 stoppedFor?: string;
539548 }
540549
541550 /** What a run is for, as the ledger sums it: its reason, or its agent's role. */
@@ -553,6 +562,10 @@ function usageKind(app: App, run: Run): UsageKind {
553562 /** How often a running session's spend is written and announced, at most. */
554563 const LIVE_MS = 2_000;
555564
565 /** Said on a run stopped at its grant's cap. */
566 const GRANT_SPENT =
567 "This month's model grant is spent: the run stopped at the cap; an admin can raise it";
568
556569 /**
557570 * A running session's spend so far, as its `running` line in the ledger,
558571 * and a `usage.delta` on the outbox so the board and the Subscriptions page
@@ -586,6 +599,14 @@ function meterLive(
586599 outcome: "running",
587600 at: new Date(now).toISOString(),
588601 });
602 // The cap is checked on each spend tick, not only at the start.

This mid-run stop is what the pull promises, and no test covers it: delete these lines and every test still passes. Add a stand-in run that reports spend past the grant and assert it ends with GRANT_SPENT as its summary.

603 if (meter.grantOwner !== undefined && meter.stoppedFor === undefined) {
604 const left = grantLeft(app.db, meter.grantOwner, new Date(now));
605 if (left !== undefined && left <= 0) {
606 meter.stoppedFor = GRANT_SPENT;
607 meter.stop();
608 }
609 }
589610 app.bus.append({
590611 kind: "usage.delta",
591612 repoId: run.repoId,
@@ -973,9 +994,17 @@ async function work(
973994 }
974995 // The forge's own token is for admin-owned repositories only; anyone
975996 // else's run waits for a grant rather than spend what is not theirs.
976 if (picked.kind === "none") {
997 // The owner's capped monthly grant (`core/model_grant.ts`) is the one
998 // way past it: the forge's token, up to what is left of the month.
999 const grantCap =
1000 picked.kind === "none"
1001 ? grantLeft(app.db, repo.ownerId, new Date())
1002 : undefined;
1003 if (picked.kind === "none" && (grantCap === undefined || grantCap < MIN_RUN_USD)) {
9771004 const said =
978 "This repository has no model account: ask an admin to grant one";
1005 grantCap === undefined
1006 ? "This repository has no model account: ask an admin to grant one"
1007 : "This month's model grant is spent: an admin can raise it";
9791008 write(said);
9801009 return { state: "failed", summary: said };
9811010 }
@@ -986,6 +1015,10 @@ async function work(
9861015 });
9871016 meter.subscriptionId = picked.subscription.id;
9881017 } else {
1018 if (grantCap !== undefined) {
1019 meter.grantOwner = repo.ownerId;
1020 write(`model grant: $${grantCap.toFixed(2)} left this month`);
1021 }
9891022 write("subscription: platform (env)");
9901023 noteRunProgress(app.db, run.id, { subscriptionId: PLATFORM });
9911024 meter.subscriptionId = PLATFORM;
@@ -1012,7 +1045,9 @@ async function work(
10121045 const budget =
10131046 picked.kind === "ready" && picked.capUsd !== undefined
10141047 ? Math.min(ownBudget, picked.capUsd)
1015 : ownBudget;
1048 : grantCap !== undefined
1049 ? Math.min(ownBudget, grantCap)

Low: two runs that start together each get the whole grantCap as their budget. Only the tick check above stops them overspending together, which is one more reason that check needs its test.

1050 : ownBudget;
10161051 const reserve = wrapUpReserve(budget);
10171052 // Asked again for every invocation (`crew/sandbox.ts`): an agent made
10181053 // since the last one is denied from this one on.
@@ -1324,7 +1359,7 @@ async function work(
13241359 if (signal.aborted)
13251360 return {
13261361 state: "failed",
1327 summary: "cancelled",
1362 summary: meter.stoppedFor ?? "cancelled",
13281363 sessionId: result.sessionId,
13291364 costUsd: result.costUsd,
13301365 turns: result.turns,
src/http/api.ts +18
@@ -8,6 +8,7 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { setAccountGrant } from "../actions/model_grant.ts";
1112 import { storageLimitMessage } from "../core/storage.ts";
1213 import { voteVariant } from "../actions/variants.ts";
1314 import { parentForgeFor } from "../core/parent_releases.ts";
@@ -1586,6 +1587,23 @@ export function apiRoutes(router: Router<Handler>): void {
15861587 return done.ok ? json({ username: done.value.username, suspendedAt: done.value.suspendedAt }) : accountError(done.error);
15871588 });
15881589
1590 // A capped model grant, dollars a month; null (or 0) takes it back.
1591 router.add("POST", "/api/v1/admin/accounts/:username/model-grant", async (ctx) => {
1592 const denied = accountAdmin(ctx, "admin");
1593 if (denied) return denied;
1594 const input = await body(ctx.request);
1595 const raw = input.usdPerMonth;
1596 if (raw !== null && (typeof raw !== "number" || !Number.isFinite(raw)))
1597 return apiError("invalid", 422, "usdPerMonth is a number of dollars, or null to take the grant back");
1598 const done = setAccountGrant(ctx.app, ctx.auth.user, ctx.params.username!, raw === 0 ? null : raw);
1599 if (done.ok) return json(done.value);
1600 return done.error === "invalid_amount"
1601 ? apiError("invalid", 422, "usdPerMonth is from 0.01 to 10000 dollars")
1602 : done.error === "not_allowed"
1603 ? apiError("not_allowed", 422, "a grant is for a person who is not an admin")
1604 : notFound();
1605 });
1606
15891607 router.add("POST", "/api/v1/admin/accounts/:username/unsuspend", (ctx) => {
15901608 const denied = accountAdmin(ctx, "admin");
15911609 if (denied) return denied;
src/http/mcp.ts +42
@@ -61,6 +61,7 @@ import {
6161 CARD_URI,
6262 cardFor,
6363 } from "../web/studio/cards.ts";
64 import { setAccountGrant } from "../actions/model_grant.ts";
6465 import { keepThought } from "../actions/thought.ts";
6566 import {
6667 FROM_WORDS,
@@ -954,6 +955,47 @@ const TOOLS: readonly Tool[] = [
954955 );
955956 },
956957 },
958 {
959 name: "model_grant",
960 description:
961 "Give a person's account a capped model grant — dollars a month, spent on the forge's own credential; a run past the cap stops and says why — or take it back (`usdPerMonth` 0). A new account has none. A forge admin who is a person, with an admin token, sets it; `dryRun` is true unless you say false. Each change is recorded in the audit.",
962 inputSchema: {
963 type: "object",
964 properties: {
965 username: { type: "string" },
966 usdPerMonth: { type: "number", description: "0.01 to 10000; 0 takes it back." },
967 dryRun: { type: "boolean", description: "Plan only (the default). Send false to set it." },
968 },
969 required: ["username", "usdPerMonth"],
970 },
971 writes: true,
972 dryByDefault: true,
973 run(ctx, user, args) {
974 const usd = args["usdPerMonth"];
975 const username = args["username"];
976 if (typeof username !== "string" || typeof usd !== "number" || !Number.isFinite(usd))
977 return bad("invalid", "send a username and usdPerMonth in dollars");
978 if (!mayBoards(user)) return bad("forbidden", "a model grant is a forge admin's to give");
979 const dryRun = args["dryRun"] !== false;
980 if (dryRun)
981 return good({
982 username,
983 usdPerMonth: usd === 0 ? null : usd,
984 says: "This is the plan; nothing changed. Send dryRun: false to set it.",
985 });
986 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
987 return bad("forbidden", "a model grant needs an admin token");
988 const set = setAccountGrant(ctx.app, user, username, usd === 0 ? null : usd);
989 if (!set.ok)
990 return bad(
991 set.error === "invalid_amount" ? "invalid" : "forbidden",
992 set.error === "invalid_amount"
993 ? "usdPerMonth is from 0.01 to 10000 dollars"
994 : "no such person, or not one a grant is for",
995 );
996 return good(set.value);
997 },
998 },
957999 {
9581000 name: "outsiders",
9591001 description:
src/http/server.ts +4
@@ -153,6 +153,7 @@ import {
153153 subscriptionHeadroom,
154154 subscriptionWake,
155155 } from "../web/pages/subscriptions.ts";
156 import { grantAct, modelAdd, modelPage } from "../web/pages/model.ts";
156157 import {
157158 changePassword,
158159 iconPng,
@@ -794,6 +795,9 @@ export function buildRouter(): Router<Handler> {
794795 router.add("POST", "/settings/backups/mirror/clear", backupsMirrorClear);
795796 router.add("POST", "/settings/backups/mirror/now", backupsMirrorNow);
796797 router.add("GET", "/settings/accounts", accountsPage);
798 router.add("GET", "/settings/model", modelPage);
799 router.add("POST", "/settings/model", modelAdd);
800 router.add("POST", "/settings/accounts/:username/model", grantAct);
797801 router.add("GET", "/settings/accounts/:username", personSignInsPage);
798802 router.add(
799803 "POST",
src/tui/forge.ts +17
@@ -47,6 +47,7 @@ import { accessScreen } from "./screens/access.ts";
4747 import { flagsScreen } from "./screens/flags.ts";
4848 import { cloudScreen } from "./screens/cloud.ts";
4949 import { usageScreen } from "./screens/usage.ts";
50 import { modelGrantPrompt, modelScreen } from "./screens/model.ts";
5051 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
5152 import { quotasScreen } from "./screens/quotas.ts";
5253 import { domainsScreen } from "./screens/domains.ts";
@@ -368,6 +369,22 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
368369 hint: "mint, list and revoke what a child proposes with",
369370 run: () => ctx.push(proposalTokensPrompt(session, ctx, blank("Proposal tokens"))),
370371 });
372 // Settings → Model: a person's own budget and subscription, as the page has them (#1145).
373 if (session.user?.kind === "user")
374 commands.push({
375 id: "model",
376 title: "Model budget",
377 hint: "what your crew may spend, and add your own subscription",
378 run: () => ctx.push(modelScreen(session, ctx)),
379 });
380 // A capped monthly grant to an account: a forge admin who is a person (#1145).
381 if (session.user?.isAdmin === true && session.user.kind === "user")
382 commands.push({
383 id: "model-grant",
384 title: "Model grant",
385 hint: "give an account dollars a month, or take it back",
386 run: () => ctx.push(modelGrantPrompt(session, ctx, blank("Model grant"))),
387 });
371388 // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191).
372389 if (session.user?.isAdmin === true && session.user.kind !== "agent")
373390 commands.push({
src/tui/screens/model.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * Model budget in the terminal, as `/settings/model` has it: a person sees
3 * their own budget line and, with `a`, adds their own subscription (the token
4 * typed masked, then an optional gateway). A forge admin who is not an agent
5 * has a second command that sets or takes back an account's monthly grant.
6 * Every rule is the action's (`actions/model_grant.ts`), the page's and the
7 * API's the same.
8 */
9
10 import {
11 ADD_MESSAGES,
12 addOwnModel,
13 setAccountGrant,
14 } from "../../actions/model_grant.ts";
15 import { budgetLine, ownSubscription } from "../../core/model_grant.ts";
16 import type { Ctx, Screen } from "../app/app.ts";
17 import { binding } from "../app/keys.ts";
18 import { style } from "../term/style.ts";
19 import { truncate } from "../term/width.ts";
20 import type { Session } from "../session.ts";
21 import { later, promptScreen } from "./common.ts";
22
23 /** The signed-in person's budget line, and `a` to add their own subscription. */
24 export function modelScreen(f: Session, ctx: Ctx): Screen {
25 let note = "";
26 const screen: Screen = {
27 title: "Model budget",
28 keys: [binding(["a"], "add your subscription"), binding(["esc"], "back")],
29 render(area, s) {
30 const c = ctx.theme.c;
31 const user = f.user;
32 const own = user === undefined ? undefined : ownSubscription(f.app.db, user.id);
33 const lines = [
34 user === undefined ? "Sign in to see your model budget." : budgetLine(f.app.db, user, new Date()),
35 own === undefined ? "a adds your own subscription, for your own repositories." : `Your subscription: ${own.name}`,
36 note,
37 ];
38 lines.forEach((l, i) =>
39 s.text(area.x, area.y + i, truncate(l, area.w), style(c.mutedFg, c.bg), area.x + area.w),
40 );
41 },
42 key(e) {
43 if (e.ctrl || e.alt || e.key !== "a") return false;
44 ctx.push(
45 promptScreen(ctx, screen, {
46 title: "Add your subscription",
47 label: "token (from `claude setup-token`, or an API key)",
48 mask: true,
49 onSubmit: (token) =>
50 later(ctx, () =>
51 promptScreen(ctx, screen, {
52 title: "Add your subscription",
53 label: "API base URL (empty for Anthropic)",
54 onSubmit: (apiBase) => {
55 const added = addOwnModel(f.app, f.user, { token: token.trim(), apiBase: apiBase.trim() });
56 if (!added.ok) return ADD_MESSAGES[added.error];
57 note = "Your subscription is added, for your own repositories.";
58 return undefined;
59 },
60 }),
61 ),
62 }),
63 );
64 return true;
65 },
66 };
67 return screen;
68 }
69
70 /** Admin: an account's name, then dollars a month (`none` takes the grant back). */
71 export function modelGrantPrompt(f: Session, ctx: Ctx, parent: Screen): Screen {
72 return promptScreen(ctx, parent, {
73 title: "Model grant",
74 label: "account name",
75 onSubmit: (name) =>
76 later(ctx, () =>
77 promptScreen(ctx, parent, {
78 title: `Model grant for ${name.trim()}`,
79 label: "dollars a month (none takes it back)",
80 onSubmit: (raw) => {
81 const text = raw.trim().toLowerCase();
82 const usd = text === "none" || text === "0" ? null : Number(text);
83 if (usd !== null && !Number.isFinite(usd)) return "Dollars a month, like 20 — or none.";
84 const done = setAccountGrant(f.app, f.user, name.trim(), usd);
85 if (!done.ok) return done.error === "invalid_amount" ? "That amount will not do." : "No such account.";
86 ctx.toast(usd === null ? `${name.trim()}: grant taken back.` : `${name.trim()}: $${usd.toFixed(2)} a month.`);
87 return undefined;
88 },
89 }),
90 ),
91 });
92 }
src/web/pages/accounts.ts +2
@@ -42,6 +42,7 @@ import {
4242 userLink,
4343 } from "../ui.ts";
4444 import { sessionsCard } from "./account.ts";
45 import { grantCard } from "./model.ts";
4546 import { accountSections } from "./origins.ts";
4647 import { markOf } from "../../core/marks.ts";
4748
@@ -298,6 +299,7 @@ export function personSignInsPage(ctx: Ctx): Response {
298299 <h3>Access tokens</h3>`,
299300 },
300301 )}
302 ${grantCard(ctx, user, at)}
301303 ${sessionsCard(sessions, at)}`,
302304 );
303305 }
src/web/pages/model.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * Model — what an account may spend on a crew. A new account has nothing:
3 * the page says so in one line, takes the person's own subscription for
4 * their own repositories, and shows a grant an admin gave. An admin sets or
5 * takes back the grant from the account's page (`accounts.ts` shows the
6 * card). A token is typed once and never shown again.
7 */
8
9 import { ADD_MESSAGES, addOwnModel, setAccountGrant } from "../../actions/model_grant.ts";
10 import {
11 budgetLine,
12 getModelGrant,
13 GRANT_MAX_USD,
14 ownSubscription,
15 } from "../../core/model_grant.ts";
16 import { API_BASE_MAX } from "../../core/subscriptions.ts";
17 import type { User } from "../../core/users.ts";
18 import type { Ctx } from "../../http/context.ts";
19 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
20 import { field, readForm } from "../form.ts";
21 import { html, type Html } from "../html.ts";
22 import { flashTo, page } from "../layout.ts";
23 import { btn, card } from "../ui.ts";
24 import { accountOnly, accountSections } from "./origins.ts";
25
26 // GET /settings/model
27 export function modelPage(ctx: Ctx): Response {
28 const denied = accountOnly(ctx);
29 if (denied) return denied;
30 const user = ctx.auth.user!;
31 const own = ownSubscription(ctx.app.db, user.id);
32 const form = own
33 ? html`<p class="muted">
34 Your subscription <strong>${own.name}</strong> pays for the crew on your
35 own repositories, and on no one else's.
36 </p>`
37 : html`<form class="stack" method="post" action="/settings/model">
38 <div class="field">
39 <label for="f-token">Token</label>
40 <input class="input" id="f-token" name="token" type="password"
41 required autocomplete="off" spellcheck="false"
42 placeholder="sk-ant-… or any provider's key" />
43 <span class="hint"><code>claude setup-token</code> prints a
44 subscription's token. It is sealed on this forge, never shown
45 again, and pays only for repositories you own.</span>
46 </div>
47 <div class="field">
48 <label for="f-api-base">API base URL (optional)</label>
49 <input class="input" id="f-api-base" name="apiBase" type="url"
50 maxlength="${API_BASE_MAX}" autocomplete="off" spellcheck="false"
51 placeholder="https://openrouter.ai/api" />
52 </div>
53 <div>${btn("Add", { variant: "primary", size: "sm" })}</div>
54 </form>`;
55 return page({
56 ctx,
57 title: "Model",
58 body: html`<div class="page-head"><h1>Model</h1></div>
59 ${settingsLayout(
60 accountSections("model", { admin: user.isAdmin }),
61 card(html`${form}`, {
62 head: html`<h2>Your model budget</h2>
63 <p class="muted" id="model-budget">
64 ${budgetLine(ctx.app.db, user, new Date())}
65 </p>`,
66 }),
67 "Account settings",
68 )}`,
69 });
70 }
71
72 // POST /settings/model
73 export async function modelAdd(ctx: Ctx): Promise<Response> {
74 const denied = accountOnly(ctx);
75 if (denied) return denied;
76 const form = await readForm(ctx, "admin");
77 if (form instanceof Response) return form;
78 const added = addOwnModel(ctx.app, ctx.auth.user, {
79 token: field(form, "token").trim(),
80 apiBase: field(form, "apiBase").trim(),
81 });
82 return flashTo(
83 ctx,
84 "/settings/model",
85 added.ok
86 ? { message: "Your subscription is added, for your own repositories." }
87 : { tone: "danger", message: ADD_MESSAGES[added.error] },
88 );
89 }
90
91 /** The admin's card on a person's page: what they have, and the grant form. */
92 export function grantCard(ctx: Ctx, user: User, at: string): Html {
93 const grant = getModelGrant(ctx.app.db, user.id);
94 return card(
95 html`<form class="stack" method="post" action="${at}/model">
96 <div class="field">
97 <label for="f-grant">Grant, dollars a month</label>
98 <input class="input" id="f-grant" name="usd" inputmode="decimal"
99 autocomplete="off" placeholder="0 takes it back"
100 value="${grant === undefined ? "" : grant.usdPerMonth.toFixed(2)}" />
101 <span class="hint">Spent on the forge's own credential, up to
102 $${GRANT_MAX_USD} a month; a run past the cap stops.</span>
103 </div>
104 <div>${btn("Save", { variant: "primary", size: "sm" })}</div>
105 </form>`,
106 {
107 head: html`<h2>Model budget</h2>
108 <p class="muted">${budgetLine(ctx.app.db, user, new Date())}</p>`,
109 },
110 );
111 }
112
113 // POST /settings/accounts/:username/model
114 export async function grantAct(ctx: Ctx): Promise<Response> {
115 if (!ctx.auth.user) return signInFirst(ctx);
116 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
117 const form = await readForm(ctx, "admin");
118 if (form instanceof Response) return form;
119 const username = ctx.params.username ?? "";
120 const raw = field(form, "usd").trim().replace(/^\$/, "");
121 const usd = raw === "" || Number(raw) === 0 ? null : /^\d{1,6}(\.\d{1,2})?$/.test(raw) ? Number(raw) : NaN;
122 const done = setAccountGrant(ctx.app, ctx.auth.user, username, usd);
123 const back = `/settings/accounts/${encodeURIComponent(username)}`;
124 if (!done.ok) {
125 if (done.error === "not_found") return notFoundPage(ctx);
126 return flashTo(ctx, back, {
127 tone: "danger",
128 message:
129 done.error === "invalid_amount"
130 ? `A grant is dollars a month, from 0.01 to ${GRANT_MAX_USD}; 0 takes it back.`
131 : "You cannot give that to an admin or an agent.",
132 });
133 }
134 return flashTo(ctx, back, {
135 message:
136 done.value.usdPerMonth === null
137 ? `${username}'s grant is taken back.`
138 : `${username} has $${done.value.usdPerMonth.toFixed(2)} a month.`,
139 });
140 }
src/web/pages/origins.ts +7
@@ -73,6 +73,7 @@ export type AccountSection =
7373 | "password"
7474 | "tokens"
7575 | "keys"
76 | "model"
7677 | "passkeys"
7778 | "origins"
7879 | "usage"
@@ -188,6 +189,12 @@ export function accountSections(
188189 icon: "user",
189190 current: current === "profile",
190191 },
192 {
193 label: "Model",
194 href: "/settings/model",
195 icon: "key",
196 current: current === "model",
197 },
191198 {
192199 label: "Password",
193200 href: "/settings/password",
test/helpers/crew-standin.ts +10
@@ -122,6 +122,16 @@ n=0; while [ ! -f "${dataDir}/release" ] && [ $n -lt 300 ]; do sleep 0.1; n=$((n
122122 echo '{"type":"result","subtype":"error_during_execution","is_error":true,"result":"the tool crashed","total_cost_usd":0.005,"num_turns":1,"session_id":"fake-6","terminal_reason":"tool_error"}'
123123 exit 0
124124 fi
125 if grep -q GRANT-TICK "${promptFile}"; then
126 echo '{"type":"assistant","message":{"id":"m1","content":[{"type":"text","text":"Working."}],"usage":{"input_tokens":10,"output_tokens":10}}}'
127 sleep 1
128 touch "${dataDir}/ticked"
129 n=0; while [ ! -f "${dataDir}/spent" ] && [ $n -lt 300 ]; do sleep 0.1; n=$((n+1)); done
130 sleep 2.2
131 echo '{"type":"assistant","message":{"id":"m2","content":[{"type":"text","text":"Still working."}],"usage":{"input_tokens":20,"output_tokens":20}}}'
132 sleep 30
133 exit 0
134 fi
125135 if grep -q DECLINE-ME "${promptFile}"; then
126136 echo "half-done edit" > SHOULD-NOT-LAND.md
127137 echo '{"type":"result","subtype":"success","is_error":false,"result":"DECLINED: this is out of scope for me.","total_cost_usd":0.005,"num_turns":1,"session_id":"fake-2","terminal_reason":"completed"}'
test/mcp.test.ts +1
@@ -177,6 +177,7 @@ describe("the forge over MCP", () => {
177177 "stop",
178178 "health",
179179 "audit",
180 "model_grant",
180181 "outsiders",
181182 "decisions_by",
182183 "probe",
test/model-grant-pages.test.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * The pages and the API for a model grant (#1145): a new account's page says
3 * zero in one line, an admin sets the grant from the account's page or the
4 * API, and no one else may.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { getModelGrant } from "../src/core/model_grant.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const PASS = "a-long-grant-passphrase";
21
22 describe("a model grant, from the account's page and the API", () => {
23 let dataDir = "";
24 let app: App;
25 let servers: Servers;
26
27 const signIn = async (username: string) => {
28 const res = await fetch(`${servers.url}/signin`, {
29 method: "POST",
30 body: new URLSearchParams({ username, password: PASS }),
31 redirect: "manual",
32 });
33 return (res.headers.get("set-cookie") ?? "").split(";")[0]!;
34 };
35
36 beforeAll(async () => {
37 dataDir = await mkdtemp(join(tmpdir(), "forge-grant-"));
38 app = createApp({ dataDir, port: 0 });
39 servers = await serve(app);
40 for (const [username, isAdmin] of [["alice", true], ["bob", false], ["carol", false]] as const) {
41 const made = await createUser(app.db, {
42 trusted: true,
43 username,
44 email: `${username}@x.test`,
45 password: PASS,
46 isAdmin,
47 });
48 if (!made.ok) throw new Error(username);
49 }
50 }, 30_000);
51
52 afterAll(async () => {
53 servers.stop();
54 app.close();
55 await rm(dataDir, { recursive: true, force: true });
56 });
57
58 test("a new account's page says its budget is zero in one line", async () => {
59 const bob = await signIn("bob");
60 const html = await (await fetch(`${servers.url}/settings/model`, { headers: { cookie: bob } })).text();
61 expect(html).toContain("Model budget: $0. Add your own subscription, or ask an admin for a grant.");
62 });
63
64 test("an admin sets the grant from the account's page; the page shows it; a person cannot", async () => {
65 const bob = await signIn("bob");
66 const refused = await fetch(`${servers.url}/settings/accounts/bob/model`, {
67 method: "POST",
68 headers: { cookie: bob },
69 body: new URLSearchParams({ usd: "20" }),
70 redirect: "manual",
71 });
72 expect(refused.status).toBe(404);
73 expect(getModelGrant(app.db, getUserByName(app.db, "bob")!.id)).toBeUndefined();
74
75 const admin = await signIn("alice");
76 const set = await fetch(`${servers.url}/settings/accounts/bob/model`, {
77 method: "POST",
78 headers: { cookie: admin },
79 body: new URLSearchParams({ usd: "20" }),
80 redirect: "manual",
81 });
82 expect(set.status).toBe(303);
83 expect(getModelGrant(app.db, getUserByName(app.db, "bob")!.id)?.usdPerMonth).toBe(20);
84 const theirs = await (await fetch(`${servers.url}/settings/model`, { headers: { cookie: bob } })).text();
85 expect(theirs).toContain("a grant of $20.00 a month, $20.00 left");
86 const page = await (await fetch(`${servers.url}/settings/accounts/bob`, { headers: { cookie: admin } })).text();
87 expect(page).toContain("a grant of $20.00 a month");
88 // Nonsense is refused and changes nothing.
89 await fetch(`${servers.url}/settings/accounts/bob/model`, {
90 method: "POST",
91 headers: { cookie: admin },
92 body: new URLSearchParams({ usd: "lots" }),
93 redirect: "manual",
94 });
95 expect(getModelGrant(app.db, getUserByName(app.db, "bob")!.id)?.usdPerMonth).toBe(20);
96 });
97
98 test("the API sets and takes back a grant for an admin token only", async () => {
99 const admin = getUserByName(app.db, "alice")!;
100 const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "t", "admin");
101 const tok = createToken(app.db, admin.id, "t", "admin");
102 const post = (token: string, usd: unknown) =>
103 fetch(`${servers.url}/api/v1/admin/accounts/carol/model-grant`, {
104 method: "POST",
105 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
106 body: JSON.stringify({ usdPerMonth: usd }),
107 });
108 expect((await post(bobTok.token, 5)).status).toBe(404);
109 expect((await post(tok.token, "5")).status).toBe(422);
110 expect((await post(tok.token, -1)).status).toBe(422);
111 expect((await post(tok.token, 7.5)).status).toBe(200);
112 const carol = getUserByName(app.db, "carol")!;
113 expect(getModelGrant(app.db, carol.id)?.usdPerMonth).toBe(7.5);
114 expect((await post(tok.token, null)).status).toBe(200);
115 expect(getModelGrant(app.db, carol.id)).toBeUndefined();
116 });
117 });
test/model-grant.test.ts added +222
@@ -0,0 +1,222 @@
1 /**
2 * A new account has a model budget of zero; it runs a crew only on its own
3 * subscription or on a capped grant an admin gave it (#1145).
4 */
5
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { existsSync, writeFileSync } from "node:fs";
8 import { join } from "node:path";
9 import type { App } from "../src/app.ts";
10 import type { Repo } from "../src/core/repos.ts";
11 import type { User } from "../src/core/users.ts";
12 import { addToCrew } from "../src/actions/crew.ts";
13 import { addOwnModel, setAccountGrant } from "../src/actions/model_grant.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { assignIssue, openIssue } from "../src/actions/issues.ts";
16 import { createUser } from "../src/core/users.ts";
17 import { recordUsage } from "../src/core/usage.ts";
18 import { budgetLine, grantLeft, monthSpent } from "../src/core/model_grant.ts";
19 import { PLATFORM } from "../src/core/subscriptions.ts";
20 import { readRunLog } from "../src/runs/log.ts";
21 import {
22 agentRunSettled,
23 startStandInCrew,
24 stopStandInCrew,
25 type StandInCrew,
26 } from "./helpers/crew-standin.ts";
27
28 describe("model budget: zero, an own subscription, a capped grant", () => {
29 let standIn: StandInCrew;
30 let app: App;
31 let admin: User;
32 let bot: User;
33 let n = 0;
34 /** A run that spent nothing, whose ledger line a test may borrow. */
35 let zeroRun = "";
36
37 beforeAll(async () => {
38 standIn = await startStandInCrew();
39 ({ app, alice: admin, bot } = standIn);
40 process.env.ANTHROPIC_API_KEY = "sk-forge-own-token";
41 }, 30_000);
42
43 afterAll(() => {
44 delete process.env.ANTHROPIC_API_KEY;
45 return stopStandInCrew(standIn);
46 });
47
48 async function person(name: string): Promise<{ user: User; repo: Repo }> {
49 const made = await createUser(app.db, {
50 trusted: true,
51 username: name,
52 email: `${name}@forge.local`,
53 password: "a-good-passphrase",
54 });
55 if (!made.ok) throw new Error(made.error);
56 const user = made.value;
57 const r = await createRepo(app, user, { name: "yard", visibility: "private" });
58 if (!r.ok) throw new Error(String(r.error));
59 const seeded = await commitToBranch(app, user, r.value, {
60 message: "Start",
61 files: [{ path: "README.md", content: "# yard\n" }],
62 });
63 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
64 expect(addToCrew(app, user, r.value, "bot").ok).toBe(true);
65 return { user, repo: r.value };
66 }
67
68 async function runOn(user: User, repo: Repo) {
69 const issue = openIssue(app, user, repo, { title: `Job ${++n}`, body: "Soon." });
70 if (!issue.ok) throw new Error(String(issue.error));
71 expect(assignIssue(app, user, repo, issue.value, bot).ok).toBe(true);
72 return agentRunSettled(app, repo, (r) => r.issueId === issue.value.id);
73 }
74
75 test("a new account has zero, says so, and its run is refused", async () => {
76 const { user, repo } = await person("zed");
77 expect(budgetLine(app.db, user, new Date())).toContain("Model budget: $0.");
78 const run = await runOn(user, repo);
79 expect(run.state).toBe("failed");
80 zeroRun = run.id;
81 expect(run.summary).toContain("This repository has no model account");
82 expect(existsSync(standIn.argsFile)).toBe(false);
83 }, 60_000);
84
85 test("an own subscription runs, for its own repositories only", async () => {
86 const { user, repo } = await person("own");
87 const other = await person("else");
88 const added = addOwnModel(app, user, { token: "sk-ant-api03-ownkey" });
89 expect(added.ok).toBe(true);
90 expect(budgetLine(app.db, user, new Date())).toContain("own subscription");
91 const run = await runOn(user, repo);
92 expect(run.summary).not.toContain("no model account");
93 expect(readRunLog(standIn.dataDir, run.id).lines.join("\n")).toContain(
94 "subscription: own-own",
95 );
96 // Someone else's repository does not get it.
97 const theirs = await runOn(other.user, other.repo);
98 expect(theirs.summary).toContain("This repository has no model account");
99 }, 90_000);
100
101 test("only a forge admin who is a person gives a grant, and it is audited", async () => {
102 const { user } = await person("gina");
103 expect(setAccountGrant(app, user, "gina", 5).ok).toBe(false);
104 expect(setAccountGrant(app, admin, "gina", 0).ok).toBe(false);
105 expect(setAccountGrant(app, admin, "gina", 99999).ok).toBe(false);
106 expect(setAccountGrant(app, admin, bot.username, 5).ok).toBe(false);
107 expect(setAccountGrant(app, admin, "gina", 5).ok).toBe(true);
108 const event = app.db
109 .query<{ payload: string; actor_id: string }, []>(
110 "SELECT payload, actor_id FROM event WHERE kind = 'account.model_granted' ORDER BY id DESC LIMIT 1",
111 )
112 .get()!;
113 expect(event.actor_id).toBe(admin.id);
114 expect(JSON.parse(event.payload)).toEqual({ username: "gina", usdPerMonth: 5 });
115 expect(budgetLine(app.db, user, new Date())).toContain("$5.00 a month, $5.00 left");
116 });
117
118 test("a capped grant runs, and stops being offered at the cap", async () => {
119 const { user, repo } = await person("cap");
120 expect(setAccountGrant(app, admin, "cap", 5).ok).toBe(true);
121 const run = await runOn(user, repo);
122 expect(run.summary).not.toContain("no model account");
123 expect(readRunLog(standIn.dataDir, run.id).lines.join("\n")).toContain(
124 "model grant: $5.00 left this month",
125 );
126
127 // Spend the month's cap on the forge's credential: the next run is refused.
128 recordUsage(app.db, {
129 runId: run.id,
130 repoId: repo.id,
131 issueId: null,
132 agentId: null,
133 subscriptionId: PLATFORM,
134 tokensIn: 1,
135 tokensOut: 1,
136 costUsd: 5,
137 wallMs: 1,
138 outcome: "ok",
139 at: new Date().toISOString(),
140 });
141 expect(grantLeft(app.db, user.id, new Date())).toBe(0);
142 const refused = await runOn(user, repo);
143 expect(refused.state).toBe("failed");
144 expect(refused.summary).toContain("model grant is spent");
145 }, 90_000);
146
147 test("the cap is the month's: last month's spend does not count, taking it back ends it", async () => {
148 const { user, repo } = await person("mo");
149 setAccountGrant(app, admin, "mo", 2);
150 recordUsage(app.db, {
151 runId: zeroRun,
152 repoId: repo.id,
153 issueId: null,
154 agentId: null,
155 subscriptionId: PLATFORM,
156 tokensIn: 1,
157 tokensOut: 1,
158 costUsd: 9,
159 wallMs: 1,
160 outcome: "ok",
161 at: "2020-01-15T00:00:00.000Z",
162 });
163 expect(monthSpent(app.db, user.id, new Date())).toBe(0);
164 expect(grantLeft(app.db, user.id, new Date())).toBe(2);
165 expect(setAccountGrant(app, admin, "mo", null).ok).toBe(true);
166 expect(grantLeft(app.db, user.id, new Date())).toBeUndefined();
167 });
168 test("a running grant run crosses the cap mid-session, is stopped, and says the grant is spent", async () => {
169 const { user, repo } = await person("tick");
170 expect(setAccountGrant(app, admin, "tick", 5).ok).toBe(true);
171 const issue = openIssue(app, user, repo, { title: "Slow job", body: "GRANT-TICK until told." });
172 if (!issue.ok) throw new Error(String(issue.error));
173 expect(assignIssue(app, user, repo, issue.value, bot).ok).toBe(true);
174 const settled = agentRunSettled(app, repo, (r) => r.issueId === issue.value.id);
175 for (let i = 0; i < 200 && !existsSync(join(standIn.dataDir, "ticked")); i++)
176 await Bun.sleep(100);
177 expect(existsSync(join(standIn.dataDir, "ticked"))).toBe(true);
178 // The run is under its cap so far; the month's spend now reaches it.
179 expect(grantLeft(app.db, user.id, new Date())).toBe(5);
180 recordUsage(app.db, {
181 runId: zeroRun,
182 repoId: repo.id,
183 issueId: null,
184 agentId: null,
185 subscriptionId: PLATFORM,
186 tokensIn: 1,
187 tokensOut: 1,
188 costUsd: 5,
189 wallMs: 1,
190 outcome: "ok",
191 at: new Date().toISOString(),
192 });
193 writeFileSync(join(standIn.dataDir, "spent"), "");
194 const run = await settled;
195 expect(run.state).toBe("failed");
196 expect(run.summary).toContain("model grant is spent");
197 expect(run.summary).toContain("stopped at the cap");
198 }, 90_000);
199
200 test("an own subscription is found by its owner, not by its name", async () => {
201 const { user } = await person("bob");
202 const other = await person("carol");
203 // A subscription someone else made with bob's name for it is not his.
204 const { addSubscription } = await import("../src/core/subscriptions.ts");
205 expect(
206 addSubscription(app.db, standIn.dataDir, {
207 name: "bob-own",
208 token: "sk-ant-api03-adminmade",
209 createdBy: admin.id,
210 }).ok,
211 ).toBe(true);
212 const { ownSubscription } = await import("../src/core/model_grant.ts");
213 expect(ownSubscription(app.db, user.id)).toBeUndefined();
214 expect(budgetLine(app.db, user, new Date())).toContain("Model budget: $0.");
215 expect(addOwnModel(app, user, { token: "sk-ant-api03-bobkey" }).ok).toBe(true);
216 expect(ownSubscription(app.db, user.id)?.createdBy).toBe(user.id);
217 expect(ownSubscription(app.db, other.user.id)).toBeUndefined();
218 // One per account.
219 const again = addOwnModel(app, user, { token: "sk-ant-api03-bobkey2" });
220 expect(again.ok).toBe(false);
221 });
222 });
test/tui-model.test.ts added +112
@@ -0,0 +1,112 @@
1 /**
2 * Model budget in the terminal (#1145): a person sees their own line and adds
3 * their own subscription; only an admin who is a person has the grant command.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App as Forge } from "../src/app.ts";
10 import { getModelGrant, ownSubscription } from "../src/core/model_grant.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import type { TuiIdentity } from "../src/tui/session.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 describe("model budget in the terminal", () => {
21 let dataDir: string;
22 let forge: Forge;
23
24 function boot(username: string) {
25 const terminal = headlessTerminal(140, 32);
26 const user = getUserByName(forge.db, username)!;
27 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
28 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
29 app.start();
30 const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
31 return {
32 text: () => app.screen.toText(),
33 press: async (...keys: string[]) => {
34 keys.forEach(key);
35 await settleApp(app);
36 },
37 ctrl: async (k: string) => {
38 app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false });
39 await settleApp(app);
40 },
41 type: async (t: string) => {
42 for (const ch of t) key(ch);
43 await settleApp(app);
44 },
45 stop: () => app.stop(),
46 };
47 }
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-model-"));
51 forge = createApp({ dataDir, port: 0 });
52 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
53 const made = await createUser(forge.db, {
54 trusted: true,
55 username,
56 email: `${username}@forge.local`,
57 password: "a-good-passphrase",
58 adminIfFirst,
59 });
60 if (!made.ok) throw new Error(made.error);
61 }
62 });
63 afterAll(async () => {
64 forge.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 const open = async (h: ReturnType<typeof boot>, title: string) => {
69 await h.ctrl("k");
70 await h.type(title);
71 await h.press("enter");
72 };
73
74 test("a person sees budget zero and adds their own subscription", async () => {
75 const h = boot("bob");
76 await open(h, "Model budget");
77 expect(h.text()).toContain("Model budget: $0.");
78 await h.press("a");
79 await h.type("sk-ant-api03-bobkey");
80 await h.press("enter");
81 await h.press("enter");
82 const bob = getUserByName(forge.db, "bob")!;
83 expect(ownSubscription(forge.db, bob.id)).toBeDefined();
84 expect(h.text()).toContain("own subscription");
85 expect(h.text()).not.toContain("sk-ant-api03-bobkey");
86 h.stop();
87 });
88
89 test("an admin sets and takes back a grant; a person has no such command", async () => {
90 const bob = getUserByName(forge.db, "bob")!;
91 const h = boot("alice");
92 await open(h, "Model grant");
93 await h.type("bob");
94 await h.press("enter");
95 await h.type("12.5");
96 await h.press("enter");
97 expect(getModelGrant(forge.db, bob.id)?.usdPerMonth).toBe(12.5);
98 await open(h, "Model grant");
99 await h.type("bob");
100 await h.press("enter");
101 await h.type("none");
102 await h.press("enter");
103 expect(getModelGrant(forge.db, bob.id)).toBeUndefined();
104 h.stop();
105
106 const p = boot("bob");
107 await p.ctrl("k");
108 await p.type("Model grant");
109 expect(p.text()).not.toContain("give an account dollars");
110 p.stop();
111 });
112 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs