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
torque/forge:torque/10484-a-parent-funds-a-child-forge-s-crew-a-ca → main 1 commit fork
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"; | ||
| 268 | 268 | export { route as postApiV1FleetOpsIdUndo } from "./postApiV1FleetOpsIdUndo.ts"; |
| 269 | 269 | export { route as postApiV1Forges } from "./postApiV1Forges.ts"; |
| 270 | 270 | export { route as postApiV1ForgesNameClaimLink } from "./postApiV1ForgesNameClaimLink.ts"; |
| 271 | export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts"; | |
| 272 | export { route as getApiV1Credit } from "./getApiV1Credit.ts"; | |
| 271 | 273 | export { route as postApiV1Heartbeat } from "./postApiV1Heartbeat.ts"; |
| 272 | 274 | export { route as postApiV1Migrate } from "./postApiV1Migrate.ts"; |
| 273 | 275 | 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([ | ||
| 111 | 111 | "forge.removed", |
| 112 | 112 | "forge.stopped", |
| 113 | 113 | "forge.claim_reissued", |
| 114 | "forge.credit_set", | |
| 114 | 115 | "forge.claim_revoked", |
| 115 | 116 | "forge.claim_restored", |
| 116 | 117 | "agent.created", |
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 +33 −1
| @@ -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"; |
| @@ -44,7 +47,8 @@ export function forgeRoutes(router: Router<Handler>): void { | ||
| 44 | 47 | // The row, and beside it what the list always carried: which template a child came from, where it is placed. |
| 45 | 48 | const [rows, children] = await Promise.all([clientRows(ctx.app), listChildren(ctx.app)]); |
| 46 | 49 | const kept = new Map(children.map((c) => [c.name, c])); |
| 47 | return rows.map((row) => { | |
| 50 | return rows.map((listed) => { | |
| 51 | const row = listed; | |
| 48 | 52 | const c = kept.get(row.name); |
| 49 | 53 | return c === undefined |
| 50 | 54 | ? row |
| @@ -71,6 +75,34 @@ export function forgeRoutes(router: Router<Handler>): void { | ||
| 71 | 75 | return json(door.body, door.status); |
| 72 | 76 | }); |
| 73 | 77 | |
| 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 | ||
| 74 | 106 | // A new claim link for a child nobody has claimed (#729); the old one dies. |
| 75 | 107 | router.add("POST", "/api/v1/forges/:name/claim-link", async (ctx) => { |
| 76 | 108 | const denied = requireScope(ctx, "rw"); |
src/spawn/beat-door.ts +10 −4
| @@ -1,6 +1,7 @@ | ||
| 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. |
| @@ -8,6 +9,7 @@ | ||
| 8 | 9 | |
| 9 | 10 | import type { App } from "../app.ts"; |
| 10 | 11 | import { receiveBeat, SIGNATURE_HEADER } from "./heartbeat.ts"; |
| 12 | import { creditAnswer, lendModel, MODEL_BODY_MAX, type ModelDeps } from "./model_door.ts"; | |
| 11 | 13 | |
| 12 | 14 | const MAX_BODY = 1024; |
| 13 | 15 | |
| @@ -22,14 +24,18 @@ export interface BeatDoor { | ||
| 22 | 24 | close(): void; |
| 23 | 25 | } |
| 24 | 26 | |
| 25 | export function openBeatDoor(app: App): BeatDoor { | |
| 27 | export function openBeatDoor(app: App, deps: { readonly model?: ModelDeps } = {}): BeatDoor { | |
| 26 | 28 | const server = Bun.serve({ |
| 27 | 29 | port: 0, |
| 28 | 30 | hostname: "127.0.0.1", |
| 29 | maxRequestBodySize: MAX_BODY, | |
| 31 | maxRequestBodySize: MODEL_BODY_MAX, | |
| 30 | 32 | async fetch(req) { |
| 31 | 33 | 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; | |
| 33 | 39 | const raw = await req.text(); |
| 34 | 40 | if (raw.length > MAX_BODY) return refused; |
| 35 | 41 | 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"; | ||
| 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(), |
| @@ -314,6 +319,11 @@ const money = (n: number): string => (Number.isInteger(n) ? String(n) : n.toFixe | ||
| 314 | 319 | * "$3.20 of $10 today", with "ceiling reached" at the cap; `warn` from 80%. |
| 315 | 320 | * Null when the child has not reported spend. |
| 316 | 321 | */ |
| 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 | ||
| 317 | 327 | export function spendWords(r: ClientRow): { words: string; warn: boolean } | null { |
| 318 | 328 | if (r.spendTodayUsd === null) return null; |
| 319 | 329 | 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"; | ||
| 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 +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 @@ | ||
| 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 +8
| @@ -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, |
| @@ -123,6 +124,12 @@ function panel(body: Html, o: PanelOptions): Html { | ||
| 123 | 124 | </section>`; |
| 124 | 125 | } |
| 125 | 126 | |
| 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 | ||
| 126 | 133 | /** |
| 127 | 134 | * Whether anything will pick up an agent's work. The runner is detected |
| 128 | 135 | * once at boot; this is that answer, said before an issue is assigned |
| @@ -145,6 +152,7 @@ export function crewReadiness(ctx: Ctx): Html { | ||
| 145 | 152 | return html`<p class="muted small mb"> |
| 146 | 153 | ${icon("check")} ${crew.detail} is ready to run the crew. |
| 147 | 154 | </p> |
| 155 | ${fundedNote(ctx)} | |
| 148 | 156 | ${when( |
| 149 | 157 | !crew.sandbox.enforced, |
| 150 | 158 | () => |
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 +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 | }); |
new above
offline