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 #10849
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10484-a-parent-funds-a-child-forge-s-crew-a-ca → main 4 commits fork 4 threads · 4 unresolved
27 files
changed +1215
−17
docs/features/child-credit.md added +54
| @@ -0,0 +1,54 @@ | ||
| 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 | - In the terminal (a site admin, same checks as the PUT, through `lendCredit`): the palette's | |
| 13 | **Lend a child credit** (`ctrl+k`) picks a forge you sent, then asks for the dollars (`200`, `12.50`). | |
| 14 | The forge list there shows "$3.20 of $200 lent" on the child's line; on the child, **Crew agents** | |
| 15 | carries the "funded by its parent: …" line. | |
| 16 | - The parent's `/forges` row, the terminal's forge list and `GET /api/v1/forges` show | |
| 17 | `creditCapUsd` / `creditSpentUsd` ("$3.20 of $200 lent"). | |
| 18 | - The child's crew page says, in the readiness line, "…is ready to run the crew, funded by its parent: $196.80 of $200 left."; `GET /api/v1/credit` | |
| 19 | on the child answers the same (it asks the parent afresh; the page shows what the last | |
| 20 | heartbeat or ask kept). | |
| 21 | ||
| 22 | ## How it works | |
| 23 | ||
| 24 | - `src/spawn/model_door.ts`: on the beat door (the loopback port a cell relays in, | |
| 25 | `FORGE_PARENT_LOCAL`) `POST /v1/messages` checks the bearer, refuses at the cap, forwards the call | |
| 26 | on the parent's own credential (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` or | |
| 27 | `CLAUDE_CODE_OAUTH_TOKEN`, to `ANTHROPIC_BASE_URL` or api.anthropic.com), and counts the usage | |
| 28 | (JSON or streamed) at list price (`src/spawn/list_price.ts`). | |
| 29 | - `src/spawn/credit.ts`: `<data>/credit/<child>.json`, the cap, the spend and the bearer's hash. | |
| 30 | - `src/spawn/funding.ts`: the child's side. A run whose crew has no account granted gets | |
| 31 | `ANTHROPIC_BASE_URL=<parent door>` and the bearer (`credentialEnv`); an account the child granted | |
| 32 | itself still wins. | |
| 33 | - At the cap: "scottishbob's $200 from its parent is spent; the parent's admin can raise it." | |
| 34 | (HTTP 402, in Anthropic's error shape, so the run says it). | |
| 35 | ||
| 36 | ## Assumptions, stated | |
| 37 | ||
| 38 | - List price is Anthropic's published per-million-token rates by model family (Opus 15/75, Sonnet 3/15, | |
| 39 | Haiku 1/5; cache write 1.25x, cache read 0.1x); an unknown model is priced as Opus. | |
| 40 | - A call under way when the cap is reached finishes and is counted. At most 4 calls per child run at | |
| 41 | once (a fifth is answered 429 at once), so the spend can pass the cap by no more than 4 calls cost. | |
| 42 | - A request body is cut by the bytes that arrive, never by Content-Length; the bearer is checked | |
| 43 | before a byte is read (413 past 16 MB). A heartbeat must carry its signature and is read to 1 KB | |
| 44 | only. Bun holds one body limit per listener, so the listener's own limit is the model route's 16 MB. | |
| 45 | - Bearers that do not check out are counted; past 30 a minute they are answered 429 (a good | |
| 46 | bearer is never refused for them). | |
| 47 | - The hand-off comes first: the bearer is written into the child's `parent.json` before the parent | |
| 48 | saves anything, so a failed hand-off (502) leaves no credit and a retry mints again. A call for a | |
| 49 | child that holds no bearer also mints again, keeping its spend. | |
| 50 | - The audit event `forge.credit_set` is an admins' event; readers are not told the dollars. | |
| 51 | - No new egress: `children.modelEgress` is untouched; the door only reaches the one host the parent's | |
| 52 | own credential names. | |
| 53 | - Not done: a spawn-time cap field (the PUT is the one door), and a funded hosted (in-process) child, | |
| 54 | which has no door to call. |
src/actions/child_credit.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 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` and only then saves it (a failed hand-off | |
| 5 | * leaves nothing, so a retry mints again); later ones only move the cap. An | |
| 6 | * event on the outbox, so the audit has it. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { childDirOf, listChildren } from "./spawn.ts"; | |
| 11 | import { setCap, type Credit } from "../spawn/credit.ts"; | |
| 12 | import { fundingOf, keepFunding } from "../spawn/funding.ts"; | |
| 13 | import type { User } from "../core/users.ts"; | |
| 14 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 15 | import { requireUser } from "./access.ts"; | |
| 16 | ||
| 17 | export type CreditError = "unauthorized" | "forbidden" | "not_found" | "invalid_amount" | "unreachable"; | |
| 18 | ||
| 19 | export async function lendCredit( | |
| 20 | app: App, | |
| 21 | actor: User | undefined, | |
| 22 | name: string, | |
| 23 | usd: unknown, | |
| 24 | ): Promise<Result<Credit, CreditError>> { | |
| 25 | const user = requireUser(actor); | |
| 26 | if (!user.ok) return err("unauthorized"); | |
| 27 | if (!user.value.isAdmin) return err("forbidden"); | |
| 28 | const child = (await listChildren(app)).find((c) => c.name === name && c.done); | |
| 29 | if (child === undefined) return err("not_found"); | |
| 30 | if (typeof usd !== "number") return err("invalid_amount"); | |
| 31 | const dir = childDirOf(app, child.bodyId); | |
| 32 | const set = setCap(app, name, usd, fundingOf(dir) !== undefined, (token, credit) => | |
| 33 | keepFunding(dir, { token, capUsd: credit.capUsd, spentUsd: credit.spentUsd }), | |
| 34 | ); | |
| 35 | if (set === undefined) return err("invalid_amount"); | |
| 36 | if (set === "unreachable") return err("unreachable"); | |
| 37 | app.bus.append({ kind: "forge.credit_set", actorId: user.value.id, payload: { name, usd: set.capUsd } }); | |
| 38 | return ok(set); | |
| 39 | } |
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
| @@ -69,6 +69,7 @@ export { route as getApiV1Contribute } from "./getApiV1Contribute.ts"; | ||
| 69 | 69 | export { route as getApiV1ConversationFilesId } from "./getApiV1ConversationFilesId.ts"; |
| 70 | 70 | export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts"; |
| 71 | 71 | export { route as getApiV1Conversations } from "./getApiV1Conversations.ts"; |
| 72 | export { route as getApiV1Credit } from "./getApiV1Credit.ts"; | |
| 72 | 73 | export { route as getApiV1CrewNow } from "./getApiV1CrewNow.ts"; |
| 73 | 74 | export { route as getApiV1CrewScorecard } from "./getApiV1CrewScorecard.ts"; |
| 74 | 75 | export { route as getApiV1CrewWeek } from "./getApiV1CrewWeek.ts"; |
| @@ -368,6 +369,7 @@ export { route as putApiV1AdminTeamsSlug } from "./putApiV1AdminTeamsSlug.ts"; | ||
| 368 | 369 | export { route as putApiV1BoardsSlug } from "./putApiV1BoardsSlug.ts"; |
| 369 | 370 | export { route as putApiV1FeaturesSlug } from "./putApiV1FeaturesSlug.ts"; |
| 370 | 371 | export { route as putApiV1ForgeEconomics } from "./putApiV1ForgeEconomics.ts"; |
| 372 | export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts"; | |
| 371 | 373 | export { route as putApiV1Front } from "./putApiV1Front.ts"; |
| 372 | 374 | export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts"; |
| 373 | 375 | export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.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 +3 −1
| @@ -250,7 +250,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 250 | 250 | kind === "repo.file_held" || |
| 251 | 251 | kind === "repo.file_released" || |
| 252 | 252 | // Which repositories New app offers is the admins' choice (`actions/app_templates.ts`). |
| 253 | kind.startsWith("app_template.") | |
| 253 | kind.startsWith("app_template.") || | |
| 254 | // What a parent lends a child's crew, by name and dollar, is the admins' business (`actions/child_credit.ts`, #10484). | |
| 255 | kind === "forge.credit_set" | |
| 254 | 256 | ) |
| 255 | 257 | return "admins"; |
| 256 | 258 | // A broken adapter sync is for those who may write the repository (`apps/pull.ts`). |
src/crew/conversation.ts +1 −1
| @@ -513,7 +513,7 @@ function begin( | ||
| 513 | 513 | budgetUsd: budget, |
| 514 | 514 | resume: session, |
| 515 | 515 | env: { |
| 516 | ...credentialEnv(picked), | |
| 516 | ...credentialEnv(picked, app.config.dataDir), | |
| 517 | 517 | CLAUDE_CONFIG_DIR: configDir, |
| 518 | 518 | FORGE_MCP_TOKEN: minted.value.token, |
| 519 | 519 | }, |
src/crew/one_shot.ts +1 −1
| @@ -49,7 +49,7 @@ export async function oneShot(app: App, userId: string, system: string, prompt: | ||
| 49 | 49 | appendSystemPrompt: system, |
| 50 | 50 | model: model ?? options.model, |
| 51 | 51 | 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) }, | |
| 53 | 53 | sandbox: sandboxPolicy({ dataDir: app.config.dataDir, agentName: "one-shot", urls: [], enforced: false, runId }), |
| 54 | 54 | label: "one-shot", |
| 55 | 55 | timeoutMs: ONE_SHOT_MS, |
src/crew/runner.ts +6 −2
| @@ -197,6 +197,7 @@ import { | ||
| 197 | 197 | skipsFeedback, |
| 198 | 198 | } from "./guard.ts"; |
| 199 | 199 | import { passportEnv } from "./passport_env.ts"; |
| 200 | import { fundedEnv } from "../spawn/funding.ts"; | |
| 200 | 201 | |
| 201 | 202 | const log = createLog("crew"); |
| 202 | 203 | |
| @@ -1408,7 +1409,7 @@ async function work( | ||
| 1408 | 1409 | FORGE_URL: options.forgeUrl, |
| 1409 | 1410 | FORGE_TOKEN: token.token, |
| 1410 | 1411 | ...passportEnv(app.config.dataDir), |
| 1411 | ...credentialEnv(picked), | |
| 1412 | ...credentialEnv(picked, app.config.dataDir), | |
| 1412 | 1413 | ...grant.env, |
| 1413 | 1414 | ...machineEnv(machine), |
| 1414 | 1415 | ...(options.configDir === undefined |
| @@ -2340,8 +2341,11 @@ function redactResult<T extends { result: string }>(r: T): T { | ||
| 2340 | 2341 | * the account has one), or — with nothing granted — whatever the forge |
| 2341 | 2342 | * itself was started with. |
| 2342 | 2343 | */ |
| 2343 | export function credentialEnv(picked: Pick): Record<string, string> { | |
| 2344 | export function credentialEnv(picked: Pick, dataDir?: string): Record<string, string> { | |
| 2344 | 2345 | 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; | |
| 2345 | 2349 | const own: Record<string, string> = {}; |
| 2346 | 2350 | for (const name of [ |
| 2347 | 2351 | "ANTHROPIC_API_KEY", |
src/http/api/forges.ts +29
| @@ -4,7 +4,10 @@ | ||
| 4 | 4 | * link. Moved out of `api.ts` (#6331); the rules live in `actions/spawn.ts` and `spawn/`. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { lendCredit } from "../../actions/child_credit.ts"; | |
| 7 | 8 | import { listChildren } from "../../actions/spawn.ts"; |
| 9 | import { usdWords } from "../../spawn/credit.ts"; | |
| 10 | import { fundingOf, fundingWords, refreshFunding } from "../../spawn/funding.ts"; | |
| 8 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 9 | 12 | import { clientRows } from "../../spawn/clients.ts"; |
| 10 | 13 | import { reissueDoor, spawnDoor } from "../../spawn/door.ts"; |
| @@ -71,6 +74,32 @@ export function forgeRoutes(router: Router<Handler>): void { | ||
| 71 | 74 | return json(door.body, door.status); |
| 72 | 75 | }); |
| 73 | 76 | |
| 77 | // The dollars this forge lends a child on its own model credential (#10484): a site admin sets the cap. | |
| 78 | router.add("PUT", "/api/v1/forges/:name/credit", async (ctx) => { | |
| 79 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden(); | |
| 80 | const denied = requireScope(ctx, "rw"); | |
| 81 | if (denied) return denied; | |
| 82 | const input = await body(ctx.request); | |
| 83 | const name = ctx.params["name"] ?? ""; | |
| 84 | const set = await lendCredit(ctx.app, ctx.auth.user, name, input["usd"]); | |
| 85 | if (set.ok) | |
| 86 | return json({ name, capUsd: set.value.capUsd, spentUsd: set.value.spentUsd, says: `${name} may spend ${usdWords(set.value.capUsd)} of this forge's credit.` }); | |
| 87 | if (set.error === "unauthorized" || set.error === "forbidden") return forbidden(); | |
| 88 | if (set.error === "not_found") return apiError("not_found", 404, "no such forge"); | |
| 89 | if (set.error === "unreachable") return apiError("unreachable", 502, "the child's data could not be reached"); | |
| 90 | return apiError("invalid", 422, "usd is dollars above 0 and up to 100000"); | |
| 91 | }); | |
| 92 | ||
| 93 | // On a forge its parent funds: how much is left, as the parent last said (asked afresh first). | |
| 94 | router.add("GET", "/api/v1/credit", async (ctx) => { | |
| 95 | const denied = requireScope(ctx, "ro"); | |
| 96 | if (denied) return denied; | |
| 97 | if (ctx.auth.user === undefined) return forbidden(); | |
| 98 | const f = fundingOf(ctx.app.config.dataDir) === undefined ? undefined : await refreshFunding(ctx.app); | |
| 99 | if (f === undefined) return apiError("not_found", 404, "no parent funds this forge"); | |
| 100 | return json({ capUsd: f.capUsd, spentUsd: f.spentUsd, says: fundingWords(f) }); | |
| 101 | }); | |
| 102 | ||
| 74 | 103 | // A new claim link for a child nobody has claimed (#729); the old one dies. |
| 75 | 104 | router.add("POST", "/api/v1/forges/:name/claim-link", async (ctx) => { |
| 76 | 105 | const denied = requireScope(ctx, "rw"); |
src/spawn/beat-door.ts +17 −7
| @@ -1,13 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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 | |
| 4 | 5 | * only this port in, so untrusted child code never reaches the parent's web |
| 5 | 6 | * surface, and since the door never asks who the client is, no forwarding |
| 6 | 7 | * header it sends counts for anything. |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import type { App } from "../app.ts"; |
| 11 | import { readCapped } from "./bounded_read.ts"; | |
| 10 | 12 | import { receiveBeat, SIGNATURE_HEADER } from "./heartbeat.ts"; |
| 13 | import { creditAnswer, lendModel, MODEL_BODY_MAX, type ModelDeps } from "./model_door.ts"; | |
| 11 | 14 | |
| 12 | 15 | const MAX_BODY = 1024; |
| 13 | 16 | |
| @@ -22,17 +25,24 @@ export interface BeatDoor { | ||
| 22 | 25 | close(): void; |
| 23 | 26 | } |
| 24 | 27 | |
| 25 | export function openBeatDoor(app: App): BeatDoor { | |
| 28 | export function openBeatDoor(app: App, deps: { readonly model?: ModelDeps } = {}): BeatDoor { | |
| 26 | 29 | const server = Bun.serve({ |
| 27 | 30 | port: 0, |
| 28 | 31 | hostname: "127.0.0.1", |
outdated
16 MB for every route including the unauthenticated heartbeat, which needs 1 KB. Keep the door small and cap each route by bytes read. |
||
| 29 | maxRequestBodySize: MAX_BODY, | |
| 32 | // Bun holds one limit per listener, and the model route needs the larger; each route counts the bytes it reads (`readCapped`). | |
| 33 | maxRequestBodySize: MODEL_BODY_MAX, | |
| 30 | 34 | async fetch(req) { |
| 31 | 35 | const refused = new Response("no such door", { status: 404 }); |
| 32 | if (req.method !== "POST" || new URL(req.url).pathname !== "/api/v1/heartbeat") return refused; | |
| 33 | const raw = await req.text(); | |
| 34 | if (raw.length > MAX_BODY) return refused; | |
| 35 | const kept = await receiveBeat(app, raw, req.headers.get(SIGNATURE_HEADER)); | |
| 36 | const path = new URL(req.url).pathname; | |
| 37 | if (req.method === "POST" && path === "/v1/messages") return lendModel(app, req, deps.model); | |
| 38 | if (req.method === "GET" && path === "/api/v1/credit") return creditAnswer(app, req); | |
outdated
Content-Length is client-written; it is not a cap. Count the bytes actually read. |
||
| 39 | if (req.method !== "POST" || path !== "/api/v1/heartbeat") return refused; | |
| 40 | // A beat is signed and small: no signature, no read; then at most MAX_BODY bytes, counted as they arrive. | |
| 41 | const signature = req.headers.get(SIGNATURE_HEADER); | |
| 42 | if (signature === null) return refused; | |
| 43 | const bytes = await readCapped(req, MAX_BODY); | |
| 44 | if (bytes === undefined) return refused; | |
| 45 | const kept = await receiveBeat(app, new TextDecoder().decode(bytes), signature); | |
| 36 | 46 | return kept ? Response.json({ ok: true }) : refused; |
| 37 | 47 | }, |
| 38 | 48 | }); |
src/spawn/bounded_read.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** | |
| 2 | * A request body read by the bytes that actually arrive. A Content-Length | |
| 3 | * is the client's claim and a chunked body makes none, so the stream is | |
| 4 | * counted and the read stops one chunk past the limit: nothing past `max` | |
| 5 | * is ever held. Undefined past the limit or when the client goes away. | |
| 6 | */ | |
| 7 | ||
| 8 | export async function readCapped(req: Request, max: number): Promise<Uint8Array | undefined> { | |
| 9 | if (req.body === null) return new Uint8Array(); | |
| 10 | const reader = req.body.getReader(); | |
| 11 | const chunks: Uint8Array[] = []; | |
| 12 | let size = 0; | |
| 13 | try { | |
| 14 | for (;;) { | |
| 15 | const { done, value } = await reader.read(); | |
| 16 | if (done) break; | |
| 17 | size += value.byteLength; | |
| 18 | if (size > max) { | |
| 19 | await reader.cancel().catch(() => undefined); | |
| 20 | return undefined; | |
| 21 | } | |
| 22 | chunks.push(value); | |
| 23 | } | |
| 24 | } catch { | |
| 25 | return undefined; | |
| 26 | } | |
| 27 | return Buffer.concat(chunks); | |
| 28 | } |
src/spawn/clients.ts +10
| @@ -13,6 +13,7 @@ import type { App } from "../app.ts"; | ||
| 13 | 13 | import { childDirOf, listChildren, statusTokenOf, type Child } from "../actions/spawn.ts"; |
| 14 | 14 | import { timingSafeEqual } from "node:crypto"; |
| 15 | 15 | import { sha256 } from "../kernel/hash.ts"; |
| 16 | import { creditView, usdWords } from "./credit.ts"; | |
| 16 | 17 | import type { ChildAppsSummary } from "./child-apps.ts"; |
| 17 | 18 | import { childAwake, hostedChildApps } from "./host.ts"; |
| 18 | 19 | import { hasAdmin, linkOf, spawnedOrg, type LinkInfo, type LinkState } from "./claim.ts"; |
| @@ -54,6 +55,9 @@ export interface ClientRow { | ||
| 54 | 55 | readonly spendTodayUsd: number | null; |
| 55 | 56 | /** The child's forge cap in dollars; null when none or unsaid. */ |
| 56 | 57 | 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; | |
| 57 | 61 | /** Its size (`spawn/sizes.ts`). */ |
| 58 | 62 | readonly size: Size; |
| 59 | 63 | /** 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[]> { | ||
| 231 | 235 | reach: reachOf(app, c.name) ?? null, |
| 232 | 236 | spendTodayUsd: beats[i]?.beat.spendTodayUsd ?? null, |
| 233 | 237 | ceilingUsd: beats[i]?.beat.ceilingUsd ?? null, |
| 238 | ...creditView(app, c.name), | |
| 234 | 239 | size: c.size, |
| 235 | 240 | use: forgeUse(c, childDirOf(app, c.bodyId)), |
| 236 | 241 | lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(), |
| @@ -310,6 +315,11 @@ export function runningWords(r: ClientRow): string | null { | ||
| 310 | 315 | |
| 311 | 316 | const money = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixed(2)); |
| 312 | 317 | |
| 318 | /** "$3.20 of $200 lent"; null when this forge lends the child nothing. */ | |
| 319 | export function creditWords(r: Pick<ClientRow, "creditCapUsd" | "creditSpentUsd">): string | null { | |
| 320 | return r.creditCapUsd === null || r.creditCapUsd === undefined ? null : `${usdWords(r.creditSpentUsd ?? 0)} of ${usdWords(r.creditCapUsd)} lent`; | |
| 321 | } | |
| 322 | ||
| 313 | 323 | /** |
| 314 | 324 | * "$3.20 of $10 today", with "ceiling reached" at the cap; `warn` from 80%. |
| 315 | 325 | * Null when the child has not reported spend. |
src/spawn/credit.ts added +107
| @@ -0,0 +1,107 @@ | ||
| 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. When the child holds no bearer (the first time, or its | |
| 51 | * copy is lost) one is minted and `handOff` must put it in the child's hands | |
| 52 | * before anything is saved: if it cannot, nothing is, and the next call mints | |
| 53 | * again. The spend already counted is never reset. Undefined for a sum that | |
| 54 | * is not dollars; "unreachable" when the hand-off failed. | |
| 55 | */ | |
| 56 | export function setCap( | |
| 57 | app: App, | |
| 58 | name: string, | |
| 59 | usd: number, | |
| 60 | childHolds: boolean, | |
| 61 | handOff: (token: string, credit: Credit) => boolean, | |
| 62 | ): Credit | "unreachable" | undefined { | |
| 63 | if (!isChildName(name) || !(Number.isFinite(usd) && usd > 0 && usd <= CREDIT_MAX_USD)) return undefined; | |
| 64 | const before = readCredit(app, name); | |
| 65 | if (before !== undefined && childHolds) { | |
| 66 | const credit = { ...before, capUsd: cents(usd) }; | |
| 67 | keep(app, credit); | |
| 68 | return credit; | |
| 69 | } | |
| 70 | const token = `${name}.${randomBytes(24).toString("base64url")}`; | |
| 71 | const credit = { name, capUsd: cents(usd), spentUsd: before?.spentUsd ?? 0, tokenHash: sha(token) }; | |
| 72 | if (!handOff(token, credit)) return "unreachable"; | |
| 73 | keep(app, credit); | |
| 74 | return credit; | |
| 75 | } | |
| 76 | ||
| 77 | /** The child a bearer was minted for, or undefined. The name rides in the bearer; the hash decides. */ | |
| 78 | export function creditOfToken(app: App, token: string): Credit | undefined { | |
| 79 | const name = token.split(".")[0] ?? ""; | |
| 80 | const c = readCredit(app, name); | |
| 81 | if (c === undefined) return undefined; | |
| 82 | const a = Buffer.from(sha(token)); | |
| 83 | const b = Buffer.from(c.tokenHash); | |
| 84 | return a.length === b.length && timingSafeEqual(a, b) ? c : undefined; | |
| 85 | } | |
| 86 | ||
| 87 | /** Count dollars spent against a child's cap. */ | |
| 88 | export function addSpend(app: App, name: string, usd: number): void { | |
| 89 | const c = readCredit(app, name); | |
| 90 | if (c === undefined || !(usd > 0) || !Number.isFinite(usd)) return; | |
| 91 | keep(app, { ...c, spentUsd: Math.round((c.spentUsd + usd) * 1e6) / 1e6 }); | |
| 92 | } | |
| 93 | ||
| 94 | export const leftUsd = (c: Pick<Credit, "capUsd" | "spentUsd">): number => Math.max(0, cents(c.capUsd - c.spentUsd)); | |
| 95 | ||
| 96 | /** Whole dollars read plainly, others to the cent. */ | |
| 97 | export const usdWords = (n: number): string => `$${Number.isInteger(n) ? n : n.toFixed(2)}`; | |
| 98 | ||
| 99 | /** The one sentence a call past the cap is refused with. */ | |
| 100 | export const spentWords = (name: string, capUsd: number): string => | |
| 101 | `${name}'s ${usdWords(capUsd)} from its parent is spent; the parent's admin can raise it.`; | |
| 102 | ||
| 103 | /** What the parent lists for a child: cap and spend to the cent, or nulls when no credit was set. */ | |
| 104 | export function creditView(app: App, name: string): { creditCapUsd: number | null; creditSpentUsd: number | null } { | |
| 105 | const c = readCredit(app, name); | |
| 106 | return c === undefined ? { creditCapUsd: null, creditSpentUsd: null } : { creditCapUsd: c.capUsd, creditSpentUsd: cents(c.spentUsd) }; | |
| 107 | } |
src/spawn/door_guard.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | /** | |
| 2 | * What keeps the lent model door from being spent past its cap or hammered | |
| 3 | * (#10484). The cap is checked before a call and counted after it, so calls | |
| 4 | * in flight at once would each pass the check: a child may have at most | |
| 5 | * `MAX_IN_FLIGHT` calls under way, and the spend can pass its cap by no more | |
| 6 | * than that many calls cost. Bad bearers are counted too: past | |
| 7 | * `BAD_PER_MINUTE` a minute, a bearer that does not check out is answered | |
| 8 | * "slow down" and nothing more (a good bearer is never turned away by them). | |
| 9 | * Process memory only; a restart empties it, and no call is then in flight. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { App } from "../app.ts"; | |
| 13 | ||
| 14 | export const MAX_IN_FLIGHT = 4; | |
| 15 | export const BAD_PER_MINUTE = 30; | |
| 16 | const MINUTE = 60_000; | |
| 17 | ||
| 18 | interface Guard { | |
| 19 | readonly inflight: Map<string, number>; | |
| 20 | bad: number[]; | |
| 21 | } | |
| 22 | ||
| 23 | const guards = new WeakMap<App, Guard>(); | |
| 24 | ||
| 25 | function guardOf(app: App): Guard { | |
| 26 | let g = guards.get(app); | |
| 27 | if (g === undefined) { | |
| 28 | g = { inflight: new Map(), bad: [] }; | |
| 29 | guards.set(app, g); | |
| 30 | } | |
| 31 | return g; | |
| 32 | } | |
| 33 | ||
| 34 | /** Take one place for `name`; the release (once only) gives it back. Undefined when all are taken. */ | |
| 35 | export function enter(app: App, name: string): (() => void) | undefined { | |
| 36 | const g = guardOf(app); | |
| 37 | const n = g.inflight.get(name) ?? 0; | |
| 38 | if (n >= MAX_IN_FLIGHT) return undefined; | |
| 39 | g.inflight.set(name, n + 1); | |
| 40 | let released = false; | |
| 41 | return () => { | |
| 42 | if (released) return; | |
| 43 | released = true; | |
| 44 | const left = (g.inflight.get(name) ?? 1) - 1; | |
| 45 | if (left <= 0) g.inflight.delete(name); | |
| 46 | else g.inflight.set(name, left); | |
| 47 | }; | |
| 48 | } | |
| 49 | ||
| 50 | /** A bearer that did not check out. True when there have been too many of these this minute. */ | |
| 51 | export function noteBad(app: App, now: number = Date.now()): boolean { | |
| 52 | const g = guardOf(app); | |
| 53 | g.bad = g.bad.filter((t) => now - t < MINUTE); | |
| 54 | if (g.bad.length < BAD_PER_MINUTE * 2) g.bad.push(now); | |
| 55 | return g.bad.length > BAD_PER_MINUTE; | |
| 56 | } |
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"; | ||
| 22 | 22 | import { shippedReleases } from "../release/shipped.ts"; |
| 23 | 23 | import { forgeLinkOf, forgeTipOf, FOLLOW_MODES, type FollowMode, type ForgeLink } from "./chain.ts"; |
| 24 | 24 | import { isParentLocal } from "./beat-door.ts"; |
| 25 | import { refreshFunding } from "./funding.ts"; | |
| 25 | 26 | import { upstreamState } from "./upstream.ts"; |
| 26 | 27 | import { progress } from "../core/next-steps.ts"; |
| 27 | 28 | import { running } from "../kernel/version.ts"; |
| @@ -314,6 +315,7 @@ export async function sendBeat( | ||
| 314 | 315 | signal: AbortSignal.timeout(5000), |
| 315 | 316 | redirect: "error", |
| 316 | 317 | }); |
| 318 | if (res.ok) await refreshFunding(app, fetcher); | |
| 317 | 319 | return res.ok; |
| 318 | 320 | } catch { |
| 319 | 321 | 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 +197
| @@ -0,0 +1,197 @@ | ||
| 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, and at most | |
| 11 | * `MAX_IN_FLIGHT` calls run at once per child (`spawn/door_guard.ts`), so the | |
| 12 | * spend can pass the cap by no more than what those calls cost. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { App } from "../app.ts"; | |
| 16 | import { addSpend, creditOfToken, leftUsd, readCredit, spentWords, type Credit } from "./credit.ts"; | |
| 17 | import { listPriceUsd, NO_USAGE, type Usage } from "./list_price.ts"; | |
| 18 | import { readCapped } from "./bounded_read.ts"; | |
| 19 | import { enter, MAX_IN_FLIGHT, noteBad } from "./door_guard.ts"; | |
| 20 | ||
| 21 | type FetchLike = (url: string, init?: RequestInit) => Promise<Response>; | |
| 22 | ||
| 23 | /** Where the parent's own credential points: a base URL and the headers that carry it. */ | |
| 24 | export interface ModelUpstream { | |
| 25 | readonly base: string; | |
| 26 | readonly headers: Record<string, string>; | |
| 27 | } | |
| 28 | ||
| 29 | export interface ModelDeps { | |
| 30 | readonly fetch?: FetchLike; | |
| 31 | readonly upstream?: () => ModelUpstream | undefined; | |
| 32 | } | |
| 33 | ||
| 34 | /** The parent's own credential from its environment, never from a request. */ | |
| 35 | export function ownUpstream(): ModelUpstream | undefined { | |
| 36 | const base = (process.env["ANTHROPIC_BASE_URL"] ?? "https://api.anthropic.com").replace(/\/+$/, ""); | |
| 37 | const key = process.env["ANTHROPIC_API_KEY"]; | |
| 38 | const auth = process.env["ANTHROPIC_AUTH_TOKEN"]; | |
| 39 | const oauth = process.env["CLAUDE_CODE_OAUTH_TOKEN"]; | |
| 40 | if (key !== undefined && key !== "") return { base, headers: { "x-api-key": key } }; | |
| 41 | if (auth !== undefined && auth !== "") return { base, headers: { authorization: `Bearer ${auth}` } }; | |
| 42 | if (oauth !== undefined && oauth !== "") | |
| 43 | return { base, headers: { authorization: `Bearer ${oauth}`, "anthropic-beta": "oauth-2025-04-20" } }; | |
| 44 | return undefined; | |
| 45 | } | |
| 46 | ||
| 47 | export const MODEL_BODY_MAX = 16 * 1024 * 1024; | |
| 48 | ||
| 49 | const refuse = (status: number, type: string, message: string): Response => | |
| 50 | Response.json({ type: "error", error: { type, message } }, { status }); | |
| 51 | ||
| 52 | function bearerOf(req: Request): string { | |
| 53 | const auth = req.headers.get("authorization") ?? ""; | |
| 54 | const m = /^Bearer\s+(\S{8,200})$/i.exec(auth); | |
| 55 | return m?.[1] ?? req.headers.get("x-api-key") ?? ""; | |
| 56 | } | |
| 57 | ||
| 58 | const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0); | |
| 59 | ||
| 60 | function usageOf(u: unknown, into: Usage): Usage { | |
| 61 | if (typeof u !== "object" || u === null) return into; | |
| 62 | const r = u as Record<string, unknown>; | |
| 63 | return { | |
| 64 | inputTokens: "input_tokens" in r ? num(r["input_tokens"]) : into.inputTokens, | |
| 65 | outputTokens: "output_tokens" in r ? num(r["output_tokens"]) : into.outputTokens, | |
| 66 | cacheWriteTokens: "cache_creation_input_tokens" in r ? num(r["cache_creation_input_tokens"]) : into.cacheWriteTokens, | |
| 67 | cacheReadTokens: "cache_read_input_tokens" in r ? num(r["cache_read_input_tokens"]) : into.cacheReadTokens, | |
| 68 | }; | |
| 69 | } | |
| 70 | ||
| 71 | /** Reads usage out of a server-sent stream as it passes: `message_start` carries the input, `message_delta` the output so far. */ | |
| 72 | class Tally { | |
| 73 | usage: Usage = NO_USAGE; | |
| 74 | model = ""; | |
| 75 | private buf = ""; | |
| 76 | private readonly dec = new TextDecoder(); | |
| 77 | ||
| 78 | feed(chunk: Uint8Array): void { | |
| 79 | this.buf += this.dec.decode(chunk, { stream: true }); | |
| 80 | let at: number; | |
| 81 | while ((at = this.buf.indexOf("\n")) >= 0) { | |
| 82 | const line = this.buf.slice(0, at).trimEnd(); | |
| 83 | this.buf = this.buf.slice(at + 1); | |
| 84 | if (!line.startsWith("data:")) continue; | |
| 85 | try { | |
| 86 | const ev = JSON.parse(line.slice(5)) as { type?: unknown; message?: { model?: unknown; usage?: unknown }; usage?: unknown }; | |
| 87 | if (ev.type === "message_start") { | |
| 88 | if (typeof ev.message?.model === "string") this.model = ev.message.model; | |
| 89 | this.usage = usageOf(ev.message?.usage, this.usage); | |
| 90 | } else if (ev.type === "message_delta") this.usage = usageOf(ev.usage, this.usage); | |
| 91 | } catch { | |
| 92 | // not an event we count | |
| 93 | } | |
| 94 | } | |
| 95 | if (this.buf.length > 1_000_000) this.buf = ""; | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | /** Counts a reply as it passes and gives the call's place back when it ends, however it ends. */ | |
| 100 | function meter(app: App, credit: Credit, fetched: Response, release: () => void): Response { | |
| 101 | const type = fetched.headers.get("content-type") ?? ""; | |
| 102 | const headers = new Headers({ "content-type": type || "application/json" }); | |
| 103 | // A refusal or an error from the provider is not a call that was served: nothing is charged. | |
| 104 | if (!fetched.ok || fetched.body === null) { | |
| 105 | release(); | |
| 106 | return new Response(fetched.body, { status: fetched.status, headers }); | |
| 107 | } | |
| 108 | const streamed = type.includes("text/event-stream"); | |
| 109 | const reader = fetched.body.getReader(); | |
| 110 | const tally = new Tally(); | |
| 111 | const dec = new TextDecoder(); | |
| 112 | let text = ""; | |
| 113 | let done = false; | |
| 114 | const finish = (): void => { | |
| 115 | if (done) return; | |
| 116 | done = true; | |
| 117 | release(); | |
| 118 | if (streamed) return void addSpend(app, credit.name, listPriceUsd(tally.model, tally.usage)); | |
| 119 | try { | |
| 120 | const doc = JSON.parse(text) as { model?: unknown; usage?: unknown }; | |
| 121 | addSpend(app, credit.name, listPriceUsd(typeof doc.model === "string" ? doc.model : "", usageOf(doc.usage, NO_USAGE))); | |
| 122 | } catch { | |
| 123 | // an unreadable answer is not counted; the provider's own ledger is the check | |
| 124 | } | |
| 125 | }; | |
| 126 | return new Response( | |
| 127 | new ReadableStream<Uint8Array>({ | |
| 128 | async pull(c) { | |
| 129 | try { | |
| 130 | const { done: end, value } = await reader.read(); | |
| 131 | if (end) { | |
| 132 | finish(); | |
| 133 | c.close(); | |
| 134 | return; | |
| 135 | } | |
| 136 | if (streamed) tally.feed(value); | |
| 137 | else if (text.length < MODEL_BODY_MAX) text += dec.decode(value, { stream: true }); | |
| 138 | c.enqueue(value); | |
| 139 | } catch (e) { | |
| 140 | finish(); | |
| 141 | c.error(e); | |
| 142 | } | |
| 143 | }, | |
| 144 | // The client left: what the provider already said is still owed. | |
| 145 | cancel(why) { | |
| 146 | finish(); | |
| 147 | return reader.cancel(why); | |
| 148 | }, | |
| 149 | }), | |
| 150 | { status: fetched.status, headers }, | |
| 151 | ); | |
| 152 | } | |
| 153 | ||
| 154 | /** | |
| 155 | * `POST /v1/messages`: one lent model call. The bearer is checked before a | |
| 156 | * byte of the body is read; the body is then read by bytes arrived, at most | |
| 157 | * `MODEL_BODY_MAX`. | |
| 158 | */ | |
| 159 | export async function lendModel(app: App, req: Request, deps: ModelDeps = {}): Promise<Response> { | |
| 160 | const credit = creditOfToken(app, bearerOf(req)); | |
| 161 | if (credit === undefined) | |
| 162 | return noteBad(app) ? refuse(429, "rate_limit_error", "too many keys this forge does not fund; slow down") : refuse(401, "authentication_error", "this forge does not fund that key"); | |
| 163 | if (readCredit(app, credit.name)!.spentUsd >= credit.capUsd) | |
| 164 | return refuse(402, "billing_error", spentWords(credit.name, credit.capUsd)); | |
| 165 | const own = (deps.upstream ?? ownUpstream)(); | |
| 166 | if (own === undefined) return refuse(503, "api_error", "the parent has no model credential to lend"); | |
| 167 | // Checked and taken with no await between, so parallel calls cannot all pass. | |
| 168 | const release = enter(app, credit.name); | |
| 169 | if (release === undefined) return refuse(429, "rate_limit_error", `${credit.name} has ${MAX_IN_FLIGHT} calls under way; wait for one to finish`); | |
| 170 | let handed = false; | |
| 171 | try { | |
| 172 | const raw = await readCapped(req, MODEL_BODY_MAX); | |
| 173 | if (raw === undefined) return refuse(413, "invalid_request_error", "request too large"); | |
| 174 | const headers: Record<string, string> = { "content-type": "application/json", "anthropic-version": req.headers.get("anthropic-version") ?? "2023-06-01" }; | |
| 175 | const beta = [req.headers.get("anthropic-beta"), own.headers["anthropic-beta"]].filter((b): b is string => !!b && /^[\w.,-]{1,300}$/.test(b)); | |
| 176 | Object.assign(headers, own.headers); | |
| 177 | if (beta.length > 0) headers["anthropic-beta"] = beta.join(","); | |
| 178 | let fetched: Response; | |
| 179 | try { | |
| 180 | fetched = await (deps.fetch ?? fetch)(`${own.base}/v1/messages`, { method: "POST", headers, body: raw, redirect: "error" }); | |
| 181 | } catch { | |
| 182 | return refuse(502, "api_error", "the parent could not reach its model provider"); | |
| 183 | } | |
| 184 | handed = true; | |
| 185 | return meter(app, credit, fetched, release); | |
| 186 | } finally { | |
| 187 | if (!handed) release(); | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | /** `GET /api/v1/credit`: what the bearer's child may spend and has. */ | |
| 192 | export function creditAnswer(app: App, req: Request): Response { | |
| 193 | const credit = creditOfToken(app, bearerOf(req)); | |
| 194 | if (credit === undefined) | |
| 195 | return noteBad(app) ? refuse(429, "rate_limit_error", "too many keys this forge does not fund; slow down") : refuse(401, "authentication_error", "this forge does not fund that key"); | |
| 196 | return Response.json({ capUsd: credit.capUsd, spentUsd: credit.spentUsd, leftUsd: leftUsd(credit) }); | |
| 197 | } |
src/tui/screens/agents.ts +4 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { checkPatch, createAgent, deleteAgent, listAllAgents, updateAgent } from "../../actions/crew.ts"; |
| 12 | import { fundingOf, fundingWords } from "../../spawn/funding.ts"; | |
| 12 | 13 | import { CREW_ROLES, type AgentPatch } from "../../core/agents.ts"; |
| 13 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 14 | 15 | import { binding } from "../app/keys.ts"; |
| @@ -52,7 +53,9 @@ export function agentsScreen(f: Session, ctx: Ctx): Screen { | ||
| 52 | 53 | render(area, s) { |
| 53 | 54 | const c = ctx.theme.c; |
| 54 | 55 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| 55 | const text = note !== "" ? note : allowed() ? "The crew's settings; each change asks first." : "Agents are set by a forge admin."; | |
| 56 | const funded = fundingOf(f.app.config.dataDir); | |
| 57 | const base = allowed() ? "The crew's settings; each change asks first." : "Agents are set by a forge admin."; | |
| 58 | const text = note !== "" ? note : funded === undefined ? base : `${base} ${fundingWords(funded)}`; | |
| 56 | 59 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 57 | 60 | drawList(s, body!, c, state, rows()); |
| 58 | 61 | }, |
src/tui/screens/childcredit.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * A site admin sets what a child forge may spend of this forge's model | |
| 3 | * credential, in the terminal (#10484): pick a forge it sent, type the | |
| 4 | * dollars. The rule is the action's (`lendCredit`), the same as the API's | |
| 5 | * `PUT /api/v1/forges/:name/credit`: admins only, a sum above 0 and up to | |
| 6 | * 100000, the child's bearer handed over before anything is saved. | |
| 7 | */ | |
| 8 | ||
| 9 | import { lendCredit, type CreditError } from "../../actions/child_credit.ts"; | |
| 10 | import { usdWords } from "../../spawn/credit.ts"; | |
| 11 | import { clientRows } from "../../spawn/clients.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import type { Session } from "../session.ts"; | |
| 14 | import { blank, pickerScreen, promptScreen, runAsync } from "./common.ts"; | |
| 15 | import type { PaletteEntry } from "./entries.ts"; | |
| 16 | ||
| 17 | const WORDS: Record<CreditError, string> = { | |
| 18 | unauthorized: "sign in first", | |
| 19 | forbidden: "only a site admin lends credit", | |
| 20 | not_found: "no such forge", | |
| 21 | invalid_amount: "dollars above 0 and up to 100000, like 200 or 12.50", | |
| 22 | unreachable: "the child's data could not be reached; nothing was saved", | |
| 23 | }; | |
| 24 | ||
| 25 | /** "200", "12.50": plain dollars, nothing else, else NaN (the action refuses it). */ | |
| 26 | export const dollarsOf = (raw: string): number => (/^\d{1,6}(\.\d{1,2})?$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN); | |
| 27 | ||
| 28 | export function lendScreen(f: Session, ctx: Ctx, parent: Screen, forges: readonly { name: string; hint: string }[]): Screen { | |
| 29 | return pickerScreen(ctx, parent, { | |
| 30 | title: "Lend credit to which forge?", | |
| 31 | items: forges.map((c) => ({ id: c.name, label: c.name, hint: c.hint })), | |
| 32 | onPick: (ids) => { | |
| 33 | const name = ids[0]; | |
| 34 | if (name === undefined) return; | |
| 35 | ctx.push( | |
| 36 | promptScreen(ctx, parent, { | |
| 37 | title: `Credit for ${name}`, | |
| 38 | label: "dollars it may spend on this forge's credential", | |
| 39 | onSubmit: (raw) => | |
| 40 | lendCredit(f.app, f.user, name, dollarsOf(raw)).then((done) => | |
| 41 | ctx.toast(done.ok ? `${name} may spend ${usdWords(done.value.capUsd)} of this forge's credit.` : `${name}: ${WORDS[done.error]}`, done.ok ? "success" : "danger"), | |
| 42 | ), | |
| 43 | }), | |
| 44 | ); | |
| 45 | }, | |
| 46 | }); | |
| 47 | } | |
| 48 | ||
| 49 | export const entry: PaletteEntry = { | |
| 50 | id: "lend-credit", | |
| 51 | title: "Lend a child credit", | |
| 52 | hint: "set the dollars a forge you sent may spend on this one's model credential", | |
| 53 | after: ["forges"], | |
| 54 | offered: (session) => session.user?.isAdmin === true, | |
| 55 | run: (session, ctx) => | |
| 56 | runAsync(ctx, async () => { | |
| 57 | const rows = (await clientRows(session.app)).filter((r) => r.made); | |
| 58 | if (rows.length === 0) return void ctx.toast("You have not sent a forge yet"); | |
| 59 | ctx.push(lendScreen(session, ctx, blank("Lend credit"), rows.map((r) => ({ name: r.name, hint: r.creditCapUsd == null ? "no credit lent" : `${usdWords(r.creditCapUsd)} lent` })))); | |
| 60 | }), | |
| 61 | }; |
src/tui/screens/entries.ts +2
| @@ -20,6 +20,7 @@ import { entry as bets } from "./bets.ts"; | ||
| 20 | 20 | import { entry as boards } from "./boards.ts"; |
| 21 | 21 | import { entry as bugs } from "./bugs.ts"; |
| 22 | 22 | import { entry as bulk } from "./bulk.ts"; |
| 23 | import { entry as childCredit } from "./childcredit.ts"; | |
| 23 | 24 | import { entry as cloud } from "./cloud.ts"; |
| 24 | 25 | import { entry as connections } from "./connections.ts"; |
| 25 | 26 | import { entry as crewHandover } from "./crewhandover.ts"; |
| @@ -146,6 +147,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 146 | 147 | front, |
| 147 | 148 | owner, |
| 148 | 149 | forges, |
| 150 | childCredit, | |
| 149 | 151 | originAhead, |
| 150 | 152 | profile, |
| 151 | 153 | dotfiles, |
src/tui/screens/forges.ts +2 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { Ctx, Screen } from "../app/app.ts"; |
| 8 | 8 | 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"; | |
| 10 | 10 | import { useLine } from "../../spawn/usage.ts"; |
| 11 | 11 | import { timeAgo } from "../../web/ui.ts"; |
| 12 | 12 | import { card } from "../ui/widgets.ts"; |
| @@ -23,7 +23,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] { | ||
| 23 | 23 | if (rows.length === 0) return [["", "You have not sent a forge yet"]]; |
| 24 | 24 | return rows.flatMap((r): [string, string][] => [[ |
| 25 | 25 | 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)}`] | |
| 27 | 27 | .filter((m): m is string => m !== null) |
| 28 | 28 | .join(" · "), |
| 29 | 29 | ], ...addressLines(r)]); |
src/web/pages/crew.ts +4 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * the crew is actually there, in words, before anyone assigns anything. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { fundingOf, fundingWords } from "../../spawn/funding.ts"; | |
| 9 | 10 | import { |
| 10 | 11 | createAgent, |
| 11 | 12 | deleteAgent, |
| @@ -142,8 +143,10 @@ export function crewReadiness(ctx: Ctx): Html { | ||
| 142 | 143 | if (!crew.ready) |
| 143 | 144 | return html`<div class="toast" data-tone="danger" role="alert">${icon("alert")}<div class="toast-body">The crew cannot run: ${crew.detail}. Agents can be |
| 144 | 145 | created, but nothing picks up their work until this is fixed.</div></div>`; |
| 146 | const funded = fundingOf(ctx.app.config.dataDir); | |
| 147 | // One sentence: a forge its parent funds says so in the readiness line, not in a second paragraph (#10484). | |
| 145 | 148 | return html`<p class="muted small mb"> |
| 146 | ${icon("check")} ${crew.detail} is ready to run the crew. | |
| 149 | ${icon("check")} ${crew.detail} is ready to run the crew${funded === undefined ? "." : html`, <span data-funded>${fundingWords(funded)}</span>.`} | |
| 147 | 150 | </p> |
| 148 | 151 | ${when( |
| 149 | 152 | !crew.sandbox.enforced, |
src/web/pages/forges.ts +2 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 12 | 12 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 13 | 13 | import { html, type Html } from "../html.ts"; |
| @@ -52,6 +52,7 @@ function forgeRow(r: ClientRow, now: number): Html { | ||
| 52 | 52 | r.made ? html`${sizeWords(r.size)}` : null, |
| 53 | 53 | runningNote(r), |
| 54 | 54 | spendNote(r), |
| 55 | creditWords(r) === null ? null : html`<span data-credit>${creditWords(r)}</span>`, | |
| 55 | 56 | r.sentAt === null ? null : html`sent ${time(r.sentAt)}`, |
| 56 | 57 | r.lastActivityAt === null |
| 57 | 58 | ? null |
test/child-credit.test.ts added +433
| @@ -0,0 +1,433 @@ | ||
| 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 { eventAudience } from "../src/core/event_audience.ts"; | |
| 20 | import { readCapped } from "../src/spawn/bounded_read.ts"; | |
| 21 | import { crewReadiness } from "../src/web/pages/crew.ts"; | |
| 22 | import { startCrew } from "../src/crew/dispatch.ts"; | |
| 23 | import { createSession } from "../src/core/sessions.ts"; | |
| 24 | import { clientRows } from "../src/spawn/clients.ts"; | |
| 25 | import { forgeLines } from "../src/tui/screens/forges.ts"; | |
| 26 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 27 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 28 | import { getUserByName } from "../src/core/users.ts"; | |
| 29 | import { settleApp } from "./helpers/tui.ts"; | |
| 30 | import type { Ctx } from "../src/http/context.ts"; | |
| 31 | import { chmod, rename, writeFile } from "node:fs/promises"; | |
| 32 | import { createToken } from "../src/core/tokens.ts"; | |
| 33 | import { createUser } from "../src/core/users.ts"; | |
| 34 | import { commitFiles } from "../src/git/write.ts"; | |
| 35 | import { repoDir } from "../src/git/repos.ts"; | |
| 36 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 37 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 38 | import { MAX_IN_FLIGHT } from "../src/spawn/door_guard.ts"; | |
| 39 | import { MODEL_BODY_MAX } from "../src/spawn/model_door.ts"; | |
| 40 | import { openBeatDoor, parentLocal, type BeatDoor } from "../src/spawn/beat-door.ts"; | |
| 41 | import { readCredit } from "../src/spawn/credit.ts"; | |
| 42 | import { fundedEnv, fundingOf, fundingWords, refreshFunding } from "../src/spawn/funding.ts"; | |
| 43 | import { listPriceUsd } from "../src/spawn/list_price.ts"; | |
| 44 | import { quietLogs } from "./helpers/globals.ts"; | |
| 45 | ||
| 46 | quietLogs(); | |
| 47 | ||
| 48 | const PARENT_KEY = "sk-ant-api03-PARENTS-OWN-KEY-never-lent"; | |
| 49 | ||
| 50 | describe("list price", () => { | |
| 51 | test("matches a hand calculation: 200k in + 40k out on Sonnet is $0.60 + $0.60", () => { | |
| 52 | // 200_000 / 1e6 * $3 = 0.6 ; 40_000 / 1e6 * $15 = 0.6 | |
| 53 | expect(listPriceUsd("claude-sonnet-4-5", { inputTokens: 200_000, outputTokens: 40_000, cacheWriteTokens: 0, cacheReadTokens: 0 })).toBeCloseTo(1.2, 10); | |
| 54 | // Opus: 1M in $15; cache write 1M = $18.75; cache read 1M = $1.50 | |
| 55 | expect(listPriceUsd("claude-opus-4", { inputTokens: 1e6, outputTokens: 0, cacheWriteTokens: 1e6, cacheReadTokens: 1e6 })).toBeCloseTo(35.25, 10); | |
| 56 | // A model nobody knows is priced as the dearest. | |
| 57 | expect(listPriceUsd("mystery", { inputTokens: 1e6, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0 })).toBe(15); | |
| 58 | }); | |
| 59 | }); | |
| 60 | ||
| 61 | describe("a parent funds a child's crew", () => { | |
| 62 | let dir = ""; | |
| 63 | let parent: App; | |
| 64 | let child: App; | |
| 65 | let childDir = ""; | |
| 66 | let servers: Servers; | |
| 67 | let childServers: Servers; | |
| 68 | let door: BeatDoor; | |
| 69 | let adminRw = ""; | |
| 70 | let bobRw = ""; | |
| 71 | let childReader = ""; | |
| 72 | const upstreamSeen: Request[] = []; | |
| 73 | const saved = process.env["FORGE_PARENT_LOCAL"]; | |
| 74 | let open!: () => void; | |
| 75 | const gate = new Promise<void>((resolve) => (open = resolve)); | |
| 76 | const call = (token: string, extra: Record<string, unknown> = {}) => | |
| 77 | fetch(`${parentLocal(door.port)}/v1/messages`, { | |
| 78 | method: "POST", | |
| 79 | headers: { "x-api-key": token, "content-type": "application/json" }, | |
| 80 | body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 8, messages: [], ...extra }), | |
| 81 | }); | |
| 82 | const spent = () => readCredit(parent, "maria")!.spentUsd; | |
| 83 | ||
| 84 | const put = (token: string, name: string, body: unknown, headers: Record<string, string> = {}) => | |
| 85 | fetch(`${servers.url}/api/v1/forges/${name}/credit`, { | |
| 86 | method: "PUT", | |
| 87 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...headers }, | |
| 88 | body: JSON.stringify(body), | |
| 89 | }); | |
| 90 | const listed = async () => { | |
| 91 | const res = await fetch(`${servers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${adminRw}` } }); | |
| 92 | return ((await res.json()) as { items: { name: string; creditCapUsd: number | null; creditSpentUsd: number | null }[] }).items; | |
| 93 | }; | |
| 94 | /** One crew run's model call, as Claude Code makes it from the environment the child gave it. */ | |
| 95 | const run = (env: Record<string, string>, stream = false) => | |
| 96 | fetch(`${env["ANTHROPIC_BASE_URL"]}/v1/messages`, { | |
| 97 | method: "POST", | |
| 98 | headers: { "x-api-key": env["ANTHROPIC_API_KEY"] ?? "", "content-type": "application/json", "anthropic-version": "2023-06-01" }, | |
| 99 | body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 8, stream, messages: [{ role: "user", content: "hi" }] }), | |
| 100 | }); | |
| 101 | ||
| 102 | beforeAll(async () => { | |
| 103 | dir = await mkdtemp(join(tmpdir(), "forge-child-credit-")); | |
| 104 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 105 | const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true }); | |
| 106 | const bob = await createUser(parent.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-credit-passphrase" }); | |
| 107 | if (!alice.ok || !bob.ok) throw new Error("users"); | |
| 108 | adminRw = createToken(parent.db, alice.value.id, "rw", "rw").token; | |
| 109 | bobRw = createToken(parent.db, bob.value.id, "rw", "rw").token; | |
| 110 | const repo = await createRepo(parent, alice.value, { name: "bakery" }); | |
| 111 | if (!repo.ok) throw new Error("repo"); | |
| 112 | const c = await commitFiles(repoDir(parent.reposRoot, "alice", "bakery"), { | |
| 113 | branch: "main", | |
| 114 | message: "bakery", | |
| 115 | files: [{ path: "README.md", content: "# bakery\n" }], | |
| 116 | author: { name: "a", email: "[email protected]" }, | |
| 117 | }); | |
| 118 | if (!c.ok) throw new Error("commit"); | |
| 119 | servers = await serve(parent); | |
| 120 | const made = await fetch(`${servers.url}/api/v1/forges`, { | |
| 121 | method: "POST", | |
| 122 | headers: { authorization: `Bearer ${adminRw}`, "content-type": "application/json" }, | |
| 123 | body: JSON.stringify({ name: "maria", apps: ["alice/bakery"], dryRun: false }), | |
| 124 | }); | |
| 125 | if (made.status !== 201) throw new Error(`spawn ${made.status}`); | |
| 126 | const { bodyId } = (await made.json()) as { bodyId: string }; | |
| 127 | childDir = childDirOf(parent, bodyId); | |
| 128 | child = createApp({ dataDir: childDir, port: 0 }); | |
| 129 | await firstBoot(child); | |
| 130 | childServers = await serve(child); | |
| 131 | const reader = await createUser(child.db, { origin: "seed", trusted: true, username: "reader", email: "[email protected]", password: "a-long-credit-passphrase" }); | |
| 132 | if (!reader.ok) throw new Error("child user"); | |
| 133 | childReader = createToken(child.db, reader.value.id, "ro", "ro").token; | |
| 134 | // The parent's own provider, faked: it answers with usage, and keeps what it was sent. | |
| 135 | door = openBeatDoor(parent, { | |
| 136 | model: { | |
| 137 | upstream: () => ({ base: "https://provider.test", headers: { "x-api-key": PARENT_KEY } }), | |
| 138 | fetch: async (url, init) => { | |
| 139 | upstreamSeen.push(new Request(url, { method: init?.method ?? "POST", headers: init?.headers as Record<string, string> })); | |
| 140 | const usage = { input_tokens: 200_000, output_tokens: 40_000 }; | |
| 141 | const sent = JSON.parse(new TextDecoder().decode(init?.body as ArrayBuffer)) as { stream?: boolean; fail?: boolean; hold?: boolean }; | |
| 142 | if (sent.fail) return Response.json({ type: "error", error: { type: "overloaded_error", message: "busy" }, usage }, { status: 529 }); | |
| 143 | if (sent.hold) await gate; | |
| 144 | const stream = sent.stream === true; | |
| 145 | if (!stream) return Response.json({ id: "m", type: "message", model: "claude-sonnet-4-5", content: [], usage }); | |
| 146 | const sse = | |
| 147 | `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` + | |
| 148 | `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", usage: { output_tokens: 40_000 } })}\n\n`; | |
| 149 | return new Response(sse, { headers: { "content-type": "text/event-stream" } }); | |
| 150 | }, | |
| 151 | }, | |
| 152 | }); | |
| 153 | process.env["FORGE_PARENT_LOCAL"] = parentLocal(door.port); | |
| 154 | }, 60_000); | |
| 155 | ||
| 156 | afterAll(async () => { | |
| 157 | if (saved === undefined) delete process.env["FORGE_PARENT_LOCAL"]; | |
| 158 | else process.env["FORGE_PARENT_LOCAL"] = saved; | |
| 159 | door.close(); | |
| 160 | childServers.stop(); | |
| 161 | child.close(); | |
| 162 | servers.stop(); | |
| 163 | parent.close(); | |
| 164 | await rm(dir, { recursive: true, force: true }); | |
| 165 | }); | |
| 166 | ||
| 167 | test("only a site admin sets a cap, from the forge's own origin, in dollars", async () => { | |
| 168 | expect((await put(bobRw, "maria", { usd: 1 })).status).toBe(403); | |
| 169 | expect((await put(adminRw, "maria", { usd: 1 }, { origin: "https://evil.test" })).status).toBe(403); | |
| 170 | expect((await put(adminRw, "maria", { usd: -5 })).status).toBe(422); | |
| 171 | expect((await put(adminRw, "maria", { usd: "1" })).status).toBe(422); | |
| 172 | expect((await put(adminRw, "nobody", { usd: 1 })).status).toBe(404); | |
| 173 | expect(fundingOf(childDir)).toBeUndefined(); | |
| 174 | }); | |
| 175 | ||
| 176 | test("with no credit lent, the child's crew has no way to the parent's model", () => { | |
| 177 | expect(fundedEnv(childDir)).toBeUndefined(); | |
| 178 | expect(credentialEnv({ kind: "env" }, childDir)).not.toHaveProperty("ANTHROPIC_BASE_URL", parentLocal(door.port)); | |
| 179 | }); | |
| 180 | ||
| 181 | test("a $1 cap: the first run reaches the model through the parent, the second is refused in one sentence", async () => { | |
| 182 | const set = await put(adminRw, "maria", { usd: 1 }); | |
| 183 | expect(set.status).toBe(200); | |
| 184 | expect(((await set.json()) as { capUsd: number }).capUsd).toBe(1); | |
| 185 | ||
| 186 | // The child holds a bearer for the parent's door, and no provider credential anywhere. | |
| 187 | const f = fundingOf(childDir)!; | |
| 188 | expect(f.token.startsWith("maria.")).toBe(true); | |
| 189 | for (const file of await readdir(childDir, { recursive: true })) { | |
| 190 | const path = join(childDir, file); | |
| 191 | const text = await readFile(path, "latin1").catch(() => ""); | |
| 192 | expect(text.includes(PARENT_KEY)).toBe(false); | |
| 193 | } | |
| 194 | const env = credentialEnv({ kind: "env" }, childDir); | |
| 195 | expect(env).toEqual({ ANTHROPIC_BASE_URL: parentLocal(door.port), ANTHROPIC_API_KEY: f.token }); | |
| 196 | expect(JSON.stringify(env)).not.toContain(PARENT_KEY); | |
| 197 | ||
| 198 | const first = await run(env); | |
| 199 | expect(first.status).toBe(200); | |
| 200 | expect(((await first.json()) as { model: string }).model).toBe("claude-sonnet-4-5"); | |
| 201 | // The provider saw the parent's key, never the child's bearer. | |
| 202 | expect(upstreamSeen[0]!.headers.get("x-api-key")).toBe(PARENT_KEY); | |
| 203 | expect(upstreamSeen[0]!.url).toBe("https://provider.test/v1/messages"); | |
| 204 | ||
| 205 | const second = await run(env); | |
| 206 | expect(second.status).toBe(402); | |
| 207 | const refused = (await second.json()) as { error: { message: string } }; | |
| 208 | expect(refused.error.message).toBe("maria's $1 from its parent is spent; the parent's admin can raise it."); | |
| 209 | expect(upstreamSeen.length).toBe(1); | |
| 210 | }); | |
| 211 | ||
| 212 | test("the cap and the spend read back on both forges' APIs", async () => { | |
| 213 | // 200k in at $3/M + 40k out at $15/M = $1.20 | |
| 214 | expect((await listed()).find((i) => i.name === "maria")).toMatchObject({ creditCapUsd: 1, creditSpentUsd: 1.2 }); | |
| 215 | const mine = await fetch(`${childServers.url}/api/v1/credit`, { headers: { authorization: `Bearer ${childReader}` } }); | |
| 216 | expect(mine.status).toBe(200); | |
| 217 | const said = (await mine.json()) as { capUsd: number; spentUsd: number; says: string }; | |
| 218 | expect(said).toMatchObject({ capUsd: 1, spentUsd: 1.2 }); | |
| 219 | expect(said.says).toBe("funded by its parent: $0 of $1 left"); | |
| 220 | expect(fundingWords({ token: "x", capUsd: 200, spentUsd: 0.5 })).toBe("funded by its parent: $199.50 of $200 left"); | |
| 221 | }); | |
| 222 | ||
| 223 | test("raising the cap lends more, keeps the bearer and the spend; a stream is counted as it passes", async () => { | |
| 224 | const token = fundingOf(childDir)!.token; | |
| 225 | expect((await put(adminRw, "maria", { usd: 5 })).status).toBe(200); | |
| 226 | await refreshFunding(child); | |
| 227 | expect(fundingOf(childDir)).toMatchObject({ token, capUsd: 5, spentUsd: 1.2 }); | |
| 228 | const env = credentialEnv({ kind: "env" }, childDir); | |
| 229 | const res = await run(env, true); | |
| 230 | expect(res.status).toBe(200); | |
| 231 | await res.text(); | |
| 232 | expect((await listed()).find((i) => i.name === "maria")!.creditSpentUsd).toBe(2.4); | |
| 233 | }); | |
| 234 | ||
| 235 | test("the door lends to no one without the bearer", async () => { | |
| 236 | const base = parentLocal(door.port); | |
| 237 | expect((await run({ ANTHROPIC_BASE_URL: base, ANTHROPIC_API_KEY: "maria.not-the-bearer-at-all" })).status).toBe(401); | |
| 238 | expect((await run({ ANTHROPIC_BASE_URL: base, ANTHROPIC_API_KEY: "" })).status).toBe(401); | |
| 239 | expect((await fetch(`${base}/api/v1/credit`)).status).toBe(401); | |
| 240 | }); | |
| 241 | test("a body is capped by the bytes that arrive, not by what it says it is; the bearer is checked first", async () => { | |
| 242 | const before = upstreamSeen.length; | |
| 243 | const token = fundingOf(childDir)!.token; | |
| 244 | const big = new Uint8Array(MODEL_BODY_MAX + 1024); | |
| 245 | const refused = await fetch(`${parentLocal(door.port)}/v1/messages`, { method: "POST", headers: { "x-api-key": token }, body: big }).catch(() => undefined); | |
| 246 | // Either the runtime's own 413 or ours: never an answer from the provider. | |
| 247 | expect(refused === undefined || refused.status === 413).toBe(true); | |
| 248 | expect(upstreamSeen.length).toBe(before); | |
| 249 | // A chunked body declares no length at all; the count still stops it. | |
| 250 | const chunks = new ReadableStream<Uint8Array>({ | |
| 251 | pull(c) { | |
| 252 | c.enqueue(new Uint8Array(400)); | |
| 253 | }, | |
| 254 | }); | |
| 255 | const req = new Request("http://x/", { method: "POST", body: chunks, duplex: "half" } as RequestInit); | |
| 256 | expect(await readCapped(req, 1000)).toBeUndefined(); | |
| 257 | expect((await readCapped(new Request("http://x/", { method: "POST", body: "12345" }), 5))?.length).toBe(5); | |
| 258 | expect(await readCapped(new Request("http://x/", { method: "POST", body: "123456" }), 5)).toBeUndefined(); | |
| 259 | }); | |
| 260 | ||
| 261 | test("the heartbeat is still refused past 1 KB, and unsigned", async () => { | |
| 262 | const base = parentLocal(door.port); | |
| 263 | const post = (body: string, headers: Record<string, string>) => | |
| 264 | fetch(`${base}/api/v1/heartbeat`, { method: "POST", headers, body }); | |
| 265 | expect((await post("x".repeat(2048), { "x-forge-signature": "a".repeat(64) })).status).toBe(404); | |
| 266 | expect((await post("{}", {})).status).toBe(404); | |
| 267 | }); | |
| 268 | ||
| 269 | test("a forge nobody funds answers 404 on /api/v1/credit", async () => { | |
| 270 | const res = await fetch(`${servers.url}/api/v1/credit`, { headers: { authorization: `Bearer ${adminRw}` } }); | |
| 271 | expect(res.status).toBe(404); | |
| 272 | }); | |
| 273 | ||
| 274 | test("a stream the client leaves is still counted; an upstream error is not charged", async () => { | |
| 275 | expect((await put(adminRw, "maria", { usd: 500 })).status).toBe(200); | |
| 276 | const token = fundingOf(childDir)!.token; | |
| 277 | const before = spent(); | |
| 278 | const res = await call(token, { stream: true }); | |
| 279 | const reader = res.body!.getReader(); | |
| 280 | await reader.read(); | |
| 281 | await reader.cancel(); | |
| 282 | await Bun.sleep(20); | |
| 283 | expect(spent()).toBeCloseTo(before + 1.2, 6); | |
| 284 | const failed = await call(token, { fail: true }); | |
| 285 | expect(failed.status).toBe(529); | |
| 286 | expect(spent()).toBeCloseTo(before + 1.2, 6); | |
| 287 | }); | |
| 288 | ||
| 289 | test("calls in parallel pass the cap by at most the in-flight limit, and a place is given back", async () => { | |
| 290 | const token = fundingOf(childDir)!.token; | |
| 291 | const before = upstreamSeen.length; | |
| 292 | const held = Array.from({ length: MAX_IN_FLIGHT + 2 }, () => call(token, { hold: true })); | |
| 293 | const answers = await Promise.all(held.slice(MAX_IN_FLIGHT).map((p) => p)); | |
| 294 | // Calls past the limit were refused at once, without reaching the provider. | |
| 295 | for (const a of answers) expect(a.status).toBe(429); | |
| 296 | for (let i = 0; i < 50 && upstreamSeen.length < before + MAX_IN_FLIGHT; i++) await Bun.sleep(10); | |
| 297 | expect(upstreamSeen.length - before).toBe(MAX_IN_FLIGHT); | |
| 298 | const was = spent(); | |
| 299 | open(); | |
| 300 | for (const p of held.slice(0, MAX_IN_FLIGHT)) { | |
| 301 | const r = await p; | |
| 302 | expect(r.status).toBe(200); | |
| 303 | await r.text(); | |
| 304 | } | |
| 305 | expect(spent()).toBeCloseTo(was + MAX_IN_FLIGHT * 1.2, 6); | |
| 306 | expect((await call(token)).status).toBe(200); | |
| 307 | }); | |
| 308 | ||
| 309 | test("the crew page, the /forges row and the terminal say it; a reader is not told the dollars", async () => { | |
| 310 | const bin = join(dir, "fake-claude"); | |
| 311 | await writeFile(bin, "#!/bin/sh\necho 2.1.0\n"); | |
| 312 | await chmod(bin, 0o755); | |
| 313 | const crew = await startCrew(child, { forgeUrl: childServers.url, localUrl: childServers.url, binary: bin, concurrency: 0, budgetUsd: 1, runTimeoutMs: 5_000 }); | |
| 314 | try { | |
| 315 | const said = String(crewReadiness({ app: child } as unknown as Ctx)); | |
| 316 | expect(said).toContain("data-funded"); | |
| 317 | expect(said).toContain("funded by its parent: $"); | |
| 318 | expect(said.match(/<p[\s>]/g)?.length).toBe(1); | |
| 319 | } finally { | |
| 320 | await crew.stop(); | |
| 321 | } | |
| 322 | const alice = getUserByName(parent.db, "alice")!; | |
| 323 | const cookie = `forge_session=${createSession(parent.db, alice.id).token}`; | |
| 324 | const page = await (await fetch(`${servers.url}/forges`, { headers: { cookie } })).text(); | |
| 325 | expect(page).toMatch(/data-credit[^>]*>\$[\d.]+ of \$500 lent/); | |
| 326 | expect(forgeLines(await clientRows(parent)).map((l) => l[1]).join("\n")).toMatch(/\$[\d.]+ of \$500 lent/); | |
| 327 | expect(eventAudience("forge.credit_set")).toBe("admins"); | |
| 328 | }); | |
| 329 | ||
| 330 | test("the terminal sets a cap as an admin, and offers it to no one else", async () => { | |
| 331 | const drive = (username: string) => { | |
| 332 | const terminal = headlessTerminal(140, 32); | |
| 333 | const { app: tui } = createForgeApp(parent, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(parent.db, username)! }, { terminal, exitHooks: false }); | |
| 334 | tui.start(); | |
| 335 | const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 336 | return { | |
| 337 | text: () => tui.screen.toText(), | |
| 338 | async type(t: string) { | |
| 339 | for (const ch of t) key(ch); | |
| 340 | await settleApp(tui); | |
| 341 | }, | |
| 342 | async press(...ks: string[]) { | |
| 343 | ks.forEach((k) => key(k)); | |
| 344 | await settleApp(tui); | |
| 345 | }, | |
| 346 | async palette(q: string) { | |
| 347 | key("k", true); | |
| 348 | await settleApp(tui); | |
| 349 | await this.type(q); | |
| 350 | }, | |
| 351 | stop: () => tui.stop(), | |
| 352 | }; | |
| 353 | }; | |
| 354 | const bobTui = drive("bob"); | |
| 355 | await bobTui.palette("Lend a child"); | |
| 356 | expect(bobTui.text()).not.toContain("Lend a child credit"); | |
| 357 | bobTui.stop(); | |
| 358 | const t = drive("alice"); | |
| 359 | await t.palette("Lend a child"); | |
| 360 | expect(t.text()).toContain("Lend a child credit"); | |
| 361 | await t.press("enter"); | |
| 362 | expect(t.text()).toContain("maria"); | |
| 363 | await t.press("enter"); | |
| 364 | await t.type("7.5"); | |
| 365 | await t.press("enter"); | |
| 366 | for (let i = 0; i < 50 && readCredit(parent, "maria")!.capUsd !== 7.5; i++) await Bun.sleep(10); | |
| 367 | expect(readCredit(parent, "maria")!.capUsd).toBe(7.5); | |
| 368 | // The same checks as the API: a sum that is not dollars sets nothing. | |
| 369 | await t.palette("Lend a child"); | |
| 370 | await t.press("enter", "enter"); | |
| 371 | await t.type("-3"); | |
| 372 | await t.press("enter"); | |
| 373 | await Bun.sleep(50); | |
| 374 | expect(readCredit(parent, "maria")!.capUsd).toBe(7.5); | |
| 375 | t.stop(); | |
| 376 | }); | |
| 377 | ||
| 378 | test("a child's agents screen in the terminal carries the funded line", async () => { | |
| 379 | const admin = await createUser(child.db, { origin: "seed", trusted: true, username: "kidadmin", email: "[email protected]", password: "a-long-credit-passphrase", isAdmin: true }); | |
| 380 | if (!admin.ok) throw new Error("child admin"); | |
| 381 | const terminal = headlessTerminal(160, 32); | |
| 382 | const { app: tui } = createForgeApp(child, { sshUsername: "t", remoteAddress: "127.0.0.1", user: admin.value }, { terminal, exitHooks: false }); | |
| 383 | tui.start(); | |
| 384 | const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 385 | key("k", true); | |
| 386 | await settleApp(tui); | |
| 387 | for (const ch of "Crew agents") key(ch); | |
| 388 | await settleApp(tui); | |
| 389 | key("enter"); | |
| 390 | await settleApp(tui); | |
| 391 | expect(tui.screen.toText()).toContain("funded by its parent"); | |
| 392 | tui.stop(); | |
| 393 | }); | |
| 394 | ||
| 395 | test("a failed hand-off saves nothing, so a retry still mints the bearer; a child that lost it gets a new one", async () => { | |
| 396 | const credit = join(parent.config.dataDir, "credit", "maria.json"); | |
| 397 | const heldBack = join(childDir, "parent.json.away"); | |
| 398 | const doc = JSON.parse(await readFile(join(childDir, "parent.json"), "utf8")) as Record<string, unknown>; | |
| 399 | const spentBefore = spent(); | |
| 400 | await rm(credit); | |
| 401 | await rename(join(childDir, "parent.json"), heldBack); | |
| 402 | // The child's directory cannot be written: 502, and no credit on the parent either. | |
| 403 | expect((await put(adminRw, "maria", { usd: 9 })).status).toBe(502); | |
| 404 | expect(readCredit(parent, "maria")).toBeUndefined(); | |
| 405 | await rename(heldBack, join(childDir, "parent.json")); | |
| 406 | delete doc["credit"]; | |
| 407 | await writeFile(join(childDir, "parent.json"), JSON.stringify(doc)); | |
| 408 | expect(fundingOf(childDir)).toBeUndefined(); | |
| 409 | expect((await put(adminRw, "maria", { usd: 9 })).status).toBe(200); | |
| 410 | expect(fundingOf(childDir)!.token.startsWith("maria.")).toBe(true); | |
| 411 | expect(readCredit(parent, "maria")!.capUsd).toBe(9); | |
| 412 | // The credit exists but the child holds nothing: the call mints again and keeps the spend. | |
| 413 | const keptSpend = spent(); | |
| 414 | const old = fundingOf(childDir)!.token; | |
| 415 | await writeFile(join(childDir, "parent.json"), JSON.stringify(doc)); | |
| 416 | expect((await put(adminRw, "maria", { usd: 9 })).status).toBe(200); | |
| 417 | expect(fundingOf(childDir)!.token).not.toBe(old); | |
| 418 | expect(spent()).toBe(keptSpend); | |
| 419 | expect(spentBefore).toBeGreaterThan(0); | |
| 420 | expect((await call(old)).status).toBe(401); | |
| 421 | expect((await call(fundingOf(childDir)!.token)).status).toBe(200); | |
| 422 | }); | |
| 423 | ||
| 424 | test("bad bearers are slowed past a rate, and a good one is never turned away by them", async () => { | |
| 425 | const base = parentLocal(door.port); | |
| 426 | const codes: number[] = []; | |
| 427 | for (let i = 0; i < 40; i++) codes.push((await fetch(`${base}/api/v1/credit`, { headers: { authorization: `Bearer maria.guess-number-${i}-xxxx` } })).status); | |
| 428 | expect(codes.slice(0, 5).every((c) => c === 401)).toBe(true); | |
| 429 | expect(codes.at(-1)).toBe(429); | |
| 430 | const good = await fetch(`${base}/api/v1/credit`, { headers: { authorization: `Bearer ${fundingOf(childDir)!.token}` } }); | |
| 431 | expect(good.status).toBe(200); | |
| 432 | }); | |
| 433 | }); |
test/palette-roster.test.ts +2
| @@ -165,6 +165,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 165 | 165 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 166 | 166 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 167 | 167 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 168 | ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"], | |
| 168 | 169 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 169 | 170 | ["profile", "Your profile", "display name and email"], |
| 170 | 171 | ["dotfiles", "Your dotfiles", "the repository every shell you open copies from"], |
| @@ -240,6 +241,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 240 | 241 | ["feature-feed", "Feature feeds", "the feed items about an absorbed app, as Settings › Features shows them"], |
| 241 | 242 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 242 | 243 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
| 244 | ["lend-credit", "Lend a child credit", "set the dollars a forge you sent may spend on this one's model credential"], | |
| 243 | 245 | ["origin-ahead", "New commits upstream", "mirrors whose origin has moved on: take them"], |
| 244 | 246 | ["forge-view", "Forge view", "the forge's counts, and Foundry when it is installed"], |
| 245 | 247 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
If this fails after setCap, the credit and token hash are already saved and the bearer is gone for good; retries return no token. Persist only after the hand-off, or re-mint.