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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1145-a-new-account-starts-with-a-model-budget → main 5 commits fork 4 threads · 4 unresolved
README.md +1
| @@ -678,6 +678,7 @@ Subscriptions, sealed, and grants each to a repository (Settings → Crew) or | ||
| 678 | 678 | an organization (its page), down to one crew member: a work plan pays for |
| 679 | 679 | the work repositories and nothing else. Two on one grant take turns, and |
| 680 | 680 | 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. | |
| 681 | 682 | **Sandbox.** An agent's shell is held to a policy — the network is the forge |
| 682 | 683 | and the package registry, writes stay in the clone, the forge's data and the |
| 683 | 684 | 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 | ||
| 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 |
||
| 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"; | ||
| 110 | 110 | import { parkedTwice } from "./parks.ts"; |
| 111 | 111 | import { noteLimit } from "../core/standing.ts"; |
| 112 | 112 | import { pauseReason } from "./pacing.ts"; |
| 113 | import { grantLeft } from "../core/model_grant.ts"; | |
| 113 | 114 | import { |
| 114 | 115 | credentialEnvOf, |
| 115 | 116 | hostOfApiBase, |
| 117 | MIN_RUN_USD, | |
| 116 | 118 | pickSubscription, |
| 117 | 119 | PLATFORM, |
| 118 | 120 | restSubscription, |
| @@ -312,6 +314,7 @@ export async function executeAgentRun( | ||
| 312 | 314 | subscriptionId: null, |
| 313 | 315 | kind: usageKind(app, claimed), |
| 314 | 316 | reportedAt: 0, |
| 317 | stop: () => controller.abort(), | |
| 315 | 318 | }; |
| 316 | 319 | let outcome: Outcome; |
| 317 | 320 | try { |
| @@ -536,6 +539,12 @@ interface Meter { | ||
| 536 | 539 | kind: UsageKind; |
| 537 | 540 | /** When a `running` line was last written (epoch ms); 0 for never. */ |
| 538 | 541 | 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; | |
| 539 | 548 | } |
| 540 | 549 | |
| 541 | 550 | /** 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 { | ||
| 553 | 562 | /** How often a running session's spend is written and announced, at most. */ |
| 554 | 563 | const LIVE_MS = 2_000; |
| 555 | 564 | |
| 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 | ||
| 556 | 569 | /** |
| 557 | 570 | * A running session's spend so far, as its `running` line in the ledger, |
| 558 | 571 | * and a `usage.delta` on the outbox so the board and the Subscriptions page |
| @@ -586,6 +599,14 @@ function meterLive( | ||
| 586 | 599 | outcome: "running", |
| 587 | 600 | at: new Date(now).toISOString(), |
| 588 | 601 | }); |
| 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 | } | |
| 589 | 610 | app.bus.append({ |
| 590 | 611 | kind: "usage.delta", |
| 591 | 612 | repoId: run.repoId, |
| @@ -973,9 +994,17 @@ async function work( | ||
| 973 | 994 | } |
| 974 | 995 | // The forge's own token is for admin-owned repositories only; anyone |
| 975 | 996 | // 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)) { | |
| 977 | 1004 | 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"; | |
| 979 | 1008 | write(said); |
| 980 | 1009 | return { state: "failed", summary: said }; |
| 981 | 1010 | } |
| @@ -986,6 +1015,10 @@ async function work( | ||
| 986 | 1015 | }); |
| 987 | 1016 | meter.subscriptionId = picked.subscription.id; |
| 988 | 1017 | } else { |
| 1018 | if (grantCap !== undefined) { | |
| 1019 | meter.grantOwner = repo.ownerId; | |
| 1020 | write(`model grant: $${grantCap.toFixed(2)} left this month`); | |
| 1021 | } | |
| 989 | 1022 | write("subscription: platform (env)"); |
| 990 | 1023 | noteRunProgress(app.db, run.id, { subscriptionId: PLATFORM }); |
| 991 | 1024 | meter.subscriptionId = PLATFORM; |
| @@ -1012,7 +1045,9 @@ async function work( | ||
| 1012 | 1045 | const budget = |
| 1013 | 1046 | picked.kind === "ready" && picked.capUsd !== undefined |
| 1014 | 1047 | ? 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 |
||
| 1050 | : ownBudget; | |
| 1016 | 1051 | const reserve = wrapUpReserve(budget); |
| 1017 | 1052 | // Asked again for every invocation (`crew/sandbox.ts`): an agent made |
| 1018 | 1053 | // since the last one is denied from this one on. |
| @@ -1324,7 +1359,7 @@ async function work( | ||
| 1324 | 1359 | if (signal.aborted) |
| 1325 | 1360 | return { |
| 1326 | 1361 | state: "failed", |
| 1327 | summary: "cancelled", | |
| 1362 | summary: meter.stoppedFor ?? "cancelled", | |
| 1328 | 1363 | sessionId: result.sessionId, |
| 1329 | 1364 | costUsd: result.costUsd, |
| 1330 | 1365 | turns: result.turns, |
src/http/api.ts +18
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * back to continue); errors are `{ error, message }`. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { setAccountGrant } from "../actions/model_grant.ts"; | |
| 11 | 12 | import { storageLimitMessage } from "../core/storage.ts"; |
| 12 | 13 | import { voteVariant } from "../actions/variants.ts"; |
| 13 | 14 | import { parentForgeFor } from "../core/parent_releases.ts"; |
| @@ -1586,6 +1587,23 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1586 | 1587 | return done.ok ? json({ username: done.value.username, suspendedAt: done.value.suspendedAt }) : accountError(done.error); |
| 1587 | 1588 | }); |
| 1588 | 1589 | |
| 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 | ||
| 1589 | 1607 | router.add("POST", "/api/v1/admin/accounts/:username/unsuspend", (ctx) => { |
| 1590 | 1608 | const denied = accountAdmin(ctx, "admin"); |
| 1591 | 1609 | if (denied) return denied; |
src/http/mcp.ts +42
| @@ -61,6 +61,7 @@ import { | ||
| 61 | 61 | CARD_URI, |
| 62 | 62 | cardFor, |
| 63 | 63 | } from "../web/studio/cards.ts"; |
| 64 | import { setAccountGrant } from "../actions/model_grant.ts"; | |
| 64 | 65 | import { keepThought } from "../actions/thought.ts"; |
| 65 | 66 | import { |
| 66 | 67 | FROM_WORDS, |
| @@ -954,6 +955,47 @@ const TOOLS: readonly Tool[] = [ | ||
| 954 | 955 | ); |
| 955 | 956 | }, |
| 956 | 957 | }, |
| 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 | }, | |
| 957 | 999 | { |
| 958 | 1000 | name: "outsiders", |
| 959 | 1001 | description: |
src/http/server.ts +4
| @@ -153,6 +153,7 @@ import { | ||
| 153 | 153 | subscriptionHeadroom, |
| 154 | 154 | subscriptionWake, |
| 155 | 155 | } from "../web/pages/subscriptions.ts"; |
| 156 | import { grantAct, modelAdd, modelPage } from "../web/pages/model.ts"; | |
| 156 | 157 | import { |
| 157 | 158 | changePassword, |
| 158 | 159 | iconPng, |
| @@ -794,6 +795,9 @@ export function buildRouter(): Router<Handler> { | ||
| 794 | 795 | router.add("POST", "/settings/backups/mirror/clear", backupsMirrorClear); |
| 795 | 796 | router.add("POST", "/settings/backups/mirror/now", backupsMirrorNow); |
| 796 | 797 | 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); | |
| 797 | 801 | router.add("GET", "/settings/accounts/:username", personSignInsPage); |
| 798 | 802 | router.add( |
| 799 | 803 | "POST", |
src/tui/forge.ts +17
| @@ -47,6 +47,7 @@ import { accessScreen } from "./screens/access.ts"; | ||
| 47 | 47 | import { flagsScreen } from "./screens/flags.ts"; |
| 48 | 48 | import { cloudScreen } from "./screens/cloud.ts"; |
| 49 | 49 | import { usageScreen } from "./screens/usage.ts"; |
| 50 | import { modelGrantPrompt, modelScreen } from "./screens/model.ts"; | |
| 50 | 51 | import { proposalTokensPrompt } from "./screens/proposal_tokens.ts"; |
| 51 | 52 | import { quotasScreen } from "./screens/quotas.ts"; |
| 52 | 53 | import { domainsScreen } from "./screens/domains.ts"; |
| @@ -368,6 +369,22 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 368 | 369 | hint: "mint, list and revoke what a child proposes with", |
| 369 | 370 | run: () => ctx.push(proposalTokensPrompt(session, ctx, blank("Proposal tokens"))), |
| 370 | 371 | }); |
| 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 | }); | |
| 371 | 388 | // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191). |
| 372 | 389 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 373 | 390 | 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 { | ||
| 42 | 42 | userLink, |
| 43 | 43 | } from "../ui.ts"; |
| 44 | 44 | import { sessionsCard } from "./account.ts"; |
| 45 | import { grantCard } from "./model.ts"; | |
| 45 | 46 | import { accountSections } from "./origins.ts"; |
| 46 | 47 | import { markOf } from "../../core/marks.ts"; |
| 47 | 48 | |
| @@ -298,6 +299,7 @@ export function personSignInsPage(ctx: Ctx): Response { | ||
| 298 | 299 | <h3>Access tokens</h3>`, |
| 299 | 300 | }, |
| 300 | 301 | )} |
| 302 | ${grantCard(ctx, user, at)} | |
| 301 | 303 | ${sessionsCard(sessions, at)}`, |
| 302 | 304 | ); |
| 303 | 305 | } |
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 = | ||
| 73 | 73 | | "password" |
| 74 | 74 | | "tokens" |
| 75 | 75 | | "keys" |
| 76 | | "model" | |
| 76 | 77 | | "passkeys" |
| 77 | 78 | | "origins" |
| 78 | 79 | | "usage" |
| @@ -188,6 +189,12 @@ export function accountSections( | ||
| 188 | 189 | icon: "user", |
| 189 | 190 | current: current === "profile", |
| 190 | 191 | }, |
| 192 | { | |
| 193 | label: "Model", | |
| 194 | href: "/settings/model", | |
| 195 | icon: "key", | |
| 196 | current: current === "model", | |
| 197 | }, | |
| 191 | 198 | { |
| 192 | 199 | label: "Password", |
| 193 | 200 | 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 | ||
| 122 | 122 | 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"}' |
| 123 | 123 | exit 0 |
| 124 | 124 | 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 | |
| 125 | 135 | if grep -q DECLINE-ME "${promptFile}"; then |
| 126 | 136 | echo "half-done edit" > SHOULD-NOT-LAND.md |
| 127 | 137 | 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", () => { | ||
| 177 | 177 | "stop", |
| 178 | 178 | "health", |
| 179 | 179 | "audit", |
| 180 | "model_grant", | |
| 180 | 181 | "outsiders", |
| 181 | 182 | "decisions_by", |
| 182 | 183 | "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 | }); |
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.idtogether with its owner-scope grant.