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

A parent funds a child forge's crew: a $ cap per child on the parent's own credential, metered, shown on both forges #10848

Closed opened this in answer to
Closed without merging

torque/forge:torque/10484-a-parent-funds-a-child-forge-s-crew-a-ca → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
21 files changed +812 −12
docs/features/child-credit.md added +41
@@ -0,0 +1,41 @@
1 # A parent funds a child's crew (#10484)
2
3 A parent lends a child forge dollars of its own model credential. The child's
4 crew calls the model through the parent; the child never holds the credential.
5
6 ## Use
7
8 - Set the cap (a site admin, `rw` token or session from the forge's own origin):
9 `PUT /api/v1/forges/scottishbob/credit` with `{"usd":200}`. The first call mints the
10 child's bearer and writes it into the child's `parent.json` (`credit.token`, mode 0600, like the
11 proposal token); later calls only move the cap and keep what was spent.
12 - The parent's `/forges` row, the terminal's forge list and `GET /api/v1/forges` show
13 `creditCapUsd` / `creditSpentUsd` ("$3.20 of $200 lent").
14 - The child's crew page says "funded by its parent: $196.80 of $200 left"; `GET /api/v1/credit`
15 on the child answers the same (it asks the parent afresh; the page shows what the last
16 heartbeat or ask kept).
17
18 ## How it works
19
20 - `src/spawn/model_door.ts`: on the beat door (the loopback port a cell relays in,
21 `FORGE_PARENT_LOCAL`) `POST /v1/messages` checks the bearer, refuses at the cap, forwards the call
22 on the parent's own credential (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` or
23 `CLAUDE_CODE_OAUTH_TOKEN`, to `ANTHROPIC_BASE_URL` or api.anthropic.com), and counts the usage
24 (JSON or streamed) at list price (`src/spawn/list_price.ts`).
25 - `src/spawn/credit.ts`: `<data>/credit/<child>.json`, the cap, the spend and the bearer's hash.
26 - `src/spawn/funding.ts`: the child's side. A run whose crew has no account granted gets
27 `ANTHROPIC_BASE_URL=<parent door>` and the bearer (`credentialEnv`); an account the child granted
28 itself still wins.
29 - At the cap: "scottishbob's $200 from its parent is spent; the parent's admin can raise it."
30 (HTTP 402, in Anthropic's error shape, so the run says it).
31
32 ## Assumptions, stated
33
34 - List price is Anthropic's published per-million-token rates by model family (Opus 15/75, Sonnet 3/15,
35 Haiku 1/5; cache write 1.25x, cache read 0.1x); an unknown model is priced as Opus.
36 - A call under way when the cap is reached finishes and is counted, so the spend can pass the cap by
37 what the calls in flight cost.
38 - No new egress: `children.modelEgress` is untouched; the door only reaches the one host the parent's
39 own credential names.
40 - Not done: a spawn-time cap field (the PUT is the one door), and a funded hosted (in-process) child,
41 which has no door to call.
src/actions/child_credit.ts added +36
@@ -0,0 +1,36 @@
1 /**
2 * An admin sets the dollars a child may spend on this forge's model
3 * credential (#10484). The first time mints the child's bearer and hands it
4 * to the child's `parent.json`; later ones only move the cap. An event on
5 * the outbox, so the audit has it.
6 */
7
8 import type { App } from "../app.ts";
9 import { childDirOf, listChildren } from "./spawn.ts";
10 import { setCap, type Credit } from "../spawn/credit.ts";
11 import { keepFunding } from "../spawn/funding.ts";
12 import type { User } from "../core/users.ts";
13 import { err, ok, type Result } from "../kernel/result.ts";
14 import { requireUser } from "./access.ts";
15
16 export type CreditError = "unauthorized" | "forbidden" | "not_found" | "invalid_amount" | "unreachable";
17
18 export async function lendCredit(
19 app: App,
20 actor: User | undefined,
21 name: string,
22 usd: unknown,
23 ): Promise<Result<Credit, CreditError>> {
24 const user = requireUser(actor);
25 if (!user.ok) return err("unauthorized");
26 if (!user.value.isAdmin) return err("forbidden");
27 const child = (await listChildren(app)).find((c) => c.name === name && c.done);
28 if (child === undefined) return err("not_found");
29 if (typeof usd !== "number") return err("invalid_amount");
30 const set = setCap(app, name, usd);
31 if (set === undefined) return err("invalid_amount");
32 if (set.token !== undefined && !keepFunding(childDirOf(app, child.bodyId), { token: set.token, capUsd: set.credit.capUsd, spentUsd: 0 }))
33 return err("unreachable");
34 app.bus.append({ kind: "forge.credit_set", actorId: user.value.id, payload: { name, usd: set.credit.capUsd } });
35 return ok(set.credit);
36 }
src/contract/openapi/routes/getApiV1Credit.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/credit",
6 summary:
7 "On a forge its parent funds: the cap and the spend of the credit it was lent, asked of the parent afresh; answers { capUsd, spentUsd, says } (\"funded by its parent: $X of $200 left\"). 404 when no parent funds this forge",
8 auth: true,
9 refusals: { not_found: "no parent funds this forge" },
10 };
src/contract/openapi/routes/index.ts +2
@@ -268,6 +268,8 @@ export { route as postApiV1FleetOps } from "./postApiV1FleetOps.ts";
268268 export { route as postApiV1FleetOpsIdUndo } from "./postApiV1FleetOpsIdUndo.ts";
269269 export { route as postApiV1Forges } from "./postApiV1Forges.ts";
270270 export { route as postApiV1ForgesNameClaimLink } from "./postApiV1ForgesNameClaimLink.ts";
271 export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts";
272 export { route as getApiV1Credit } from "./getApiV1Credit.ts";
271273 export { route as postApiV1Heartbeat } from "./postApiV1Heartbeat.ts";
272274 export { route as postApiV1Migrate } from "./postApiV1Migrate.ts";
273275 export { route as postApiV1Orgs } from "./postApiV1Orgs.ts";
src/contract/openapi/routes/putApiV1ForgesNameCredit.ts added +10
@@ -0,0 +1,10 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "put",
5 path: "/api/v1/forges/{name}/credit",
6 summary:
7 "Lend a child forge dollars of this forge's own model credential (a site admin's, rw scope). Body { usd } above 0 and up to 100000; answers { name, capUsd, spentUsd, says }. The child's crew calls the model through this forge, never holding the credential; every call is counted at list price and refused once the cap is spent. Raising the cap keeps what was spent. The list GET /api/v1/forges shows creditCapUsd and creditSpentUsd per child",
8 auth: true,
9 refusals: { not_found: "no child forge has this name", forbidden: "the caller is not a site admin", invalid: "usd is not dollars in range" },
10 };
src/core/event_audience.ts +1
@@ -111,6 +111,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
111111 "forge.removed",
112112 "forge.stopped",
113113 "forge.claim_reissued",
114 "forge.credit_set",
114115 "forge.claim_revoked",
115116 "forge.claim_restored",
116117 "agent.created",
src/crew/conversation.ts +1 −1
@@ -513,7 +513,7 @@ function begin(
513513 budgetUsd: budget,
514514 resume: session,
515515 env: {
516 ...credentialEnv(picked),
516 ...credentialEnv(picked, app.config.dataDir),
517517 CLAUDE_CONFIG_DIR: configDir,
518518 FORGE_MCP_TOKEN: minted.value.token,
519519 },
src/crew/one_shot.ts +1 −1
@@ -49,7 +49,7 @@ export async function oneShot(app: App, userId: string, system: string, prompt:
4949 appendSystemPrompt: system,
5050 model: model ?? options.model,
5151 budgetUsd: budget,
52 env: { ...credentialEnv(picked), CLAUDE_CONFIG_DIR: sessionsDir(app.config.dataDir, options.configDir) },
52 env: { ...credentialEnv(picked, app.config.dataDir), CLAUDE_CONFIG_DIR: sessionsDir(app.config.dataDir, options.configDir) },
5353 sandbox: sandboxPolicy({ dataDir: app.config.dataDir, agentName: "one-shot", urls: [], enforced: false, runId }),
5454 label: "one-shot",
5555 timeoutMs: ONE_SHOT_MS,
src/crew/runner.ts +6 −2
@@ -197,6 +197,7 @@ import {
197197 skipsFeedback,
198198 } from "./guard.ts";
199199 import { passportEnv } from "./passport_env.ts";
200 import { fundedEnv } from "../spawn/funding.ts";
200201
201202 const log = createLog("crew");
202203
@@ -1408,7 +1409,7 @@ async function work(
14081409 FORGE_URL: options.forgeUrl,
14091410 FORGE_TOKEN: token.token,
14101411 ...passportEnv(app.config.dataDir),
1411 ...credentialEnv(picked),
1412 ...credentialEnv(picked, app.config.dataDir),
14121413 ...grant.env,
14131414 ...machineEnv(machine),
14141415 ...(options.configDir === undefined
@@ -2340,8 +2341,11 @@ function redactResult<T extends { result: string }>(r: T): T {
23402341 * the account has one), or — with nothing granted — whatever the forge
23412342 * itself was started with.
23422343 */
2343 export function credentialEnv(picked: Pick): Record<string, string> {
2344 export function credentialEnv(picked: Pick, dataDir?: string): Record<string, string> {
23442345 if (picked.kind === "ready") return credentialEnvOf(picked);
2346 // A forge its parent funds (#10484): with no account of its own, the crew calls the model through the parent.
2347 const lent = picked.kind === "env" && dataDir !== undefined ? fundedEnv(dataDir) : undefined;
2348 if (lent !== undefined) return lent;
23452349 const own: Record<string, string> = {};
23462350 for (const name of [
23472351 "ANTHROPIC_API_KEY",
src/http/api/forges.ts +33 −1
@@ -4,7 +4,10 @@
44 * link. Moved out of `api.ts` (#6331); the rules live in `actions/spawn.ts` and `spawn/`.
55 */
66
7 import { lendCredit } from "../../actions/child_credit.ts";
78 import { listChildren } from "../../actions/spawn.ts";
9 import { usdWords } from "../../spawn/credit.ts";
10 import { fundingOf, fundingWords, refreshFunding } from "../../spawn/funding.ts";
811 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
912 import { clientRows } from "../../spawn/clients.ts";
1013 import { reissueDoor, spawnDoor } from "../../spawn/door.ts";
@@ -44,7 +47,8 @@ export function forgeRoutes(router: Router<Handler>): void {
4447 // The row, and beside it what the list always carried: which template a child came from, where it is placed.
4548 const [rows, children] = await Promise.all([clientRows(ctx.app), listChildren(ctx.app)]);
4649 const kept = new Map(children.map((c) => [c.name, c]));
47 return rows.map((row) => {
50 return rows.map((listed) => {
51 const row = listed;
4852 const c = kept.get(row.name);
4953 return c === undefined
5054 ? row
@@ -71,6 +75,34 @@ export function forgeRoutes(router: Router<Handler>): void {
7175 return json(door.body, door.status);
7276 });
7377
78 // The dollars this forge lends a child on its own model credential (#10484): a site admin sets the cap.
79 router.add("PUT", "/api/v1/forges/:name/credit", async (ctx) => {
80 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
81 const denied = requireScope(ctx, "rw");
82 if (denied) return denied;
83 const input = await body(ctx.request);
84 if (typeof input !== "object" || input === null || Array.isArray(input))
85 return apiError("invalid", 422, 'send a JSON object: {"usd":200}');
86 const name = ctx.params["name"] ?? "";
87 const set = await lendCredit(ctx.app, ctx.auth.user, name, (input as Record<string, unknown>)["usd"]);
88 if (set.ok)
89 return json({ name, capUsd: set.value.capUsd, spentUsd: set.value.spentUsd, says: `${name} may spend ${usdWords(set.value.capUsd)} of this forge's credit.` });
90 if (set.error === "unauthorized" || set.error === "forbidden") return forbidden();
91 if (set.error === "not_found") return apiError("not_found", 404, "no such forge");
92 if (set.error === "unreachable") return apiError("unreachable", 502, "the child's data could not be reached");
93 return apiError("invalid", 422, "usd is dollars above 0 and up to 100000");
94 });
95
96 // On a forge its parent funds: how much is left, as the parent last said (asked afresh first).
97 router.add("GET", "/api/v1/credit", async (ctx) => {
98 const denied = requireScope(ctx, "ro");
99 if (denied) return denied;
100 if (ctx.auth.user === undefined) return forbidden();
101 const f = fundingOf(ctx.app.config.dataDir) === undefined ? undefined : await refreshFunding(ctx.app);
102 if (f === undefined) return apiError("not_found", 404, "no parent funds this forge");
103 return json({ capUsd: f.capUsd, spentUsd: f.spentUsd, says: fundingWords(f) });
104 });
105
74106 // A new claim link for a child nobody has claimed (#729); the old one dies.
75107 router.add("POST", "/api/v1/forges/:name/claim-link", async (ctx) => {
76108 const denied = requireScope(ctx, "rw");
src/spawn/beat-door.ts +10 −4
@@ -1,6 +1,7 @@
11 /**
22 * The one door a cell child has to its parent: a loopback listener that
3 * answers `POST /api/v1/heartbeat` and nothing else. The child's cell relays
3 * answers `POST /api/v1/heartbeat`, and the lent model endpoint
4 * (`spawn/model_door.ts`: `POST /v1/messages`, `GET /api/v1/credit`), and nothing else. The child's cell relays
45 * only this port in, so untrusted child code never reaches the parent's web
56 * surface, and since the door never asks who the client is, no forwarding
67 * header it sends counts for anything.
@@ -8,6 +9,7 @@
89
910 import type { App } from "../app.ts";
1011 import { receiveBeat, SIGNATURE_HEADER } from "./heartbeat.ts";
12 import { creditAnswer, lendModel, MODEL_BODY_MAX, type ModelDeps } from "./model_door.ts";
1113
1214 const MAX_BODY = 1024;
1315
@@ -22,14 +24,18 @@ export interface BeatDoor {
2224 close(): void;
2325 }
2426
25 export function openBeatDoor(app: App): BeatDoor {
27 export function openBeatDoor(app: App, deps: { readonly model?: ModelDeps } = {}): BeatDoor {
2628 const server = Bun.serve({
2729 port: 0,
2830 hostname: "127.0.0.1",
29 maxRequestBodySize: MAX_BODY,
31 maxRequestBodySize: MODEL_BODY_MAX,
3032 async fetch(req) {
3133 const refused = new Response("no such door", { status: 404 });
32 if (req.method !== "POST" || new URL(req.url).pathname !== "/api/v1/heartbeat") return refused;
34 const path = new URL(req.url).pathname;
35 if (req.method === "POST" && path === "/v1/messages") return lendModel(app, req, deps.model);
36 if (req.method === "GET" && path === "/api/v1/credit") return creditAnswer(app, req);
37 if (req.method !== "POST" || path !== "/api/v1/heartbeat") return refused;
38 if (Number(req.headers.get("content-length") ?? 0) > MAX_BODY) return refused;
3339 const raw = await req.text();
3440 if (raw.length > MAX_BODY) return refused;
3541 const kept = await receiveBeat(app, raw, req.headers.get(SIGNATURE_HEADER));
src/spawn/clients.ts +10
@@ -13,6 +13,7 @@ import type { App } from "../app.ts";
1313 import { childDirOf, listChildren, statusTokenOf, type Child } from "../actions/spawn.ts";
1414 import { timingSafeEqual } from "node:crypto";
1515 import { sha256 } from "../kernel/hash.ts";
16 import { creditView, usdWords } from "./credit.ts";
1617 import type { ChildAppsSummary } from "./child-apps.ts";
1718 import { childAwake, hostedChildApps } from "./host.ts";
1819 import { hasAdmin, linkOf, spawnedOrg, type LinkInfo, type LinkState } from "./claim.ts";
@@ -54,6 +55,9 @@ export interface ClientRow {
5455 readonly spendTodayUsd: number | null;
5556 /** The child's forge cap in dollars; null when none or unsaid. */
5657 readonly ceilingUsd: number | null;
58 /** The dollars this forge lends it on its own model credential, and what it has spent; null when none is lent. */
59 readonly creditCapUsd?: number | null;
60 readonly creditSpentUsd?: number | null;
5761 /** Its size (`spawn/sizes.ts`). */
5862 readonly size: Size;
5963 /** What it uses against that size: its cell's reading, or "shared" when it is hosted here. */
@@ -231,6 +235,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
231235 reach: reachOf(app, c.name) ?? null,
232236 spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null,
233237 ceilingUsd: beats[i]?.beat.ceilingUsd ?? null,
238 ...creditView(app, c.name),
234239 size: c.size,
235240 use: forgeUse(c, childDirOf(app, c.bodyId)),
236241 lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(),
@@ -314,6 +319,11 @@ const money = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixe
314319 * "$3.20 of $10 today", with "ceiling reached" at the cap; `warn` from 80%.
315320 * Null when the child has not reported spend.
316321 */
322 /** "$3.20 of $200 lent"; null when this forge lends the child nothing. */
323 export function creditWords(r: Pick<ClientRow, "creditCapUsd" | "creditSpentUsd">): string | null {
324 return r.creditCapUsd === null || r.creditCapUsd === undefined ? null : `${usdWords(r.creditSpentUsd ?? 0)} of ${usdWords(r.creditCapUsd)} lent`;
325 }
326
317327 export function spendWords(r: ClientRow): { words: string; warn: boolean } | null {
318328 if (r.spendTodayUsd === null) return null;
319329 const spent = `$${r.spendTodayUsd.toFixed(2)}`;
src/spawn/credit.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * The parent's credit to one child: a dollar cap an admin sets, the spend
3 * counted against it, and the bearer the child's crew presents to the model
4 * door (`spawn/model_door.ts`). One small file per child under
5 * `<data>/credit/<name>.json`, beside the ledger and the beats; only the
6 * bearer's hash is kept here, the bearer itself lives in the child's
7 * `parent.json`. Reads and writes are synchronous, so a check and the spend
8 * it follows cannot interleave in this one process.
9 */
10
11 import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
12 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13 import { join } from "node:path";
14 import type { App } from "../app.ts";
15 import { isChildName } from "../core/proposal_token.ts";
16
17 export interface Credit {
18 readonly name: string;
19 readonly capUsd: number;
20 readonly spentUsd: number;
21 readonly tokenHash: string;
22 }
23
24 export const CREDIT_MAX_USD = 100_000;
25
26 const sha = (s: string): string => createHash("sha256").update(s).digest("hex");
27 const path = (app: App, name: string): string => join(app.config.dataDir, "credit", `${name}.json`);
28 const cents = (n: number): number => Math.round(n * 100) / 100;
29
30 export function readCredit(app: App, name: string): Credit | undefined {
31 if (!isChildName(name)) return undefined;
32 try {
33 const d = JSON.parse(readFileSync(path(app, name), "utf8")) as Record<string, unknown>;
34 const { capUsd, spentUsd, tokenHash } = d;
35 if (typeof capUsd !== "number" || typeof spentUsd !== "number" || typeof tokenHash !== "string") return undefined;
36 return { name, capUsd, spentUsd, tokenHash };
37 } catch {
38 return undefined;
39 }
40 }
41
42 function keep(app: App, c: Credit): void {
43 mkdirSync(join(app.config.dataDir, "credit"), { recursive: true });
44 const p = path(app, c.name);
45 writeFileSync(`${p}.tmp`, JSON.stringify(c), { mode: 0o600 });
46 renameSync(`${p}.tmp`, p);
47 }
48
49 /**
50 * Set a child's cap. The first time also mints its bearer, returned once
51 * (`token`) for the caller to hand to the child; the spend already counted
52 * is never reset. Undefined for a sum that is not dollars.
53 */
54 export function setCap(app: App, name: string, usd: number): { credit: Credit; token?: string } | undefined {
55 if (!isChildName(name) || !(Number.isFinite(usd) && usd > 0 && usd <= CREDIT_MAX_USD)) return undefined;
56 const before = readCredit(app, name);
57 if (before !== undefined) {
58 const credit = { ...before, capUsd: cents(usd) };
59 keep(app, credit);
60 return { credit };
61 }
62 const token = `${name}.${randomBytes(24).toString("base64url")}`;
63 const credit = { name, capUsd: cents(usd), spentUsd: 0, tokenHash: sha(token) };
64 keep(app, credit);
65 return { credit, token };
66 }
67
68 /** The child a bearer was minted for, or undefined. The name rides in the bearer; the hash decides. */
69 export function creditOfToken(app: App, token: string): Credit | undefined {
70 const name = token.split(".")[0] ?? "";
71 const c = readCredit(app, name);
72 if (c === undefined) return undefined;
73 const a = Buffer.from(sha(token));
74 const b = Buffer.from(c.tokenHash);
75 return a.length === b.length && timingSafeEqual(a, b) ? c : undefined;
76 }
77
78 /** Count dollars spent against a child's cap. */
79 export function addSpend(app: App, name: string, usd: number): void {
80 const c = readCredit(app, name);
81 if (c === undefined || !(usd > 0) || !Number.isFinite(usd)) return;
82 keep(app, { ...c, spentUsd: Math.round((c.spentUsd + usd) * 1e6) / 1e6 });
83 }
84
85 export const leftUsd = (c: Pick<Credit, "capUsd" | "spentUsd">): number => Math.max(0, cents(c.capUsd - c.spentUsd));
86
87 /** Whole dollars read plainly, others to the cent. */
88 export const usdWords = (n: number): string => `$${Number.isInteger(n) ? n : n.toFixed(2)}`;
89
90 /** The one sentence a call past the cap is refused with. */
91 export const spentWords = (name: string, capUsd: number): string =>
92 `${name}'s ${usdWords(capUsd)} from its parent is spent; the parent's admin can raise it.`;
93
94 /** What the parent lists for a child: cap and spend to the cent, or nulls when no credit was set. */
95 export function creditView(app: App, name: string): { creditCapUsd: number | null; creditSpentUsd: number | null } {
96 const c = readCredit(app, name);
97 return c === undefined ? { creditCapUsd: null, creditSpentUsd: null } : { creditCapUsd: c.capUsd, creditSpentUsd: cents(c.spentUsd) };
98 }
src/spawn/funding.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * What a child knows of the credit its parent lends it (#10484): the bearer
3 * the parent minted (`credit.token` in `parent.json`, beside the proposal
4 * token, never in the database) and the cap and spend last heard. A funded
5 * child's crew, with no account of its own granted, calls the model through
6 * the parent's door (`FORGE_PARENT_LOCAL`); the parent's credential never
7 * reaches this forge's environment or data directory, only the bearer that
8 * is good for nothing but that door.
9 */
10
11 import { readFileSync, renameSync, writeFileSync } from "node:fs";
12 import { join } from "node:path";
13 import type { App } from "../app.ts";
14 import { leftUsd, usdWords } from "./credit.ts";
15 import { isParentLocal } from "./beat-door.ts";
16
17 export interface Funding {
18 readonly token: string;
19 readonly capUsd: number | null;
20 readonly spentUsd: number | null;
21 }
22
23 const BEARER = /^[\x21-\x7e]{8,200}$/;
24 const file = (dataDir: string): string => join(dataDir, "parent.json");
25
26 function readDoc(dataDir: string): Record<string, unknown> | undefined {
27 try {
28 const d = JSON.parse(readFileSync(file(dataDir), "utf8"));
29 return typeof d === "object" && d !== null && !Array.isArray(d) ? (d as Record<string, unknown>) : undefined;
30 } catch {
31 return undefined;
32 }
33 }
34
35 const sum = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null);
36
37 /** The credit this forge was handed, or undefined. */
38 export function fundingOf(dataDir: string): Funding | undefined {
39 const c = readDoc(dataDir)?.["credit"];
40 if (typeof c !== "object" || c === null) return undefined;
41 const { token, capUsd, spentUsd } = c as Record<string, unknown>;
42 if (typeof token !== "string" || !BEARER.test(token)) return undefined;
43 return { token, capUsd: sum(capUsd), spentUsd: sum(spentUsd) };
44 }
45
46 /** Write `credit` into a forge's `parent.json`, keeping the rest. The parent calls this on a child's directory. */
47 export function keepFunding(dataDir: string, funding: Funding): boolean {
48 const doc = readDoc(dataDir);
49 if (doc === undefined) return false;
50 writeFileSync(`${file(dataDir)}.tmp`, JSON.stringify({ ...doc, credit: funding }), { mode: 0o600 });
51 renameSync(`${file(dataDir)}.tmp`, file(dataDir));
52 return true;
53 }
54
55 /** The environment that points a crew run at the parent's door; undefined unless funded and the door is reachable. */
56 export function fundedEnv(dataDir: string): Record<string, string> | undefined {
57 const local = process.env["FORGE_PARENT_LOCAL"] ?? "";
58 const f = fundingOf(dataDir);
59 if (f === undefined || !isParentLocal(local)) return undefined;
60 return { ANTHROPIC_BASE_URL: local, ANTHROPIC_API_KEY: f.token };
61 }
62
63 type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
64
65 /** Ask the parent how much is left and keep the answer; the last known stands when it does not answer. Never throws. */
66 export async function refreshFunding(app: App, fetcher: FetchLike = fetch): Promise<Funding | undefined> {
67 const f = fundingOf(app.config.dataDir);
68 const local = process.env["FORGE_PARENT_LOCAL"] ?? "";
69 if (f === undefined) return undefined;
70 if (!isParentLocal(local)) return f;
71 try {
72 const res = await fetcher(`${local}/api/v1/credit`, {
73 headers: { authorization: `Bearer ${f.token}` },
74 signal: AbortSignal.timeout(3000),
75 redirect: "error",
76 });
77 if (!res.ok) return f;
78 const d = (await res.json()) as Record<string, unknown>;
79 const next = { token: f.token, capUsd: sum(d["capUsd"]), spentUsd: sum(d["spentUsd"]) };
80 keepFunding(app.config.dataDir, next);
81 return next;
82 } catch {
83 return f;
84 }
85 }
86
87 /** The line the crew page and the API say: "funded by its parent: $199.50 of $200 left". */
88 export function fundingWords(f: Funding): string {
89 return f.capUsd === null || f.spentUsd === null
90 ? "funded by its parent"
91 : `funded by its parent: ${usdWords(leftUsd({ capUsd: f.capUsd, spentUsd: f.spentUsd }))} of ${usdWords(f.capUsd)} left`;
92 }
src/spawn/heartbeat.ts +2
@@ -22,6 +22,7 @@ import { beatKeyOf, listChildren } from "../actions/spawn.ts";
2222 import { shippedReleases } from "../release/shipped.ts";
2323 import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts";
2424 import { isParentLocal } from "./beat-door.ts";
25 import { refreshFunding } from "./funding.ts";
2526 import { upstreamState } from "./upstream.ts";
2627 import { progress } from "../core/next-steps.ts";
2728 import { running } from "../kernel/version.ts";
@@ -314,6 +315,7 @@ export async function sendBeat(
314315 signal: AbortSignal.timeout(5000),
315316 redirect: "error",
316317 });
318 if (res.ok) await refreshFunding(app, fetcher);
317319 return res.ok;
318320 } catch {
319321 return false;
src/spawn/list_price.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * What a model call costs at list price, in dollars, from the usage the
3 * provider reports. USD per million tokens, Anthropic's published list
4 * (anthropic.com/pricing): Opus 15 in / 75 out, Sonnet 3 / 15, Haiku 1 / 5;
5 * a cache write costs 1.25x the input price and a cache read 0.1x. A model
6 * whose family is not recognised is priced as Opus, the dearest: a lent
7 * credential is never undercounted, and the worst a wrong guess does is
8 * stop a child's spending early.
9 */
10
11 export interface Usage {
12 readonly inputTokens: number;
13 readonly outputTokens: number;
14 readonly cacheWriteTokens: number;
15 readonly cacheReadTokens: number;
16 }
17
18 export const NO_USAGE: Usage = { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 };
19
20 interface Rate {
21 readonly in: number;
22 readonly out: number;
23 }
24
25 const FAMILIES: readonly (readonly [RegExp, Rate])[] = [
26 [/haiku/i, { in: 1, out: 5 }],
27 [/sonnet/i, { in: 3, out: 15 }],
28 [/opus/i, { in: 15, out: 75 }],
29 ];
30 const DEAREST: Rate = { in: 15, out: 75 };
31
32 export const rateOf = (model: string): Rate => FAMILIES.find(([re]) => re.test(model))?.[1] ?? DEAREST;
33
34 /** Dollars at list price: tokens / 1e6 * USD per million, summed. Unrounded. */
35 export function listPriceUsd(model: string, u: Usage): number {
36 const r = rateOf(model);
37 return (
38 (u.inputTokens * r.in + u.outputTokens * r.out + u.cacheWriteTokens * r.in * 1.25 + u.cacheReadTokens * r.in * 0.1) /
39 1_000_000
40 );
41 }
src/spawn/model_door.ts added +193
@@ -0,0 +1,193 @@
1 /**
2 * The model endpoint a parent lends a child (#10484): `POST /v1/messages`
3 * and `GET /api/v1/credit` on the beat door. The child's crew presents the
4 * bearer the parent minted for it; the parent forwards the call on its own
5 * credential, which the child never holds, counts the reply's usage at list
6 * price (`spawn/list_price.ts`) against the child's dollar cap
7 * (`spawn/credit.ts`), and refuses the next call once the cap is spent.
8 * This is a path through the parent: it opens no egress for the child, and
9 * the only host it reaches is the one the parent's own credential names.
10 * A call in flight when the cap runs out finishes and is counted, so the
11 * spend can pass the cap by what the calls already under way cost.
12 */
13
14 import type { App } from "../app.ts";
15 import { addSpend, creditOfToken, leftUsd, readCredit, spentWords, type Credit } from "./credit.ts";
16 import { listPriceUsd, NO_USAGE, type Usage } from "./list_price.ts";
17
18 type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
19
20 /** Where the parent's own credential points: a base URL and the headers that carry it. */
21 export interface ModelUpstream {
22 readonly base: string;
23 readonly headers: Record<string, string>;
24 }
25
26 export interface ModelDeps {
27 readonly fetch?: FetchLike;
28 readonly upstream?: () => ModelUpstream | undefined;
29 }
30
31 /** The parent's own credential from its environment, never from a request. */
32 export function ownUpstream(): ModelUpstream | undefined {
33 const base = (process.env["ANTHROPIC_BASE_URL"] ?? "https://api.anthropic.com").replace(/\/+$/, "");
34 const key = process.env["ANTHROPIC_API_KEY"];
35 const auth = process.env["ANTHROPIC_AUTH_TOKEN"];
36 const oauth = process.env["CLAUDE_CODE_OAUTH_TOKEN"];
37 if (key !== undefined && key !== "") return { base, headers: { "x-api-key": key } };
38 if (auth !== undefined && auth !== "") return { base, headers: { authorization: `Bearer ${auth}` } };
39 if (oauth !== undefined && oauth !== "")
40 return { base, headers: { authorization: `Bearer ${oauth}`, "anthropic-beta": "oauth-2025-04-20" } };
41 return undefined;
42 }
43
44 export const MODEL_BODY_MAX = 16 * 1024 * 1024;
45
46 const refuse = (status: number, type: string, message: string): Response =>
47 Response.json({ type: "error", error: { type, message } }, { status });
48
49 function bearerOf(req: Request): string {
50 const auth = req.headers.get("authorization") ?? "";
51 const m = /^Bearer\s+(\S{8,200})$/i.exec(auth);
52 return m?.[1] ?? req.headers.get("x-api-key") ?? "";
53 }
54
55 const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
56
57 function usageOf(u: unknown, into: Usage): Usage {
58 if (typeof u !== "object" || u === null) return into;
59 const r = u as Record<string, unknown>;
60 return {
61 inputTokens: "input_tokens" in r ? num(r["input_tokens"]) : into.inputTokens,
62 outputTokens: "output_tokens" in r ? num(r["output_tokens"]) : into.outputTokens,
63 cacheWriteTokens: "cache_creation_input_tokens" in r ? num(r["cache_creation_input_tokens"]) : into.cacheWriteTokens,
64 cacheReadTokens: "cache_read_input_tokens" in r ? num(r["cache_read_input_tokens"]) : into.cacheReadTokens,
65 };
66 }
67
68 /** Reads usage out of a server-sent stream as it passes: `message_start` carries the input, `message_delta` the output so far. */
69 class Tally {
70 usage: Usage = NO_USAGE;
71 model = "";
72 private buf = "";
73 private readonly dec = new TextDecoder();
74
75 feed(chunk: Uint8Array): void {
76 this.buf += this.dec.decode(chunk, { stream: true });
77 let at: number;
78 while ((at = this.buf.indexOf("\n")) >= 0) {
79 const line = this.buf.slice(0, at).trimEnd();
80 this.buf = this.buf.slice(at + 1);
81 if (!line.startsWith("data:")) continue;
82 try {
83 const ev = JSON.parse(line.slice(5)) as { type?: unknown; message?: { model?: unknown; usage?: unknown }; usage?: unknown };
84 if (ev.type === "message_start") {
85 if (typeof ev.message?.model === "string") this.model = ev.message.model;
86 this.usage = usageOf(ev.message?.usage, this.usage);
87 } else if (ev.type === "message_delta") this.usage = usageOf(ev.usage, this.usage);
88 } catch {
89 // not an event we count
90 }
91 }
92 if (this.buf.length > 1_000_000) this.buf = "";
93 }
94 }
95
96 function meter(app: App, credit: Credit, fetched: Response): Response {
97 const type = fetched.headers.get("content-type") ?? "";
98 const headers = new Headers({ "content-type": type || "application/json" });
99 if (!fetched.ok || fetched.body === null) return new Response(fetched.body, { status: fetched.status, headers });
100 const charge = (model: string, usage: Usage): void => addSpend(app, credit.name, listPriceUsd(model, usage));
101 if (!type.includes("text/event-stream")) {
102 // One document: count it once it is whole.
103 return new Response(
104 fetched.body.pipeThrough(
105 (() => {
106 let text = "";
107 const dec = new TextDecoder();
108 return new TransformStream<Uint8Array, Uint8Array>({
109 transform(chunk, c) {
110 text += dec.decode(chunk, { stream: true });
111 c.enqueue(chunk);
112 },
113 flush() {
114 try {
115 const doc = JSON.parse(text) as { model?: unknown; usage?: unknown };
116 charge(typeof doc.model === "string" ? doc.model : "", usageOf(doc.usage, NO_USAGE));
117 } catch {
118 // an unreadable answer is not counted; the provider's own ledger is the check
119 }
120 },
121 });
122 })(),
123 ),
124 { status: fetched.status, headers },
125 );
126 }
127 const reader = fetched.body.getReader();
128 const tally = new Tally();
129 let done = false;
130 const finish = (): void => {
131 if (done) return;
132 done = true;
133 charge(tally.model, tally.usage);
134 };
135 return new Response(
136 new ReadableStream<Uint8Array>({
137 async pull(c) {
138 try {
139 const { done: end, value } = await reader.read();
140 if (end) {
141 finish();
142 c.close();
143 return;
144 }
145 tally.feed(value);
146 c.enqueue(value);
147 } catch (e) {
148 finish();
149 c.error(e);
150 }
151 },
152 cancel(why) {
153 finish();
154 return reader.cancel(why);
155 },
156 }),
157 { status: fetched.status, headers },
158 );
159 }
160
161 /** `POST /v1/messages`: one lent model call. */
162 export async function lendModel(app: App, req: Request, deps: ModelDeps = {}): Promise<Response> {
163 const credit = creditOfToken(app, bearerOf(req));
164 if (credit === undefined) return refuse(401, "authentication_error", "this forge does not fund that key");
165 if (readCredit(app, credit.name)!.spentUsd >= credit.capUsd)
166 return refuse(402, "billing_error", spentWords(credit.name, credit.capUsd));
167 const own = (deps.upstream ?? ownUpstream)();
168 if (own === undefined) return refuse(503, "api_error", "the parent has no model credential to lend");
169 if (Number(req.headers.get("content-length") ?? 0) > MODEL_BODY_MAX) return refuse(413, "invalid_request_error", "request too large");
170 const headers: Record<string, string> = { "content-type": "application/json", "anthropic-version": req.headers.get("anthropic-version") ?? "2023-06-01" };
171 const beta = [req.headers.get("anthropic-beta"), own.headers["anthropic-beta"]].filter((b): b is string => !!b && /^[\w.,-]{1,300}$/.test(b));
172 Object.assign(headers, own.headers);
173 if (beta.length > 0) headers["anthropic-beta"] = beta.join(",");
174 let fetched: Response;
175 try {
176 fetched = await (deps.fetch ?? fetch)(`${own.base}/v1/messages`, {
177 method: "POST",
178 headers,
179 body: await req.arrayBuffer(),
180 redirect: "error",
181 });
182 } catch {
183 return refuse(502, "api_error", "the parent could not reach its model provider");
184 }
185 return meter(app, credit, fetched);
186 }
187
188 /** `GET /api/v1/credit`: what the bearer's child may spend and has. */
189 export function creditAnswer(app: App, req: Request): Response {
190 const credit = creditOfToken(app, bearerOf(req));
191 if (credit === undefined) return refuse(401, "authentication_error", "this forge does not fund that key");
192 return Response.json({ capUsd: credit.capUsd, spentUsd: credit.spentUsd, leftUsd: leftUsd(credit) });
193 }
src/tui/screens/forges.ts +2 −2
@@ -6,7 +6,7 @@
66
77 import type { Ctx, Screen } from "../app/app.ts";
88 import { binding } from "../app/keys.ts";
9 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
9 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
1010 import { useLine } from "../../spawn/usage.ts";
1111 import { timeAgo } from "../../web/ui.ts";
1212 import { card } from "../ui/widgets.ts";
@@ -23,7 +23,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
2323 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
2424 return rows.flatMap((r): [string, string][] => [[
2525 r.name,
26 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null, useLine(r.size, r.use), r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
26 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), useLine(r.size, r.use), r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
2727 .filter((m): m is string => m !== null)
2828 .join(" · "),
2929 ], ...addressLines(r)]);
src/web/pages/crew.ts +8
@@ -6,6 +6,7 @@
66 * the crew is actually there, in words, before anyone assigns anything.
77 */
88
9 import { fundingOf, fundingWords } from "../../spawn/funding.ts";
910 import {
1011 createAgent,
1112 deleteAgent,
@@ -123,6 +124,12 @@ function panel(body: Html, o: PanelOptions): Html {
123124 </section>`;
124125 }
125126
127 /** On a forge its parent funds (#10484), what is left of the credit, as the parent last said. */
128 function fundedNote(ctx: Ctx): Html | null {
129 const f = fundingOf(ctx.app.config.dataDir);
130 return f === undefined ? null : html`<p class="muted small mb" data-funded>${fundingWords(f)}</p>`;
131 }
132
126133 /**
127134 * Whether anything will pick up an agent's work. The runner is detected
128135 * once at boot; this is that answer, said before an issue is assigned
@@ -145,6 +152,7 @@ export function crewReadiness(ctx: Ctx): Html {
145152 return html`<p class="muted small mb">
146153 ${icon("check")} ${crew.detail} is ready to run the crew.
147154 </p>
155 ${fundedNote(ctx)}
148156 ${when(
149157 !crew.sandbox.enforced,
150158 () =>
src/web/pages/forges.ts +2 −1
@@ -7,7 +7,7 @@
77 */
88
99 import type { Ctx } from "../../http/context.ts";
10 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, clientRows, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
10 import { ATTENTION_WORDS, CLAIM_WORDS, claimState, clientRows, creditWords, runningWords, spendWords, type ClientRow } from "../../spawn/clients.ts";
1111 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1212 import { notFoundPage, pageHead } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
@@ -52,6 +52,7 @@ function forgeRow(r: ClientRow, now: number): Html {
5252 r.made ? html`${sizeWords(r.size)}` : null,
5353 runningNote(r),
5454 spendNote(r),
55 creditWords(r) === null ? null : html`<span data-credit>${creditWords(r)}</span>`,
5556 r.sentAt === null ? null : html`sent ${time(r.sentAt)}`,
5657 r.lastActivityAt === null
5758 ? null
test/child-credit.test.ts added +213
@@ -0,0 +1,213 @@
1 /**
2 * A parent lends a child its model credential by the dollar (#10484): an
3 * admin sets a cap, the child's crew reaches the model through the parent's
4 * door with a bearer that is good for nothing else, every call is counted at
5 * list price, and a call past the cap is refused in one sentence. The
6 * parent's key never reaches the child's environment or data directory.
7 * (A real cell is not booted here: the sandbox runs no cells. The door is
8 * the one a cell relays in, `openBeatDoor`, reached on its loopback port.)
9 */
10
11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12 import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { createRepo } from "../src/actions/repos.ts";
16 import { childDirOf } from "../src/actions/spawn.ts";
17 import { createApp, type App } from "../src/app.ts";
18 import { credentialEnv } from "../src/crew/runner.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { createUser } from "../src/core/users.ts";
21 import { commitFiles } from "../src/git/write.ts";
22 import { repoDir } from "../src/git/repos.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { firstBoot } from "../src/spawn/first-boot.ts";
25 import { openBeatDoor, parentLocal, type BeatDoor } from "../src/spawn/beat-door.ts";
26 import { fundedEnv, fundingOf, fundingWords, refreshFunding } from "../src/spawn/funding.ts";
27 import { listPriceUsd } from "../src/spawn/list_price.ts";
28 import { quietLogs } from "./helpers/globals.ts";
29
30 quietLogs();
31
32 const PARENT_KEY = "sk-ant-api03-PARENTS-OWN-KEY-never-lent";
33
34 describe("list price", () => {
35 test("matches a hand calculation: 200k in + 40k out on Sonnet is $0.60 + $0.60", () => {
36 // 200_000 / 1e6 * $3 = 0.6 ; 40_000 / 1e6 * $15 = 0.6
37 expect(listPriceUsd("claude-sonnet-4-5", { inputTokens: 200_000, outputTokens: 40_000, cacheWriteTokens: 0, cacheReadTokens: 0 })).toBeCloseTo(1.2, 10);
38 // Opus: 1M in $15; cache write 1M = $18.75; cache read 1M = $1.50
39 expect(listPriceUsd("claude-opus-4", { inputTokens: 1e6, outputTokens: 0, cacheWriteTokens: 1e6, cacheReadTokens: 1e6 })).toBeCloseTo(35.25, 10);
40 // A model nobody knows is priced as the dearest.
41 expect(listPriceUsd("mystery", { inputTokens: 1e6, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 })).toBe(15);
42 });
43 });
44
45 describe("a parent funds a child's crew", () => {
46 let dir = "";
47 let parent: App;
48 let child: App;
49 let childDir = "";
50 let servers: Servers;
51 let childServers: Servers;
52 let door: BeatDoor;
53 let adminRw = "";
54 let bobRw = "";
55 let childReader = "";
56 const upstreamSeen: Request[] = [];
57 const saved = process.env["FORGE_PARENT_LOCAL"];
58
59 const put = (token: string, name: string, body: unknown, headers: Record<string, string> = {}) =>
60 fetch(`${servers.url}/api/v1/forges/${name}/credit`, {
61 method: "PUT",
62 headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...headers },
63 body: JSON.stringify(body),
64 });
65 const listed = async () => {
66 const res = await fetch(`${servers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${adminRw}` } });
67 return ((await res.json()) as { items: { name: string; creditCapUsd: number | null; creditSpentUsd: number | null }[] }).items;
68 };
69 /** One crew run's model call, as Claude Code makes it from the environment the child gave it. */
70 const run = (env: Record<string, string>, stream = false) =>
71 fetch(`${env["ANTHROPIC_BASE_URL"]}/v1/messages`, {
72 method: "POST",
73 headers: { "x-api-key": env["ANTHROPIC_API_KEY"] ?? "", "content-type": "application/json", "anthropic-version": "2023-06-01" },
74 body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 8, stream, messages: [{ role: "user", content: "hi" }] }),
75 });
76
77 beforeAll(async () => {
78 dir = await mkdtemp(join(tmpdir(), "forge-child-credit-"));
79 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
80 const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true });
81 const bob = await createUser(parent.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-credit-passphrase" });
82 if (!alice.ok || !bob.ok) throw new Error("users");
83 adminRw = createToken(parent.db, alice.value.id, "rw", "rw").token;
84 bobRw = createToken(parent.db, bob.value.id, "rw", "rw").token;
85 const repo = await createRepo(parent, alice.value, { name: "bakery" });
86 if (!repo.ok) throw new Error("repo");
87 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), {
88 branch: "main",
89 message: "bakery",
90 files: [{ path: "README.md", content: "# bakery\n" }],
91 author: { name: "a", email: "[email protected]" },
92 });
93 if (!c.ok) throw new Error("commit");
94 servers = await serve(parent);
95 const made = await fetch(`${servers.url}/api/v1/forges`, {
96 method: "POST",
97 headers: { authorization: `Bearer ${adminRw}`, "content-type": "application/json" },
98 body: JSON.stringify({ name: "maria", apps: ["alice/bakery"], dryRun: false }),
99 });
100 if (made.status !== 201) throw new Error(`spawn ${made.status}`);
101 const { bodyId } = (await made.json()) as { bodyId: string };
102 childDir = childDirOf(parent, bodyId);
103 child = createApp({ dataDir: childDir, port: 0 });
104 await firstBoot(child);
105 childServers = await serve(child);
106 const reader = await createUser(child.db, { origin: "seed", trusted: true, username: "reader", email: "[email protected]", password: "a-long-credit-passphrase" });
107 if (!reader.ok) throw new Error("child user");
108 childReader = createToken(child.db, reader.value.id, "ro", "ro").token;
109 // The parent's own provider, faked: it answers with usage, and keeps what it was sent.
110 door = openBeatDoor(parent, {
111 model: {
112 upstream: () => ({ base: "https://provider.test", headers: { "x-api-key": PARENT_KEY } }),
113 fetch: async (url, init) => {
114 upstreamSeen.push(new Request(url, { method: init?.method ?? "POST", headers: init?.headers as Record<string, string> }));
115 const usage = { input_tokens: 200_000, output_tokens: 40_000 };
116 const stream = JSON.parse(new TextDecoder().decode(init?.body as ArrayBuffer)).stream === true;
117 if (!stream) return Response.json({ id: "m", type: "message", model: "claude-sonnet-4-5", content: [], usage });
118 const sse =
119 `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { model: "claude-sonnet-4-5", usage: { input_tokens: 200_000, output_tokens: 1 } } })}\n\n` +
120 `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", usage: { output_tokens: 40_000 } })}\n\n`;
121 return new Response(sse, { headers: { "content-type": "text/event-stream" } });
122 },
123 },
124 });
125 process.env["FORGE_PARENT_LOCAL"] = parentLocal(door.port);
126 }, 60_000);
127
128 afterAll(async () => {
129 if (saved === undefined) delete process.env["FORGE_PARENT_LOCAL"];
130 else process.env["FORGE_PARENT_LOCAL"] = saved;
131 door.close();
132 childServers.stop();
133 child.close();
134 servers.stop();
135 parent.close();
136 await rm(dir, { recursive: true, force: true });
137 });
138
139 test("only a site admin sets a cap, from the forge's own origin, in dollars", async () => {
140 expect((await put(bobRw, "maria", { usd: 1 })).status).toBe(403);
141 expect((await put(adminRw, "maria", { usd: 1 }, { origin: "https://evil.test" })).status).toBe(403);
142 expect((await put(adminRw, "maria", { usd: -5 })).status).toBe(422);
143 expect((await put(adminRw, "maria", { usd: "1" })).status).toBe(422);
144 expect((await put(adminRw, "nobody", { usd: 1 })).status).toBe(404);
145 expect(fundingOf(childDir)).toBeUndefined();
146 });
147
148 test("with no credit lent, the child's crew has no way to the parent's model", () => {
149 expect(fundedEnv(childDir)).toBeUndefined();
150 expect(credentialEnv({ kind: "env" }, childDir)).not.toHaveProperty("ANTHROPIC_BASE_URL", parentLocal(door.port));
151 });
152
153 test("a $1 cap: the first run reaches the model through the parent, the second is refused in one sentence", async () => {
154 const set = await put(adminRw, "maria", { usd: 1 });
155 expect(set.status).toBe(200);
156 expect(((await set.json()) as { capUsd: number }).capUsd).toBe(1);
157
158 // The child holds a bearer for the parent's door, and no provider credential anywhere.
159 const f = fundingOf(childDir)!;
160 expect(f.token.startsWith("maria.")).toBe(true);
161 for (const file of await readdir(childDir, { recursive: true })) {
162 const path = join(childDir, file);
163 const text = await readFile(path, "latin1").catch(() => "");
164 expect(text.includes(PARENT_KEY)).toBe(false);
165 }
166 const env = credentialEnv({ kind: "env" }, childDir);
167 expect(env).toEqual({ ANTHROPIC_BASE_URL: parentLocal(door.port), ANTHROPIC_API_KEY: f.token });
168 expect(JSON.stringify(env)).not.toContain(PARENT_KEY);
169
170 const first = await run(env);
171 expect(first.status).toBe(200);
172 expect(((await first.json()) as { model: string }).model).toBe("claude-sonnet-4-5");
173 // The provider saw the parent's key, never the child's bearer.
174 expect(upstreamSeen[0]!.headers.get("x-api-key")).toBe(PARENT_KEY);
175 expect(upstreamSeen[0]!.url).toBe("https://provider.test/v1/messages");
176
177 const second = await run(env);
178 expect(second.status).toBe(402);
179 const refused = (await second.json()) as { error: { message: string } };
180 expect(refused.error.message).toBe("maria's $1 from its parent is spent; the parent's admin can raise it.");
181 expect(upstreamSeen.length).toBe(1);
182 });
183
184 test("the cap and the spend read back on both forges' APIs", async () => {
185 // 200k in at $3/M + 40k out at $15/M = $1.20
186 expect((await listed()).find((i) => i.name === "maria")).toMatchObject({ creditCapUsd: 1, creditSpentUsd: 1.2 });
187 const mine = await fetch(`${childServers.url}/api/v1/credit`, { headers: { authorization: `Bearer ${childReader}` } });
188 expect(mine.status).toBe(200);
189 const said = (await mine.json()) as { capUsd: number; spentUsd: number; says: string };
190 expect(said).toMatchObject({ capUsd: 1, spentUsd: 1.2 });
191 expect(said.says).toBe("funded by its parent: $0 of $1 left");
192 expect(fundingWords({ token: "x", capUsd: 200, spentUsd: 0.5 })).toBe("funded by its parent: $199.50 of $200 left");
193 });
194
195 test("raising the cap lends more, keeps the bearer and the spend; a stream is counted as it passes", async () => {
196 const token = fundingOf(childDir)!.token;
197 expect((await put(adminRw, "maria", { usd: 5 })).status).toBe(200);
198 await refreshFunding(child);
199 expect(fundingOf(childDir)).toMatchObject({ token, capUsd: 5, spentUsd: 1.2 });
200 const env = credentialEnv({ kind: "env" }, childDir);
201 const res = await run(env, true);
202 expect(res.status).toBe(200);
203 await res.text();
204 expect((await listed()).find((i) => i.name === "maria")!.creditSpentUsd).toBe(2.4);
205 });
206
207 test("the door lends to no one without the bearer", async () => {
208 const base = parentLocal(door.port);
209 expect((await run({ ANTHROPIC_BASE_URL: base, ANTHROPIC_API_KEY: "maria.not-the-bearer-at-all" })).status).toBe(401);
210 expect((await run({ ANTHROPIC_BASE_URL: base, ANTHROPIC_API_KEY: "" })).status).toBe(401);
211 expect((await fetch(`${base}/api/v1/credit`)).status).toBe(401);
212 });
213 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs