From 8e09da18b65ef0470fc1c72ea62969725cb8211c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 19:42:50 +0000 Subject: [PATCH 1/2] Add render-token service: token accounts, model registry, render jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coders can now buy prepaid render tokens and spend them running custom AI models (Claude for code generation, Workers AI for images), and publish their own models for others to run and earn from. - migrations/0018_token_service.sql: token_accounts, append-only token_ledger, token_packs/token_purchases, render_models + render_model_versions (immutable, versioned), render_jobs. - src/db/tokens.ts: the ledger is the source of truth; balance is a cache updated in the same D1 batch under a `balance + delta >= 0` guard, so an overdraw touches neither table. Idempotency keys make every mutation replay-safe instead of double-applying on retry. - src/db/render.ts: model registry (owner, kind, provider, visibility) with append-only versions; provider/kind pairing is validated at registration time, not at render time. - src/ai/inference.ts: dispatches to Claude API (code) or Workers AI (images) behind a uniform interface; RENDER_MOCK=1 (opt-in, never in production — same convention as GITHUB_MOCK/PINNING_MOCK) returns deterministic canned output for local dev and CI. - src/tokens/handlers.ts + src/render/handlers.ts: account/ledger/packs endpoints; token purchase confirmed by verifying an on-chain ETH transfer to a treasury address (no payment processor, reuses the mint flow's receipt-verification pattern); model CRUD + publish + render, with debit-before-run and full refund on failure. - New users get a 200-token signup grant (auth/siwe.ts), idempotent on user id so it applies exactly once regardless of sign-in count. - wrangler.toml: [ai] binding added to env.production only — bound at the top level it forces `wrangler dev` into remote mode and hard- fails without a CLOUDFLARE_API_TOKEN, breaking local dev entirely. - 8 new unit tests (ledger lifetime-counter accounting, provider/kind gating) and 20 new smoke checks (signup grant, purchase flow, model registry, render + idempotent replay, balance never negative) — 26/26 unit tests and 108/108 smoke checks pass; typecheck and `wrangler deploy --dry-run` (both environments) are clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QuSa99HABSrWRarT9BQ5Tz --- workers/api/.dev.vars.example | 16 + workers/api/migrations/0018_token_service.sql | 218 +++++++ workers/api/package-lock.json | 91 ++- workers/api/package.json | 1 + workers/api/scripts/smoke_api.mjs | 157 +++++ workers/api/src/ai/inference.ts | 214 +++++++ workers/api/src/auth/siwe.ts | 25 + workers/api/src/db/render.ts | 575 +++++++++++++++++ workers/api/src/db/tokens.ts | 391 ++++++++++++ workers/api/src/index.ts | 38 ++ workers/api/src/render/handlers.ts | 590 ++++++++++++++++++ workers/api/src/tokens/handlers.ts | 219 +++++++ workers/api/src/types.ts | 26 + workers/api/test/units.test.mjs | 78 +++ workers/api/wrangler.toml | 26 + 15 files changed, 2661 insertions(+), 4 deletions(-) create mode 100644 workers/api/migrations/0018_token_service.sql create mode 100644 workers/api/src/ai/inference.ts create mode 100644 workers/api/src/db/render.ts create mode 100644 workers/api/src/db/tokens.ts create mode 100644 workers/api/src/render/handlers.ts create mode 100644 workers/api/src/tokens/handlers.ts diff --git a/workers/api/.dev.vars.example b/workers/api/.dev.vars.example index 5d39220..02c9a61 100644 --- a/workers/api/.dev.vars.example +++ b/workers/api/.dev.vars.example @@ -60,6 +60,22 @@ GA_CHAIN_ID="84532" GA_RPC_URL="https://sepolia.base.org" GA_FACTORY_ADDRESS="" +# --- Render-token service --------------------------------------------- +# Address that receives ETH for token-pack purchases. Reuses the +# GA_CHAIN_ID / GA_RPC_URL above. Until set, +# POST /v1/tokens/purchase/confirm returns 503 purchase_unconfigured. +TOKEN_TREASURY_ADDRESS="" + +# Claude API key for the `anthropic` render-model provider. Production: +# wrangler secret put ANTHROPIC_API_KEY --env production +ANTHROPIC_API_KEY="" + +# OPT-IN ONLY. When exactly "1", runInference() returns deterministic +# canned output with no network call, so the render pipeline is +# exercisable without an Anthropic key or a Workers AI binding. +# Production must NEVER set RENDER_MOCK. +RENDER_MOCK="1" + # --- Task #20: observability (all optional; unset = the surface no-ops) ---- # ADMIN_HANDLES gates /v1/internal/*; empty means those routes 403 with # `admin_unconfigured`. Set it to your own handle to use /v1/internal/stats diff --git a/workers/api/migrations/0018_token_service.sql b/workers/api/migrations/0018_token_service.sql new file mode 100644 index 0000000..99333b9 --- /dev/null +++ b/workers/api/migrations/0018_token_service.sql @@ -0,0 +1,218 @@ +-- Render-token service: prepaid compute credits, a registry of custom +-- render models, and the job rows that spend tokens against them. +-- +-- NAMING — read this before touching anything below. +-- A "render token" is a prepaid compute credit. It is NOT an ERC-721 +-- token. This codebase already uses `token_id` / `mints.token_id` for +-- on-chain NFTs (see 0001_init.sql and 0014_traits.sql); nothing in +-- this migration touches that. Every table here is about paying for +-- inference, never about minting. +-- +-- LEDGER INVARIANT +-- `token_accounts.balance` is a cache. `token_ledger` is the source of +-- truth: an append-only list of signed deltas. Every balance mutation +-- writes exactly one ledger row and updates the cached balance in the +-- SAME D1 batch (batches are transactional), guarded by the identical +-- `balance + delta >= 0` predicate on both statements so they can only +-- apply together. `balance_after` is stamped on each row, so +-- `SELECT balance_after FROM token_ledger ORDER BY id DESC LIMIT 1` +-- must always equal `token_accounts.balance` — the audit assertion in +-- test/units.test.mjs and scripts/smoke_api.mjs both rely on that. + +-- --------------------------------------------------------------------------- +-- Balances +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS token_accounts ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + balance INTEGER NOT NULL DEFAULT 0, + lifetime_purchased INTEGER NOT NULL DEFAULT 0, + lifetime_spent INTEGER NOT NULL DEFAULT 0, + lifetime_earned INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK (balance >= 0) +); + +-- Append-only. No UPDATE or DELETE statement in src/ targets this table. +-- +-- `idempotency_key` is the replay guard for every mutation path: a +-- retried purchase confirm, a double-clicked render, or a queue redelivery +-- collides on the UNIQUE index, the whole batch rolls back, and the +-- handler returns the original entry instead of moving the balance twice. +CREATE TABLE IF NOT EXISTS token_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Signed: credits are positive, debits negative. Never zero. + delta INTEGER NOT NULL, + -- grant | purchase | debit | refund | earn | adjust + kind TEXT NOT NULL, + balance_after INTEGER NOT NULL, + -- What the entry is about: 'purchase' | 'render_job' | 'signup' | 'admin' + ref_kind TEXT, + ref_id INTEGER, + memo TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + CHECK (delta <> 0) +); + +CREATE INDEX IF NOT EXISTS idx_token_ledger_user + ON token_ledger(user_id, id DESC); + +-- --------------------------------------------------------------------------- +-- Purchases (on-chain payment, no payment processor) +-- --------------------------------------------------------------------------- +-- +-- Packs are priced directly in wei rather than pegged to a fiat rate: +-- there is no oracle in this stack and an admin edit is cheaper than a +-- price feed. `price_wei` is TEXT because wei exceeds 2^53 and SQLite +-- INTEGER math would silently lose precision through the JS bridge. + +CREATE TABLE IF NOT EXISTS token_packs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + tokens INTEGER NOT NULL, + price_wei TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + sort INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + CHECK (tokens > 0) +); + +-- One row per settled payment. UNIQUE(chain_id, tx_hash) is what stops +-- the same transfer being redeemed for a second pack: the confirm +-- handler verifies the receipt on-chain and then races to insert here, +-- so replaying a valid tx hash hits the constraint, not the balance. +CREATE TABLE IF NOT EXISTS token_purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + pack_id INTEGER NOT NULL REFERENCES token_packs(id), + chain_id INTEGER NOT NULL, + tx_hash TEXT NOT NULL, + from_address TEXT NOT NULL, + value_wei TEXT NOT NULL, + tokens INTEGER NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (chain_id, tx_hash) +); + +CREATE INDEX IF NOT EXISTS idx_token_purchases_user + ON token_purchases(user_id, created_at DESC); + +-- --------------------------------------------------------------------------- +-- Custom model registry +-- --------------------------------------------------------------------------- +-- +-- A "custom model" is a creator-published render recipe: a provider +-- model plus a system prompt, a parameter schema, and a price. Coders +-- publish them, other coders render with them, and the publisher earns +-- a share of every run (see render_jobs.owner_earn_tokens). +-- +-- `provider` is the execution backend, not a brand: +-- anthropic — code generation through the Claude API +-- workers_ai — image/texture generation through the Workers AI binding +-- mock — deterministic canned output; dev + tests only + +CREATE TABLE IF NOT EXISTS render_models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT, + -- code | image + kind TEXT NOT NULL, + provider TEXT NOT NULL, + -- public | unlisted | private + visibility TEXT NOT NULL DEFAULT 'private', + -- active | disabled + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_render_models_owner + ON render_models(owner_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_render_models_public + ON render_models(visibility, status, updated_at DESC); + +-- Versions are immutable once written: a render job references the exact +-- version it ran against, so editing one in place would rewrite the +-- provenance of every past job. Publishing a change means inserting a +-- new version row. +CREATE TABLE IF NOT EXISTS render_model_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + model_id INTEGER NOT NULL REFERENCES render_models(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + -- Provider-side identifier, e.g. 'claude-opus-5' or a Workers AI model id. + provider_model_id TEXT NOT NULL, + system_prompt TEXT, + params_schema_json TEXT, + price_tokens INTEGER NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (model_id, version), + CHECK (price_tokens >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_render_model_versions_model + ON render_model_versions(model_id, version DESC); + +-- --------------------------------------------------------------------------- +-- Render jobs +-- --------------------------------------------------------------------------- +-- +-- Lifecycle: queued → running → succeeded | failed. +-- Tokens are debited at queue time (so a caller can't outrun their own +-- balance with concurrent requests) and refunded in full when the job +-- ends in `failed` — the refund is a second ledger row, never a reversal +-- of the first. +-- +-- `seed` keeps the platform's determinism contract: same model version + +-- same params + same seed must reproduce the same output. + +CREATE TABLE IF NOT EXISTS render_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + model_version_id INTEGER NOT NULL REFERENCES render_model_versions(id), + -- Optional: the studio project this render was made for. + project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, + seed TEXT NOT NULL, + params_json TEXT, + prompt_hash TEXT, + status TEXT NOT NULL DEFAULT 'queued', + price_tokens INTEGER NOT NULL, + -- Share of price_tokens credited to the model owner on success. + owner_earn_tokens INTEGER NOT NULL DEFAULT 0, + -- code | image + output_kind TEXT, + -- Inline output for `code` jobs; R2 key for `image` jobs. + output_text TEXT, + output_key TEXT, + output_hash TEXT, + error_code TEXT, + idempotency_key TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + finished_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_render_jobs_user + ON render_jobs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_render_jobs_model_version + ON render_jobs(model_version_id, created_at DESC); + +-- --------------------------------------------------------------------------- +-- Seeds +-- --------------------------------------------------------------------------- +-- +-- Pack prices are Base ETH amounts chosen so the cheapest pack is a +-- low-commitment trial. Edit with an UPDATE in a later migration rather +-- than in place, so price history stays auditable in git. +-- starter 500 tokens 0.001 ETH +-- studio 3000 tokens 0.005 ETH +-- pro 10000 tokens 0.015 ETH +INSERT OR IGNORE INTO token_packs (slug, title, tokens, price_wei, sort, created_at) +VALUES + ('starter', 'Starter', 500, '1000000000000000', 10, strftime('%s','now')), + ('studio', 'Studio', 3000, '5000000000000000', 20, strftime('%s','now')), + ('pro', 'Pro', 10000, '15000000000000000', 30, strftime('%s','now')); diff --git a/workers/api/package-lock.json b/workers/api/package-lock.json index 28a4232..6554af6 100644 --- a/workers/api/package-lock.json +++ b/workers/api/package-lock.json @@ -8,6 +8,7 @@ "name": "@generatedart/api", "version": "0.0.1", "dependencies": { + "@anthropic-ai/sdk": "^0.117.1", "hono": "^4.12.16", "siwe": "^2.3.2", "viem": "^2.48.8" @@ -32,6 +33,36 @@ "license": "MIT", "peer": true }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.117.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.117.1.tgz", + "integrity": "sha512-Yn2QlXfyCiKJ5YGCOOay7ZE78ISvII2XY621WMCiflmG8IYgwx59IBwPExxki3Xk9jKUtnD/Sj6UvplWr0rZxg==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", @@ -1348,6 +1379,12 @@ "valid-url": "^1.0.9" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@stablelib/binary": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", @@ -1716,6 +1753,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1781,6 +1824,19 @@ "ws": "*" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -1830,6 +1886,16 @@ "node": ">=16.13" } }, + "node_modules/miniflare/node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/mustache": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -2083,6 +2149,16 @@ "get-source": "^2.0.12" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stoppable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", @@ -2101,6 +2177,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2768,11 +2850,12 @@ } }, "node_modules/zod": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", - "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", - "devOptional": true, + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/workers/api/package.json b/workers/api/package.json index 192ed6a..1cef2be 100644 --- a/workers/api/package.json +++ b/workers/api/package.json @@ -29,6 +29,7 @@ "migrate:prod": "wrangler d1 migrations apply DB --env production --remote" }, "dependencies": { + "@anthropic-ai/sdk": "^0.117.1", "hono": "^4.12.16", "siwe": "^2.3.2", "viem": "^2.48.8" diff --git a/workers/api/scripts/smoke_api.mjs b/workers/api/scripts/smoke_api.mjs index 42284f3..de8ea14 100644 --- a/workers/api/scripts/smoke_api.mjs +++ b/workers/api/scripts/smoke_api.mjs @@ -231,6 +231,163 @@ let projectId = null; record("GET /v1/projects/:id/mints/:tokenId (404 expected)", r.status === 404, `status=${r.status}`); } +// ---- render-token service (user 1, still signed in from above) +// Render jobs report as succeeding when RENDER_MOCK=1 is set in +// .dev.vars, or 503 provider_unconfigured otherwise (no live +// Anthropic key / Workers AI binding in a bare local setup) — both are +// accepted, same "unconfigured is fine" posture as freeze/mint above. +{ + const r = await api("GET", "/v1/tokens/account"); + record( + "GET /v1/tokens/account (signup grant applied)", + r.status === 200 && r.data?.balance === 200, + JSON.stringify(r.data), + ); +} +{ + const r = await api("GET", "/v1/tokens/ledger"); + record( + "GET /v1/tokens/ledger (signup grant entry present)", + r.status === 200 && Array.isArray(r.data?.entries) && r.data.entries.some((e) => e.kind === "grant"), + JSON.stringify(r.data).slice(0, 200), + ); +} +{ + const r = await api("GET", "/v1/tokens/packs"); + record( + "GET /v1/tokens/packs (seeded packs visible)", + r.status === 200 && Array.isArray(r.data?.packs) && r.data.packs.length >= 3, + JSON.stringify(r.data).slice(0, 200), + ); +} +{ + const r = await api("POST", "/v1/tokens/purchase/confirm", { pack_id: 1, tx_hash: "0x" + "1".repeat(64) }); + record( + "POST /v1/tokens/purchase/confirm without treasury configured → 503", + r.status === 503, + JSON.stringify(r.data), + ); +} + +let modelSlug = null; +{ + const r = await api("POST", "/v1/models", { + title: `Smoke Model ${RUN}`, + kind: "code", + provider: "anthropic", + visibility: "public", + }); + modelSlug = r.data?.model?.slug ?? null; + record("POST /v1/models", r.status === 201 && !!modelSlug, JSON.stringify(r.data).slice(0, 200)); +} +{ + const r = await api("POST", "/v1/models", { + title: "Bad combo", + kind: "image", + provider: "anthropic", + }); + record( + "POST /v1/models rejects provider/kind mismatch", + r.status === 400 && r.data?.error === "provider_not_allowed_for_kind", + JSON.stringify(r.data), + ); +} +{ + const r = await api("GET", "/v1/models/mine"); + record( + "GET /v1/models/mine", + r.status === 200 && Array.isArray(r.data?.models) && r.data.models.some((m) => m.slug === modelSlug), + JSON.stringify(r.data).slice(0, 200), + ); +} +{ + const r = await api("POST", `/v1/models/${modelSlug}/versions`, { + provider_model_id: "claude-opus-5", + system_prompt: "You write playful p5.js sketches.", + price_tokens: 10, + }); + record( + "POST /v1/models/:slug/versions", + r.status === 201 && r.data?.version?.version === 1, + JSON.stringify(r.data).slice(0, 200), + ); +} +{ + const r = await api("GET", `/v1/models/${modelSlug}`); + record( + "GET /v1/models/:slug (owner sees system_prompt, latest version listed)", + r.status === 200 && r.data?.versions?.[0]?.system_prompt === "You write playful p5.js sketches.", + JSON.stringify(r.data).slice(0, 250), + ); +} +{ + const r = await api("GET", "/v1/models?kind=code"); + record( + "GET /v1/models?kind=code (public catalogue)", + r.status === 200 && r.data?.models?.some((m) => m.slug === modelSlug), + JSON.stringify(r.data).slice(0, 200), + ); +} +let renderJobId = null; +{ + const r = await api("POST", `/v1/models/${modelSlug}/render`, { + prompt: "a field of drifting particles", + seed: "smoke-seed-1", + }); + renderJobId = r.data?.job?.id ?? null; + record( + "POST /v1/models/:slug/render (mock success or provider_unconfigured)", + (r.status === 201 && r.data?.job?.status === "succeeded") || (r.status === 503 && r.data?.error === "provider_unconfigured"), + JSON.stringify(r.data).slice(0, 250), + ); +} +{ + // Same idempotency_key twice must not double-charge. The first call + // creates the job (201); the replay returns the SAME job unchanged + // (200, replayed:true — it isn't a new creation, so a different + // status code here is correct REST, not a bug). + const key = "smoke-idem-" + RUN; + const before = await api("GET", "/v1/tokens/account"); + const first = await api("POST", `/v1/models/${modelSlug}/render`, { prompt: "p", idempotency_key: key }); + const second = await api("POST", `/v1/models/${modelSlug}/render`, { prompt: "p", idempotency_key: key }); + const after = await api("GET", "/v1/tokens/account"); + const sameJob = first.data?.job?.id && first.data.job.id === second.data?.job?.id; + const balanceMovedOnce = + typeof before.data?.balance === "number" && + typeof after.data?.balance === "number" && + before.data.balance - after.data.balance === (first.data?.job?.price_tokens ?? 0); + record( + "POST render with repeated idempotency_key replays instead of double-charging", + first.status === 201 && second.status === 200 && second.data?.replayed === true && sameJob && balanceMovedOnce, + `first=${first.status} second=${second.status} replayed=${second.data?.replayed} before=${before.data?.balance} after=${after.data?.balance} price=${first.data?.job?.price_tokens}`, + ); +} +if (renderJobId) { + const r = await api("GET", `/v1/jobs/${renderJobId}`); + record("GET /v1/jobs/:id (owner)", r.status === 200 && r.data?.job?.id === renderJobId, JSON.stringify(r.data).slice(0, 200)); +} +{ + const r = await api("GET", "/v1/jobs"); + record("GET /v1/jobs (mine)", r.status === 200 && Array.isArray(r.data?.jobs), JSON.stringify(r.data).slice(0, 200)); +} +{ + // Draining the balance below the model's price must fail closed with + // 402 and refund nothing — never silently go negative. + const cheapPrompt = { prompt: "x" }; + let last = null; + for (let i = 0; i < 25; i++) { + last = await api("POST", `/v1/models/${modelSlug}/render`, cheapPrompt); + if (last.status === 402) break; + } + record( + "render debits stop at 402 insufficient_balance rather than going negative", + last && (last.status === 402 || last.status === 503), + `status=${last?.status} body=${JSON.stringify(last?.data).slice(0, 150)}`, + ); + const acct = await api("GET", "/v1/tokens/account"); + record("balance never goes negative", acct.status === 200 && acct.data?.balance >= 0, JSON.stringify(acct.data)); +} + // ---- galleries { const r = await api("GET", "/v1/galleries"); diff --git a/workers/api/src/ai/inference.ts b/workers/api/src/ai/inference.ts new file mode 100644 index 0000000..0bfd631 --- /dev/null +++ b/workers/api/src/ai/inference.ts @@ -0,0 +1,214 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { Env } from "../types"; +import type { ModelKind, ModelProvider } from "../db/render"; + +/** + * Model-execution layer for the render-token service. + * + * Every provider returns the SAME shape so the render handler never + * branches on provider after this call. `outputHash` is sha256 of the + * bytes actually returned — it's what render_jobs.output_hash records, + * so two jobs with the same model version + params + seed can be + * compared for reproducibility without re-running anything. + */ + +export interface InferenceInput { + kind: ModelKind; + provider: ModelProvider; + providerModelId: string; + systemPrompt: string | null; + /** User-supplied instruction/prompt for this run. */ + prompt: string; + params: Record | null; + /** Deterministic seed threaded into the provider call where supported. */ + seed: string; +} + +export interface InferenceOutput { + outputKind: "code" | "image"; + /** Present for `code` jobs — the generated source, inline. */ + text: string | null; + /** Present for `image` jobs — raw bytes for the caller to persist to R2. */ + bytes: Uint8Array | null; + contentType: string | null; + outputHash: string; +} + +export class InferenceError extends Error { + constructor( + message: string, + public code: string, + ) { + super(message); + } +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); +} + +/** + * Whether mock mode is active. OPT-IN ONLY, mirrors PINNING_MOCK / + * GITHUB_MOCK: production must never set RENDER_MOCK. A missing + * ANTHROPIC_API_KEY does NOT enable mock mode — the anthropic provider + * fails closed with `provider_unconfigured` instead. + */ +export function isRenderMock(env: Env): boolean { + return env.RENDER_MOCK === "1"; +} + +/** + * Deterministic canned output for dev/tests. Reproducible from + * (providerModelId, prompt, seed) alone — no network, no randomness — + * so scripts/smoke_api.mjs and local `wrangler dev` runs are hermetic. + */ +async function runMock(input: InferenceInput): Promise { + if (input.kind === "code") { + const text = + `// mock render — provider=${input.provider} model=${input.providerModelId}\n` + + `// seed=${input.seed}\n` + + `function setup() { createCanvas(400, 400); }\n` + + `function draw() { background(0); }\n` + + `// prompt: ${input.prompt.slice(0, 120)}\n`; + const outputHash = await sha256Hex(new TextEncoder().encode(text)); + return { outputKind: "code", text, bytes: null, contentType: null, outputHash }; + } + // A tiny valid 1x1 PNG, identical for every mock image job. Real + // pixels aren't the point of the mock path — reachability is. + const onePxPng = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + ), + (c) => c.charCodeAt(0), + ); + const outputHash = await sha256Hex(onePxPng); + return { + outputKind: "image", + text: null, + bytes: onePxPng, + contentType: "image/png", + outputHash, + }; +} + +/** + * Claude API — code generation. + * + * Uses the official Anthropic SDK per this workspace's convention. + * The determinism contract lives in the system prompt: the model is + * instructed to write self-contained p5.js/three.js code with no + * network calls and no non-seeded randomness — the same discipline the + * frozen-artifact pipeline already requires of every minted sketch. + */ +async function runAnthropic( + env: Env, + input: InferenceInput, +): Promise { + if (!env.ANTHROPIC_API_KEY) { + throw new InferenceError( + "ANTHROPIC_API_KEY is not configured", + "provider_unconfigured", + ); + } + const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); + const determinism = + "Output only self-contained JavaScript for p5.js or three.js. " + + "No network calls, no external imports, no Date.now() or Math.random() " + + "— use the numeric seed given below for any randomness. Return code only, " + + "no prose, no markdown fences."; + const system = [input.systemPrompt, determinism].filter(Boolean).join("\n\n"); + const userMessage = + `Seed: ${input.seed}\n` + + (input.params ? `Params: ${JSON.stringify(input.params)}\n` : "") + + `Instruction: ${input.prompt}`; + + let message; + try { + message = await client.messages.create({ + model: input.providerModelId, + max_tokens: 4096, + system, + messages: [{ role: "user", content: userMessage }], + thinking: { type: "adaptive" }, + }); + } catch (e) { + throw new InferenceError( + e instanceof Error ? e.message : "anthropic request failed", + "provider_error", + ); + } + const text = message.content + .filter((b): b is Anthropic.TextBlock => b.type === "text") + .map((b) => b.text) + .join("\n") + .trim(); + if (!text) { + throw new InferenceError("empty response from model", "provider_error"); + } + const outputHash = await sha256Hex(new TextEncoder().encode(text)); + return { outputKind: "code", text, bytes: null, contentType: null, outputHash }; +} + +/** + * Cloudflare Workers AI — image generation (reference art / textures). + * Output feeds the studio's working material only; it is never eligible + * for the frozen-artifact bundle (enforced in the freeze pipeline, not + * here — this function just runs the model). + */ +async function runWorkersAi( + env: Env, + input: InferenceInput, +): Promise { + if (!env.AI) { + throw new InferenceError( + "Workers AI binding is not configured", + "provider_unconfigured", + ); + } + let result: unknown; + try { + result = await env.AI.run(input.providerModelId, { + prompt: input.prompt, + seed: /^\d+$/.test(input.seed) ? parseInt(input.seed, 10) : undefined, + }); + } catch (e) { + throw new InferenceError( + e instanceof Error ? e.message : "workers ai request failed", + "provider_error", + ); + } + // Workers AI image models return a ReadableStream (binary) response. + let bytes: Uint8Array; + if (result instanceof ReadableStream) { + const buf = await new Response(result).arrayBuffer(); + bytes = new Uint8Array(buf); + } else if (result instanceof Uint8Array) { + bytes = result; + } else { + throw new InferenceError( + "unexpected Workers AI response shape", + "provider_error", + ); + } + const outputHash = await sha256Hex(bytes); + return { + outputKind: "image", + text: null, + bytes, + contentType: "image/png", + outputHash, + }; +} + +export async function runInference( + env: Env, + input: InferenceInput, +): Promise { + if (isRenderMock(env)) return runMock(input); + if (input.provider === "anthropic") return runAnthropic(env, input); + if (input.provider === "workers_ai") return runWorkersAi(env, input); + throw new InferenceError(`unsupported provider: ${input.provider}`, "provider_unconfigured"); +} diff --git a/workers/api/src/auth/siwe.ts b/workers/api/src/auth/siwe.ts index 5215fad..3686d2b 100644 --- a/workers/api/src/auth/siwe.ts +++ b/workers/api/src/auth/siwe.ts @@ -6,6 +6,14 @@ import { checkRateLimit } from "../lib/rateLimit"; import { upsertUserByAddress } from "../db/users"; import { issueSession, SESSION_TTL_SECONDS } from "./jwt"; import { setSessionCookie } from "../lib/cookies"; +import { applyLedgerEntry } from "../db/tokens"; + +// Free render-token allowance granted on signup, so a new creative coder +// can try the render service before paying for a pack. Idempotency key +// is keyed on the user id, not the sign-in event, so this call is safe +// to make on every sign-in — it applies exactly once and no-ops (via +// `replayed`) on every sign-in after the first. +const SIGNUP_BONUS_TOKENS = 200; const NONCE_TTL_SECONDS = 5 * 60; @@ -160,6 +168,23 @@ export async function verifyHandler(c: Context<{ Bindings: Env }>) { } const user = await upsertUserByAddress(c.env.DB, siwe.address); + + try { + await applyLedgerEntry(c.env.DB, { + userId: user.id, + delta: SIGNUP_BONUS_TOKENS, + kind: "grant", + idempotencyKey: `signup:${user.id}`, + refKind: "signup", + memo: "Welcome grant", + }); + } catch (e) { + // Best-effort, same as recordEvent elsewhere: a missed bonus grant + // is recoverable (an admin can re-grant); sign-in must not fail + // because of it. + console.error("signup_bonus_failed", e); + } + const { token, jti } = await issueSession(c.env.JWT_SECRET, user.id, user.address); setSessionCookie(c, token, c.env.COOKIE_DOMAIN, SESSION_TTL_SECONDS); diff --git a/workers/api/src/db/render.ts b/workers/api/src/db/render.ts new file mode 100644 index 0000000..adff8d6 --- /dev/null +++ b/workers/api/src/db/render.ts @@ -0,0 +1,575 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { slugify } from "../lib/slug"; + +/** + * Custom render models and the jobs run against them. + * + * A model is a creator-published recipe (provider + system prompt + + * parameter schema + price). Versions are immutable: a job stores the + * version id it ran against, so editing a published version in place + * would rewrite the provenance of every earlier job. + */ + +export const MODEL_KINDS = ["code", "image"] as const; +export type ModelKind = (typeof MODEL_KINDS)[number]; + +export const MODEL_PROVIDERS = ["anthropic", "workers_ai", "mock"] as const; +export type ModelProvider = (typeof MODEL_PROVIDERS)[number]; + +export const MODEL_VISIBILITIES = ["public", "unlisted", "private"] as const; +export type ModelVisibility = (typeof MODEL_VISIBILITIES)[number]; + +export const JOB_STATUSES = [ + "queued", + "running", + "succeeded", + "failed", +] as const; +export type JobStatus = (typeof JOB_STATUSES)[number]; + +export function isModelKind(v: unknown): v is ModelKind { + return typeof v === "string" && (MODEL_KINDS as readonly string[]).includes(v); +} +export function isModelProvider(v: unknown): v is ModelProvider { + return ( + typeof v === "string" && + (MODEL_PROVIDERS as readonly string[]).includes(v) + ); +} +export function isModelVisibility(v: unknown): v is ModelVisibility { + return ( + typeof v === "string" && + (MODEL_VISIBILITIES as readonly string[]).includes(v) + ); +} + +/** + * Which provider each model kind is allowed to use. + * + * Enforced at registration time rather than at render time: an image + * model pointed at the code provider would only fail once someone had + * already been charged for the job. + */ +export const PROVIDERS_BY_KIND: Record = { + code: ["anthropic", "mock"], + image: ["workers_ai", "mock"], +}; + +export function providerAllowedForKind( + kind: ModelKind, + provider: ModelProvider, +): boolean { + return PROVIDERS_BY_KIND[kind].includes(provider); +} + +export interface ModelRow { + id: number; + owner_id: number; + slug: string; + title: string; + description: string | null; + kind: string; + provider: string; + visibility: string; + status: string; + created_at: number; + updated_at: number; +} + +export interface ModelVersionRow { + id: number; + model_id: number; + version: number; + provider_model_id: string; + system_prompt: string | null; + params_schema_json: string | null; + price_tokens: number; + created_at: number; +} + +export interface ModelListItem extends ModelRow { + owner_handle: string; + owner_display_name: string | null; + owner_avatar_url: string | null; + latest_version: number | null; + latest_price_tokens: number | null; + run_count: number; +} + +export async function uniqueModelSlug( + db: D1Database, + base: string, +): Promise { + const baseSlug = slugify(base); + for (let i = 0; i < 50; i++) { + const candidate = i === 0 ? baseSlug : `${baseSlug}-${i + 1}`; + const taken = await db + .prepare("SELECT 1 FROM render_models WHERE slug = ?") + .bind(candidate) + .first(); + if (!taken) return candidate; + } + return `${baseSlug}-${crypto.randomUUID().slice(0, 6)}`; +} + +export async function insertModel( + db: D1Database, + input: { + ownerId: number; + slug: string; + title: string; + description: string | null; + kind: ModelKind; + provider: ModelProvider; + visibility: ModelVisibility; + }, +): Promise { + const now = Math.floor(Date.now() / 1000); + const row = await db + .prepare( + `INSERT INTO render_models + (owner_id, slug, title, description, kind, provider, visibility, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + RETURNING *`, + ) + .bind( + input.ownerId, + input.slug, + input.title, + input.description, + input.kind, + input.provider, + input.visibility, + now, + now, + ) + .first(); + if (!row) throw new Error("insertModel returned no row"); + return row; +} + +export async function getModelBySlug( + db: D1Database, + slug: string, +): Promise { + return await db + .prepare(`SELECT * FROM render_models WHERE slug = ?`) + .bind(slug) + .first(); +} + +export async function updateModel( + db: D1Database, + id: number, + patch: { + title?: string; + description?: string | null; + visibility?: ModelVisibility; + status?: "active" | "disabled"; + }, +): Promise { + const sets: string[] = []; + const binds: unknown[] = []; + if (patch.title !== undefined) { + sets.push("title = ?"); + binds.push(patch.title); + } + if (patch.description !== undefined) { + sets.push("description = ?"); + binds.push(patch.description); + } + if (patch.visibility !== undefined) { + sets.push("visibility = ?"); + binds.push(patch.visibility); + } + if (patch.status !== undefined) { + sets.push("status = ?"); + binds.push(patch.status); + } + if (sets.length === 0) { + return await db + .prepare(`SELECT * FROM render_models WHERE id = ?`) + .bind(id) + .first(); + } + sets.push("updated_at = ?"); + binds.push(Math.floor(Date.now() / 1000), id); + return await db + .prepare( + `UPDATE render_models SET ${sets.join(", ")} WHERE id = ? RETURNING *`, + ) + .bind(...binds) + .first(); +} + +/** + * Public listing: `public` + `active` models only, newest first, with + * the owner joined and the latest version's price denormalised so the + * catalogue renders without an N+1. + */ +export async function listPublicModels( + db: D1Database, + opts: { kind?: ModelKind; limit: number; before?: number }, +): Promise { + const where: string[] = [ + "m.visibility = 'public'", + "m.status = 'active'", + ]; + const binds: unknown[] = []; + if (opts.kind) { + where.push("m.kind = ?"); + binds.push(opts.kind); + } + if (opts.before) { + where.push("m.updated_at < ?"); + binds.push(opts.before); + } + binds.push(opts.limit); + const { results } = await db + .prepare( + `SELECT m.*, + u.handle AS owner_handle, + u.display_name AS owner_display_name, + u.avatar_url AS owner_avatar_url, + (SELECT MAX(version) FROM render_model_versions v + WHERE v.model_id = m.id) AS latest_version, + (SELECT v.price_tokens FROM render_model_versions v + WHERE v.model_id = m.id + ORDER BY v.version DESC LIMIT 1) AS latest_price_tokens, + (SELECT COUNT(*) FROM render_jobs j + JOIN render_model_versions v2 ON v2.id = j.model_version_id + WHERE v2.model_id = m.id AND j.status = 'succeeded') + AS run_count + FROM render_models m + JOIN users u ON u.id = m.owner_id + WHERE ${where.join(" AND ")} + ORDER BY m.updated_at DESC + LIMIT ?`, + ) + .bind(...binds) + .all(); + return results ?? []; +} + +export async function listModelsByOwner( + db: D1Database, + ownerId: number, +): Promise { + const { results } = await db + .prepare( + `SELECT m.*, + u.handle AS owner_handle, + u.display_name AS owner_display_name, + u.avatar_url AS owner_avatar_url, + (SELECT MAX(version) FROM render_model_versions v + WHERE v.model_id = m.id) AS latest_version, + (SELECT v.price_tokens FROM render_model_versions v + WHERE v.model_id = m.id + ORDER BY v.version DESC LIMIT 1) AS latest_price_tokens, + (SELECT COUNT(*) FROM render_jobs j + JOIN render_model_versions v2 ON v2.id = j.model_version_id + WHERE v2.model_id = m.id AND j.status = 'succeeded') + AS run_count + FROM render_models m + JOIN users u ON u.id = m.owner_id + WHERE m.owner_id = ? + ORDER BY m.updated_at DESC + LIMIT 200`, + ) + .bind(ownerId) + .all(); + return results ?? []; +} + +/** + * Appends an immutable version. `version` is allocated server-side as + * MAX+1 rather than taken from the caller, so two concurrent publishes + * can't both claim v3 — the UNIQUE(model_id, version) index turns that + * race into a constraint error the handler retries. + */ +export async function insertModelVersion( + db: D1Database, + input: { + modelId: number; + providerModelId: string; + systemPrompt: string | null; + paramsSchemaJson: string | null; + priceTokens: number; + }, +): Promise { + const now = Math.floor(Date.now() / 1000); + const row = await db + .prepare( + `INSERT INTO render_model_versions + (model_id, version, provider_model_id, system_prompt, + params_schema_json, price_tokens, created_at) + SELECT ?, + COALESCE((SELECT MAX(version) FROM render_model_versions + WHERE model_id = ?), 0) + 1, + ?, ?, ?, ?, ? + RETURNING *`, + ) + .bind( + input.modelId, + input.modelId, + input.providerModelId, + input.systemPrompt, + input.paramsSchemaJson, + input.priceTokens, + now, + ) + .first(); + if (!row) throw new Error("insertModelVersion returned no row"); + // Surface the new version on the catalogue's sort key. + await db + .prepare(`UPDATE render_models SET updated_at = ? WHERE id = ?`) + .bind(now, input.modelId) + .run(); + return row; +} + +export async function listModelVersions( + db: D1Database, + modelId: number, +): Promise { + const { results } = await db + .prepare( + `SELECT * FROM render_model_versions + WHERE model_id = ? ORDER BY version DESC LIMIT 100`, + ) + .bind(modelId) + .all(); + return results ?? []; +} + +export async function getModelVersion( + db: D1Database, + versionId: number, +): Promise { + return await db + .prepare(`SELECT * FROM render_model_versions WHERE id = ?`) + .bind(versionId) + .first(); +} + +export async function getLatestModelVersion( + db: D1Database, + modelId: number, +): Promise { + return await db + .prepare( + `SELECT * FROM render_model_versions + WHERE model_id = ? ORDER BY version DESC LIMIT 1`, + ) + .bind(modelId) + .first(); +} + +export function publicModel(m: ModelRow, extra?: Partial) { + return { + id: m.id, + slug: m.slug, + title: m.title, + description: m.description, + kind: m.kind, + provider: m.provider, + visibility: m.visibility, + status: m.status, + created_at: m.created_at, + updated_at: m.updated_at, + latest_version: extra?.latest_version ?? null, + latest_price_tokens: extra?.latest_price_tokens ?? null, + run_count: extra?.run_count ?? 0, + owner: extra?.owner_handle + ? { + handle: extra.owner_handle, + display_name: extra.owner_display_name ?? null, + avatar_url: extra.owner_avatar_url ?? null, + } + : undefined, + }; +} + +export function publicModelVersion(v: ModelVersionRow, includePrompt = false) { + return { + id: v.id, + version: v.version, + provider_model_id: v.provider_model_id, + // `system_prompt` is the creator's authored work and the thing a + // competing publisher would copy — omitted for every viewer except + // the model's own owner (includePrompt), who gets it back so they + // can review/iterate on what they published. + system_prompt: includePrompt ? v.system_prompt : undefined, + params_schema: v.params_schema_json + ? safeParse(v.params_schema_json) + : null, + price_tokens: v.price_tokens, + created_at: v.created_at, + }; +} + +function safeParse(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Jobs +// --------------------------------------------------------------------------- + +export interface JobRow { + id: number; + user_id: number; + model_version_id: number; + project_id: number | null; + seed: string; + params_json: string | null; + prompt_hash: string | null; + status: string; + price_tokens: number; + owner_earn_tokens: number; + output_kind: string | null; + output_text: string | null; + output_key: string | null; + output_hash: string | null; + error_code: string | null; + idempotency_key: string; + created_at: number; + finished_at: number | null; +} + +export async function insertJob( + db: D1Database, + input: { + userId: number; + modelVersionId: number; + projectId: number | null; + seed: string; + paramsJson: string | null; + promptHash: string | null; + priceTokens: number; + ownerEarnTokens: number; + idempotencyKey: string; + }, +): Promise { + const now = Math.floor(Date.now() / 1000); + const row = await db + .prepare( + `INSERT INTO render_jobs + (user_id, model_version_id, project_id, seed, params_json, + prompt_hash, status, price_tokens, owner_earn_tokens, + idempotency_key, created_at) + VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?) + RETURNING *`, + ) + .bind( + input.userId, + input.modelVersionId, + input.projectId, + input.seed, + input.paramsJson, + input.promptHash, + input.priceTokens, + input.ownerEarnTokens, + input.idempotencyKey, + now, + ) + .first(); + if (!row) throw new Error("insertJob returned no row"); + return row; +} + +export async function getJobByIdempotencyKey( + db: D1Database, + key: string, +): Promise { + return await db + .prepare(`SELECT * FROM render_jobs WHERE idempotency_key = ?`) + .bind(key) + .first(); +} + +export async function getJobById( + db: D1Database, + id: number, +): Promise { + return await db + .prepare(`SELECT * FROM render_jobs WHERE id = ?`) + .bind(id) + .first(); +} + +export async function finishJob( + db: D1Database, + id: number, + patch: { + status: "succeeded" | "failed"; + outputKind?: string | null; + outputText?: string | null; + outputKey?: string | null; + outputHash?: string | null; + errorCode?: string | null; + }, +): Promise { + return await db + .prepare( + `UPDATE render_jobs + SET status = ?, output_kind = ?, output_text = ?, output_key = ?, + output_hash = ?, error_code = ?, finished_at = ? + WHERE id = ? AND status = 'running' + RETURNING *`, + ) + .bind( + patch.status, + patch.outputKind ?? null, + patch.outputText ?? null, + patch.outputKey ?? null, + patch.outputHash ?? null, + patch.errorCode ?? null, + Math.floor(Date.now() / 1000), + id, + ) + .first(); +} + +export async function listJobsByUser( + db: D1Database, + userId: number, + limit: number, + before?: number, +): Promise { + const sql = before + ? `SELECT * FROM render_jobs WHERE user_id = ? AND id < ? + ORDER BY id DESC LIMIT ?` + : `SELECT * FROM render_jobs WHERE user_id = ? + ORDER BY id DESC LIMIT ?`; + const stmt = db.prepare(sql); + const bound = before + ? stmt.bind(userId, before, limit) + : stmt.bind(userId, limit); + const { results } = await bound.all(); + return results ?? []; +} + +export function publicJob(j: JobRow) { + return { + id: j.id, + model_version_id: j.model_version_id, + project_id: j.project_id, + seed: j.seed, + params: j.params_json ? safeParse(j.params_json) : null, + status: j.status, + price_tokens: j.price_tokens, + output_kind: j.output_kind, + output_text: j.output_text, + output_key: j.output_key, + output_hash: j.output_hash, + error_code: j.error_code, + created_at: j.created_at, + finished_at: j.finished_at, + }; +} diff --git a/workers/api/src/db/tokens.ts b/workers/api/src/db/tokens.ts new file mode 100644 index 0000000..933f891 --- /dev/null +++ b/workers/api/src/db/tokens.ts @@ -0,0 +1,391 @@ +import type { D1Database } from "@cloudflare/workers-types"; + +/** + * Render-token accounting. + * + * A render token is a prepaid compute credit, never an ERC-721 token — + * see the header of migrations/0018_token_service.sql. + * + * `token_ledger` is the source of truth and `token_accounts.balance` is + * a cache of it. Both move in a single D1 batch (batches are + * transactional) under the identical `balance + delta >= 0` guard, so a + * debit that would overdraw applies to neither table. + */ + +export const LEDGER_KINDS = [ + "grant", + "purchase", + "debit", + "refund", + "earn", + "adjust", +] as const; + +export type LedgerKind = (typeof LEDGER_KINDS)[number]; + +export function isLedgerKind(v: unknown): v is LedgerKind { + return typeof v === "string" && (LEDGER_KINDS as readonly string[]).includes(v); +} + +export interface TokenAccount { + user_id: number; + balance: number; + lifetime_purchased: number; + lifetime_spent: number; + lifetime_earned: number; + created_at: number; + updated_at: number; +} + +export interface LedgerRow { + id: number; + user_id: number; + delta: number; + kind: string; + balance_after: number; + ref_kind: string | null; + ref_id: number | null; + memo: string | null; + idempotency_key: string; + created_at: number; +} + +export interface LedgerEntryInput { + userId: number; + /** Signed. Positive credits, negative debits. Zero is rejected. */ + delta: number; + kind: LedgerKind; + /** Replay guard. Must be stable for the operation it represents. */ + idempotencyKey: string; + refKind?: string | null; + refId?: number | null; + memo?: string | null; +} + +export type LedgerResult = + | { status: "applied"; entry: LedgerRow } + | { status: "replayed"; entry: LedgerRow } + | { status: "insufficient"; balance: number; shortfall: number }; + +/** + * Which lifetime counter a ledger entry advances. + * + * Split out as a pure function because the mapping is the part that is + * easy to get subtly wrong (a refund must not reduce `lifetime_spent`, + * or a user who renders and gets refunded reads as having spent a + * negative amount). Unit-tested directly. + */ +export function lifetimeDeltas(kind: LedgerKind, delta: number): { + purchased: number; + spent: number; + earned: number; +} { + return { + purchased: kind === "purchase" || kind === "grant" ? Math.max(0, delta) : 0, + spent: kind === "debit" ? Math.max(0, -delta) : 0, + earned: kind === "earn" ? Math.max(0, delta) : 0, + }; +} + +/** Creates the account row on first touch. Safe to call repeatedly. */ +export async function ensureAccount( + db: D1Database, + userId: number, +): Promise { + const now = Math.floor(Date.now() / 1000); + await db + .prepare( + `INSERT OR IGNORE INTO token_accounts + (user_id, balance, created_at, updated_at) + VALUES (?, 0, ?, ?)`, + ) + .bind(userId, now, now) + .run(); +} + +export async function getAccount( + db: D1Database, + userId: number, +): Promise { + await ensureAccount(db, userId); + const row = await db + .prepare(`SELECT * FROM token_accounts WHERE user_id = ?`) + .bind(userId) + .first(); + // ensureAccount just guaranteed the row; the fallback keeps the return + // type honest rather than asserting non-null. + return ( + row ?? { + user_id: userId, + balance: 0, + lifetime_purchased: 0, + lifetime_spent: 0, + lifetime_earned: 0, + created_at: 0, + updated_at: 0, + } + ); +} + +function isUniqueViolation(e: unknown): boolean { + const msg = e instanceof Error ? e.message : String(e); + return /UNIQUE constraint failed/i.test(msg); +} + +async function entryByKey( + db: D1Database, + key: string, +): Promise { + return await db + .prepare(`SELECT * FROM token_ledger WHERE idempotency_key = ?`) + .bind(key) + .first(); +} + +/** + * The only balance-mutating path in the codebase. + * + * Returns `insufficient` rather than throwing when a debit would + * overdraw, so callers can surface the shortfall to the user. Returns + * `replayed` when `idempotencyKey` has already been used, carrying the + * original entry — a retried request is a no-op, not a second charge. + */ +export async function applyLedgerEntry( + db: D1Database, + input: LedgerEntryInput, +): Promise { + if (!Number.isInteger(input.delta) || input.delta === 0) { + throw new Error("ledger delta must be a non-zero integer"); + } + await ensureAccount(db, input.userId); + + // Fast path for an already-applied key. The UNIQUE index below is what + // actually enforces this; the lookup just avoids a guaranteed-failing + // batch on the common retry. + const existing = await entryByKey(db, input.idempotencyKey); + if (existing) return { status: "replayed", entry: existing }; + + const now = Math.floor(Date.now() / 1000); + const life = lifetimeDeltas(input.kind, input.delta); + const { userId, delta } = input; + + // Statement order matters. The INSERT runs first so `a.balance` is + // still the pre-mutation value and `balance_after` is computed from + // it. Both statements carry the same `balance + delta >= 0` guard, so + // an overdraw silently affects zero rows in both — which is exactly + // the signal read back from results[0].meta.changes. + const insertLedger = db + .prepare( + `INSERT INTO token_ledger + (user_id, delta, kind, balance_after, ref_kind, ref_id, memo, + idempotency_key, created_at) + SELECT ?, ?, ?, a.balance + ?, ?, ?, ?, ?, ? + FROM token_accounts a + WHERE a.user_id = ? AND a.balance + ? >= 0`, + ) + .bind( + userId, + delta, + input.kind, + delta, + input.refKind ?? null, + input.refId ?? null, + input.memo ?? null, + input.idempotencyKey, + now, + userId, + delta, + ); + + const updateAccount = db + .prepare( + `UPDATE token_accounts + SET balance = balance + ?, + lifetime_purchased = lifetime_purchased + ?, + lifetime_spent = lifetime_spent + ?, + lifetime_earned = lifetime_earned + ?, + updated_at = ? + WHERE user_id = ? AND balance + ? >= 0`, + ) + .bind(delta, life.purchased, life.spent, life.earned, now, userId, delta); + + const readBack = db + .prepare(`SELECT * FROM token_ledger WHERE idempotency_key = ?`) + .bind(input.idempotencyKey); + + let applied = false; + let entry: LedgerRow | null = null; + try { + const results = await db.batch([ + insertLedger, + updateAccount, + readBack, + ]); + applied = (results[0]?.meta?.changes ?? 0) > 0; + entry = results[2]?.results?.[0] ?? null; + } catch (e) { + // A concurrent request won the race on the same key. The batch rolled + // back, so re-reading gives that request's entry. + if (isUniqueViolation(e)) { + const raced = await entryByKey(db, input.idempotencyKey); + if (raced) return { status: "replayed", entry: raced }; + } + throw e; + } + + if (!applied || !entry) { + const account = await getAccount(db, userId); + return { + status: "insufficient", + balance: account.balance, + shortfall: Math.max(0, -delta - account.balance), + }; + } + return { status: "applied", entry }; +} + +export async function listLedger( + db: D1Database, + userId: number, + limit: number, + before?: number, +): Promise { + const sql = before + ? `SELECT * FROM token_ledger WHERE user_id = ? AND id < ? + ORDER BY id DESC LIMIT ?` + : `SELECT * FROM token_ledger WHERE user_id = ? + ORDER BY id DESC LIMIT ?`; + const stmt = db.prepare(sql); + const bound = before + ? stmt.bind(userId, before, limit) + : stmt.bind(userId, limit); + const { results } = await bound.all(); + return results ?? []; +} + +export function publicLedgerEntry(row: LedgerRow) { + return { + id: row.id, + delta: row.delta, + kind: row.kind, + balance_after: row.balance_after, + ref_kind: row.ref_kind, + ref_id: row.ref_id, + memo: row.memo, + created_at: row.created_at, + }; +} + +// --------------------------------------------------------------------------- +// Packs + purchases +// --------------------------------------------------------------------------- + +export interface PackRow { + id: number; + slug: string; + title: string; + tokens: number; + price_wei: string; + active: number; + sort: number; + created_at: number; +} + +export async function listActivePacks(db: D1Database): Promise { + const { results } = await db + .prepare( + `SELECT * FROM token_packs WHERE active = 1 ORDER BY sort ASC, id ASC`, + ) + .all(); + return results ?? []; +} + +export async function getPackById( + db: D1Database, + id: number, +): Promise { + return await db + .prepare(`SELECT * FROM token_packs WHERE id = ? AND active = 1`) + .bind(id) + .first(); +} + +export function publicPack(p: PackRow) { + return { + id: p.id, + slug: p.slug, + title: p.title, + tokens: p.tokens, + price_wei: p.price_wei, + }; +} + +export interface PurchaseRow { + id: number; + user_id: number; + pack_id: number; + chain_id: number; + tx_hash: string; + from_address: string; + value_wei: string; + tokens: number; + created_at: number; +} + +/** + * Claims a settled on-chain payment. The UNIQUE(chain_id, tx_hash) + * constraint is the anti-replay mechanism — a second attempt to redeem + * the same transfer returns null instead of inserting. + */ +export async function claimPurchase( + db: D1Database, + input: { + userId: number; + packId: number; + chainId: number; + txHash: string; + fromAddress: string; + valueWei: string; + tokens: number; + }, +): Promise { + const now = Math.floor(Date.now() / 1000); + try { + const row = await db + .prepare( + `INSERT INTO token_purchases + (user_id, pack_id, chain_id, tx_hash, from_address, value_wei, + tokens, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING *`, + ) + .bind( + input.userId, + input.packId, + input.chainId, + input.txHash.toLowerCase(), + input.fromAddress.toLowerCase(), + input.valueWei, + input.tokens, + now, + ) + .first(); + return row ?? null; + } catch (e) { + if (isUniqueViolation(e)) return null; + throw e; + } +} + +export async function getPurchaseByTx( + db: D1Database, + chainId: number, + txHash: string, +): Promise { + return await db + .prepare( + `SELECT * FROM token_purchases WHERE chain_id = ? AND tx_hash = ?`, + ) + .bind(chainId, txHash.toLowerCase()) + .first(); +} diff --git a/workers/api/src/index.ts b/workers/api/src/index.ts index 584f07f..03c3be6 100644 --- a/workers/api/src/index.ts +++ b/workers/api/src/index.ts @@ -67,6 +67,23 @@ import { uploadGalleryCoverHandler, projectGalleriesHandler, } from "./galleries/handlers"; +import { + tokenAccountHandler, + tokenLedgerHandler, + listPacksHandler, + confirmPurchaseHandler, +} from "./tokens/handlers"; +import { + createModelHandler, + patchModelHandler, + listModelsHandler, + myModelsHandler, + getModelHandler, + publishVersionHandler, + renderHandler, + myJobsHandler, + getJobHandler, +} from "./render/handlers"; import { accessMiddleware } from "./middleware/access"; import { logError } from "./lib/log"; import { captureException } from "./lib/sentry"; @@ -199,6 +216,27 @@ v1.get("/feed", requireAuth, feedHandler); v1.get("/notifications", requireAuth, notificationsHandler); v1.post("/notifications/read", requireAuth, markNotificationsReadHandler); +// Render-token service. Balances/ledger/purchases are all personal or +// public catalogue reads — no admin surface needed yet. Route order +// matters: the literal "mine" and "packs"/"purchase" segments must +// precede the `:slug`/`:id` patterns below so Hono doesn't swallow them +// as a slug/id (same reasoning as the galleries "cover" route above). +v1.get("/tokens/account", requireAuth, tokenAccountHandler); +v1.get("/tokens/ledger", requireAuth, tokenLedgerHandler); +v1.get("/tokens/packs", listPacksHandler); +v1.post("/tokens/purchase/confirm", requireAuth, confirmPurchaseHandler); + +v1.get("/models", listModelsHandler); +v1.get("/models/mine", requireAuth, myModelsHandler); +v1.post("/models", requireAuth, createModelHandler); +v1.get("/models/:slug{[a-z0-9-]+}", getModelHandler); +v1.patch("/models/:slug{[a-z0-9-]+}", requireAuth, patchModelHandler); +v1.post("/models/:slug{[a-z0-9-]+}/versions", requireAuth, publishVersionHandler); +v1.post("/models/:slug{[a-z0-9-]+}/render", requireAuth, renderHandler); + +v1.get("/jobs", requireAuth, myJobsHandler); +v1.get("/jobs/:id{[0-9]+}", requireAuth, getJobHandler); + // Admin-only observability surface. requireAuth runs first so // requireAdmin can read the session. _throw is a forced 500 used // to verify the request_id round-trips end to end. diff --git a/workers/api/src/render/handlers.ts b/workers/api/src/render/handlers.ts new file mode 100644 index 0000000..5303c67 --- /dev/null +++ b/workers/api/src/render/handlers.ts @@ -0,0 +1,590 @@ +import type { Context } from "hono"; +import type { Env } from "../types"; +import type { AuthVariables } from "../auth/middleware"; +import { getAuthUser } from "../auth/middleware"; +import { maybeAuthUser } from "../users/handlers"; +import { checkRateLimit } from "../lib/rateLimit"; +import { getProjectById } from "../db/projects"; +import { applyLedgerEntry } from "../db/tokens"; +import { runInference, InferenceError } from "../ai/inference"; +import { + isModelKind, + isModelProvider, + isModelVisibility, + providerAllowedForKind, + uniqueModelSlug, + insertModel, + getModelBySlug, + updateModel, + listPublicModels, + listModelsByOwner, + insertModelVersion, + listModelVersions, + getLatestModelVersion, + getModelVersion, + publicModel, + publicModelVersion, + insertJob, + getJobByIdempotencyKey, + getJobById, + finishJob, + listJobsByUser, + publicJob, + type ModelKind, +} from "../db/render"; + +function badRequest(c: Context, error: string, detail?: unknown) { + return c.json({ error, detail }, 400); +} + +const TITLE_MAX = 100; +const DESC_MAX = 2000; +const PROMPT_MAX = 4000; +const SYSTEM_PROMPT_MAX = 8000; +const PARAMS_JSON_MAX = 8000; +const PRICE_MAX = 100_000; // ceiling against a fat-fingered pack-draining price +const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,47}$/; + +// --------------------------------------------------------------------------- +// Model registry +// --------------------------------------------------------------------------- + +interface CreateModelBody { + slug?: unknown; + title?: unknown; + description?: unknown; + kind?: unknown; + provider?: unknown; + visibility?: unknown; +} + +/** POST /v1/models — register a new (versionless) model shell. */ +export async function createModelHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + + const rl = await checkRateLimit(c.env.RATE_LIMIT, { + key: `models:create:${session.uid}`, + limit: 20, + windowSeconds: 86400, + }); + if (!rl.ok) return c.json({ error: "rate_limited", reset_at: rl.resetAt }, 429); + + let body: CreateModelBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const title = typeof body.title === "string" ? body.title.trim() : ""; + if (!title || title.length > TITLE_MAX) { + return badRequest(c, "invalid_title", { max: TITLE_MAX }); + } + if (!isModelKind(body.kind)) { + return badRequest(c, "invalid_kind"); + } + if (!isModelProvider(body.provider)) { + return badRequest(c, "invalid_provider"); + } + if (!providerAllowedForKind(body.kind, body.provider)) { + return badRequest(c, "provider_not_allowed_for_kind", { + kind: body.kind, + provider: body.provider, + }); + } + const visibility = isModelVisibility(body.visibility) ? body.visibility : "private"; + + let description: string | null = null; + if (body.description !== undefined && body.description !== null) { + if (typeof body.description !== "string" || body.description.length > DESC_MAX) { + return badRequest(c, "invalid_description", { max: DESC_MAX }); + } + description = body.description.trim() || null; + } + + let slug: string; + if (typeof body.slug === "string" && body.slug.length > 0) { + if (!SLUG_RE.test(body.slug)) return badRequest(c, "invalid_slug"); + const taken = await getModelBySlug(c.env.DB, body.slug); + if (taken) return badRequest(c, "slug_taken", 409 as never); + slug = body.slug; + } else { + slug = await uniqueModelSlug(c.env.DB, title); + } + + const model = await insertModel(c.env.DB, { + ownerId: session.uid, + slug, + title, + description, + kind: body.kind, + provider: body.provider, + visibility, + }); + return c.json({ model: publicModel(model) }, 201); +} + +interface PatchModelBody { + title?: unknown; + description?: unknown; + visibility?: unknown; + status?: unknown; +} + +/** PATCH /v1/models/:slug — owner-only metadata edits. */ +export async function patchModelHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const slug = c.req.param("slug") || ""; + const model = await getModelBySlug(c.env.DB, slug); + if (!model) return c.json({ error: "not_found" }, 404); + if (model.owner_id !== session.uid) return c.json({ error: "forbidden" }, 403); + + let body: PatchModelBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const patch: Parameters[2] = {}; + if (body.title !== undefined) { + if (typeof body.title !== "string" || !body.title.trim() || body.title.length > TITLE_MAX) { + return badRequest(c, "invalid_title", { max: TITLE_MAX }); + } + patch.title = body.title.trim(); + } + if (body.description !== undefined) { + if (body.description !== null && (typeof body.description !== "string" || body.description.length > DESC_MAX)) { + return badRequest(c, "invalid_description", { max: DESC_MAX }); + } + patch.description = body.description === null ? null : (body.description as string).trim() || null; + } + if (body.visibility !== undefined) { + if (!isModelVisibility(body.visibility)) return badRequest(c, "invalid_visibility"); + patch.visibility = body.visibility; + } + if (body.status !== undefined) { + if (body.status !== "active" && body.status !== "disabled") { + return badRequest(c, "invalid_status"); + } + patch.status = body.status; + } + + const updated = await updateModel(c.env.DB, model.id, patch); + return c.json({ model: publicModel(updated ?? model) }); +} + +const LIST_DEFAULT = 20; +const LIST_MAX = 50; + +/** GET /v1/models?kind=&limit=&before= — public catalogue. */ +export async function listModelsHandler(c: Context<{ Bindings: Env }>) { + const url = new URL(c.req.url); + const kindRaw = url.searchParams.get("kind"); + let kind: ModelKind | undefined; + if (kindRaw) { + if (!isModelKind(kindRaw)) return badRequest(c, "invalid_kind"); + kind = kindRaw; + } + const limitRaw = url.searchParams.get("limit"); + let limit = LIST_DEFAULT; + if (limitRaw) { + const n = parseInt(limitRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > LIST_MAX) { + return badRequest(c, "invalid_limit", { max: LIST_MAX }); + } + limit = n; + } + const beforeRaw = url.searchParams.get("before"); + let before: number | undefined; + if (beforeRaw) { + const n = parseInt(beforeRaw, 10); + if (!Number.isFinite(n) || n < 0) return badRequest(c, "invalid_before"); + before = n; + } + const rows = await listPublicModels(c.env.DB, { kind, limit, before }); + return c.json({ + models: rows.map((r) => publicModel(r, r)), + next_before: rows.length === limit ? rows[rows.length - 1].updated_at : null, + }); +} + +/** GET /v1/models/mine — the caller's own models, any visibility/status. */ +export async function myModelsHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const rows = await listModelsByOwner(c.env.DB, session.uid); + return c.json({ models: rows.map((r) => publicModel(r, r)) }); +} + +/** GET /v1/models/:slug — detail + version history. */ +export async function getModelHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const slug = c.req.param("slug") || ""; + const model = await getModelBySlug(c.env.DB, slug); + if (!model) return c.json({ error: "not_found" }, 404); + + const viewer = await maybeAuthUser(c); + const isOwner = !!viewer && viewer.uid === model.owner_id; + if (model.visibility === "private" && !isOwner) { + return c.json({ error: "not_found" }, 404); + } + + const versions = await listModelVersions(c.env.DB, model.id); + return c.json({ + model: publicModel(model), + versions: versions.map((v) => publicModelVersion(v, isOwner)), + }); +} + +interface PublishVersionBody { + provider_model_id?: unknown; + system_prompt?: unknown; + params_schema?: unknown; + price_tokens?: unknown; +} + +/** POST /v1/models/:slug/versions — owner-only, append-only publish. */ +export async function publishVersionHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const slug = c.req.param("slug") || ""; + const model = await getModelBySlug(c.env.DB, slug); + if (!model) return c.json({ error: "not_found" }, 404); + if (model.owner_id !== session.uid) return c.json({ error: "forbidden" }, 403); + + let body: PublishVersionBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const providerModelId = + typeof body.provider_model_id === "string" ? body.provider_model_id.trim() : ""; + if (!providerModelId || providerModelId.length > 200) { + return badRequest(c, "invalid_provider_model_id"); + } + + let systemPrompt: string | null = null; + if (body.system_prompt !== undefined && body.system_prompt !== null) { + if (typeof body.system_prompt !== "string" || body.system_prompt.length > SYSTEM_PROMPT_MAX) { + return badRequest(c, "invalid_system_prompt", { max: SYSTEM_PROMPT_MAX }); + } + systemPrompt = body.system_prompt; + } + + let paramsSchemaJson: string | null = null; + if (body.params_schema !== undefined && body.params_schema !== null) { + let serialized: string; + try { + serialized = JSON.stringify(body.params_schema); + } catch { + return badRequest(c, "invalid_params_schema"); + } + if (serialized.length > PARAMS_JSON_MAX) { + return badRequest(c, "params_schema_too_large", { max: PARAMS_JSON_MAX }); + } + paramsSchemaJson = serialized; + } + + const priceTokens = + typeof body.price_tokens === "number" ? Math.trunc(body.price_tokens) : NaN; + if (!Number.isFinite(priceTokens) || priceTokens < 0 || priceTokens > PRICE_MAX) { + return badRequest(c, "invalid_price_tokens", { max: PRICE_MAX }); + } + + const version = await insertModelVersion(c.env.DB, { + modelId: model.id, + providerModelId, + systemPrompt, + paramsSchemaJson, + priceTokens, + }); + return c.json({ version: publicModelVersion(version, true) }, 201); +} + +// --------------------------------------------------------------------------- +// Render jobs +// --------------------------------------------------------------------------- + +/** + * Share of price_tokens credited to the model's owner on a successful + * run. Skipped when the renderer IS the owner (see the `earn` guard + * below) — otherwise running your own model would mint tokens out of + * a debit/credit pair on the same account, net-zero in balance but + * inflating lifetime_earned for nothing real. + */ +const OWNER_EARN_BPS = 7000; // 70% + +function ownerEarn(priceTokens: number): number { + return Math.floor((priceTokens * OWNER_EARN_BPS) / 10_000); +} + +interface RenderBody { + version_id?: unknown; + prompt?: unknown; + params?: unknown; + project_id?: unknown; + seed?: unknown; + idempotency_key?: unknown; +} + +function randomSeed(): string { + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * POST /v1/models/:slug/render + * + * Debit → run → settle. Tokens are debited BEFORE the provider call so + * a burst of concurrent requests can't spend more than the caller's + * balance; a failed run is refunded in full as a second ledger entry + * (see migrations/0018_token_service.sql header) rather than reversing + * the first. + */ +export async function renderHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const slug = c.req.param("slug") || ""; + + const rl = await checkRateLimit(c.env.RATE_LIMIT, { + key: `render:${session.uid}`, + limit: 30, + windowSeconds: 3600, + }); + if (!rl.ok) return c.json({ error: "rate_limited", reset_at: rl.resetAt }, 429); + + const model = await getModelBySlug(c.env.DB, slug); + if (!model) return c.json({ error: "not_found" }, 404); + if (model.status !== "active") return c.json({ error: "model_disabled" }, 409); + if (model.visibility === "private" && model.owner_id !== session.uid) { + return c.json({ error: "not_found" }, 404); + } + + let body: RenderBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt || prompt.length > PROMPT_MAX) { + return badRequest(c, "invalid_prompt", { max: PROMPT_MAX }); + } + + let params: Record | null = null; + if (body.params !== undefined && body.params !== null) { + if (typeof body.params !== "object" || Array.isArray(body.params)) { + return badRequest(c, "invalid_params"); + } + let serialized: string; + try { + serialized = JSON.stringify(body.params); + } catch { + return badRequest(c, "invalid_params"); + } + if (serialized.length > PARAMS_JSON_MAX) { + return badRequest(c, "params_too_large", { max: PARAMS_JSON_MAX }); + } + params = body.params as Record; + } + + let projectId: number | null = null; + if (body.project_id !== undefined && body.project_id !== null) { + projectId = typeof body.project_id === "number" ? body.project_id : NaN; + if (!Number.isFinite(projectId) || projectId < 1) return badRequest(c, "invalid_project_id"); + const project = await getProjectById(c.env.DB, projectId); + if (!project || project.owner_id !== session.uid) { + return badRequest(c, "invalid_project_id"); + } + } + + const seed = + typeof body.seed === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(body.seed) + ? body.seed + : randomSeed(); + + const idempotencyKey = + typeof body.idempotency_key === "string" && body.idempotency_key.length > 0 + ? `render:${session.uid}:${body.idempotency_key.slice(0, 128)}` + : `render:${session.uid}:${crypto.randomUUID()}`; + + const existingJob = await getJobByIdempotencyKey(c.env.DB, idempotencyKey); + if (existingJob) { + return c.json({ job: publicJob(existingJob), replayed: true }); + } + + const version = await getLatestModelVersion(c.env.DB, model.id); + if (!version) return c.json({ error: "no_published_version" }, 409); + + const debit = await applyLedgerEntry(c.env.DB, { + userId: session.uid, + delta: -version.price_tokens, + kind: "debit", + idempotencyKey: `${idempotencyKey}:debit`, + refKind: "render_job", + refId: null, + memo: `render: ${model.title}`, + }); + if (debit.status === "insufficient") { + return c.json( + { error: "insufficient_balance", balance: debit.balance, shortfall: debit.shortfall }, + 402, + ); + } + + const job = await insertJob(c.env.DB, { + userId: session.uid, + modelVersionId: version.id, + projectId, + seed, + paramsJson: params ? JSON.stringify(params) : null, + promptHash: await sha256Hex(prompt), + priceTokens: version.price_tokens, + ownerEarnTokens: ownerEarn(version.price_tokens), + idempotencyKey, + }); + + try { + const result = await runInference(c.env, { + kind: model.kind as ModelKind, + provider: model.provider as never, + providerModelId: version.provider_model_id, + systemPrompt: version.system_prompt, + prompt, + params, + seed, + }); + + let outputKey: string | null = null; + if (result.outputKind === "image" && result.bytes) { + if (!c.env.CAPTURES) { + throw new InferenceError("R2 CAPTURES bucket is not bound", "storage_unconfigured"); + } + outputKey = `renders/${session.uid}/${job.id}-${seed}.png`; + await c.env.CAPTURES.put(outputKey, result.bytes, { + httpMetadata: { contentType: result.contentType ?? "image/png" }, + customMetadata: { + user_id: String(session.uid), + job_id: String(job.id), + model_slug: model.slug, + }, + }); + } + + const finished = await finishJob(c.env.DB, job.id, { + status: "succeeded", + outputKind: result.outputKind, + outputText: result.text, + outputKey, + outputHash: result.outputHash, + }); + + // Owner earn-out on success only — a failed job (refunded below) + // never pays the model owner for compute that produced nothing. + if (job.owner_earn_tokens > 0 && model.owner_id !== session.uid) { + try { + await applyLedgerEntry(c.env.DB, { + userId: model.owner_id, + delta: job.owner_earn_tokens, + kind: "earn", + idempotencyKey: `${idempotencyKey}:earn`, + refKind: "render_job", + refId: job.id, + memo: `render earn: ${model.title}`, + }); + } catch (e) { + // Best-effort: the renderer's job already succeeded and their + // tokens were correctly spent. A missed owner credit is + // recoverable from the ledger's ref_id; it must not roll back + // a job the caller already sees as done. + console.error("owner_earn_failed", e); + } + } + + return c.json({ job: publicJob(finished ?? job) }, 201); + } catch (e) { + const code = e instanceof InferenceError ? e.code : "render_failed"; + const failed = await finishJob(c.env.DB, job.id, { + status: "failed", + errorCode: code, + }); + try { + await applyLedgerEntry(c.env.DB, { + userId: session.uid, + delta: version.price_tokens, + kind: "refund", + idempotencyKey: `${idempotencyKey}:refund`, + refKind: "render_job", + refId: job.id, + memo: `refund: ${model.title} (${code})`, + }); + } catch (refundErr) { + console.error("refund_failed", refundErr); + } + console.error("render_failed", e); + return c.json({ error: code, job: publicJob(failed ?? job) }, code === "provider_unconfigured" ? 503 : 502); + } +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +const JOB_LIST_DEFAULT = 20; +const JOB_LIST_MAX = 50; + +/** GET /v1/jobs?limit=&before= — the caller's own render jobs. */ +export async function myJobsHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const url = new URL(c.req.url); + const limitRaw = url.searchParams.get("limit"); + let limit = JOB_LIST_DEFAULT; + if (limitRaw) { + const n = parseInt(limitRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > JOB_LIST_MAX) { + return badRequest(c, "invalid_limit", { max: JOB_LIST_MAX }); + } + limit = n; + } + const beforeRaw = url.searchParams.get("before"); + let before: number | undefined; + if (beforeRaw) { + const n = parseInt(beforeRaw, 10); + if (!Number.isFinite(n) || n < 0) return badRequest(c, "invalid_before"); + before = n; + } + const rows = await listJobsByUser(c.env.DB, session.uid, limit, before); + return c.json({ + jobs: rows.map(publicJob), + next_before: rows.length === limit ? rows[rows.length - 1].id : null, + }); +} + +/** GET /v1/jobs/:id — owner-only job detail. */ +export async function getJobHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const id = parseInt(c.req.param("id") || "", 10); + if (!id || Number.isNaN(id)) return badRequest(c, "invalid_id"); + const job = await getJobById(c.env.DB, id); + if (!job) return c.json({ error: "not_found" }, 404); + if (job.user_id !== session.uid) return c.json({ error: "forbidden" }, 403); + return c.json({ job: publicJob(job) }); +} diff --git a/workers/api/src/tokens/handlers.ts b/workers/api/src/tokens/handlers.ts new file mode 100644 index 0000000..383e0e7 --- /dev/null +++ b/workers/api/src/tokens/handlers.ts @@ -0,0 +1,219 @@ +import type { Context } from "hono"; +import { createPublicClient, getAddress, http, type Hex } from "viem"; +import type { Env } from "../types"; +import type { AuthVariables } from "../auth/middleware"; +import { getAuthUser } from "../auth/middleware"; +import { checkRateLimit } from "../lib/rateLimit"; +import { + getAccount, + listLedger, + publicLedgerEntry, + listActivePacks, + getPackById, + publicPack, + claimPurchase, + applyLedgerEntry, +} from "../db/tokens"; + +/** + * The token-purchase counterpart to `chainConfig` in projects/mint.ts. + * Deliberately not shared with it: mint reads GA_FACTORY_ADDRESS, this + * reads TOKEN_TREASURY_ADDRESS — different unconfigured states should + * fail independently rather than one env var silently gating the other. + */ +function treasuryConfig(env: Env) { + const treasury = env.TOKEN_TREASURY_ADDRESS; + const chainId = env.GA_CHAIN_ID ? parseInt(env.GA_CHAIN_ID, 10) : NaN; + const rpcUrl = env.GA_RPC_URL ?? ""; + return { treasury, chainId, rpcUrl }; +} + +function badRequest(c: Context, error: string, detail?: unknown) { + return c.json({ error, detail }, 400); +} + +function isTxHash(s: unknown): s is Hex { + return typeof s === "string" && /^0x[a-fA-F0-9]{64}$/.test(s); +} + +/** GET /v1/tokens/account — balance + lifetime counters for the caller. */ +export async function tokenAccountHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const account = await getAccount(c.env.DB, session.uid); + return c.json({ + balance: account.balance, + lifetime_purchased: account.lifetime_purchased, + lifetime_spent: account.lifetime_spent, + lifetime_earned: account.lifetime_earned, + }); +} + +const LEDGER_LIST_DEFAULT = 30; +const LEDGER_LIST_MAX = 100; + +/** GET /v1/tokens/ledger?limit=&before= — the caller's own history. */ +export async function tokenLedgerHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + const url = new URL(c.req.url); + const limitRaw = url.searchParams.get("limit"); + let limit = LEDGER_LIST_DEFAULT; + if (limitRaw) { + const n = parseInt(limitRaw, 10); + if (!Number.isFinite(n) || n < 1 || n > LEDGER_LIST_MAX) { + return badRequest(c, "invalid_limit", { max: LEDGER_LIST_MAX }); + } + limit = n; + } + const beforeRaw = url.searchParams.get("before"); + let before: number | undefined; + if (beforeRaw) { + const n = parseInt(beforeRaw, 10); + if (!Number.isFinite(n) || n < 0) return badRequest(c, "invalid_before"); + before = n; + } + const rows = await listLedger(c.env.DB, session.uid, limit, before); + return c.json({ + entries: rows.map(publicLedgerEntry), + next_before: rows.length === limit ? rows[rows.length - 1].id : null, + }); +} + +/** GET /v1/tokens/packs — public catalogue of purchasable token packs. */ +export async function listPacksHandler(c: Context<{ Bindings: Env }>) { + const packs = await listActivePacks(c.env.DB); + const cfg = treasuryConfig(c.env); + return c.json({ + packs: packs.map(publicPack), + purchase_configured: !!cfg.treasury && !Number.isNaN(cfg.chainId), + chain_id: Number.isNaN(cfg.chainId) ? null : cfg.chainId, + treasury_address: cfg.treasury ?? null, + }); +} + +interface ConfirmBody { + pack_id?: unknown; + tx_hash?: unknown; +} + +/** + * POST /v1/tokens/purchase/confirm + * + * No payment processor: the client sends ETH directly to + * TOKEN_TREASURY_ADDRESS on the configured chain, then posts the tx + * hash here. The Worker fetches the transaction + receipt from the + * public RPC and verifies: + * - the tx succeeded + * - it was sent TO the treasury address + * - its value covers the pack's price_wei + * before crediting the account. Trusting only the tx hash (never a + * client-supplied amount) means a malicious client can't credit + * themselves tokens they didn't pay for. Mirrors the verification + * shape of projects/mint.ts confirmDeploy/confirmMint. + */ +export async function confirmPurchaseHandler( + c: Context<{ Bindings: Env; Variables: AuthVariables }>, +) { + const session = getAuthUser(c); + + const rl = await checkRateLimit(c.env.RATE_LIMIT, { + key: `tokens:purchase:${session.uid}`, + limit: 20, + windowSeconds: 3600, + }); + if (!rl.ok) return c.json({ error: "rate_limited", reset_at: rl.resetAt }, 429); + + const cfg = treasuryConfig(c.env); + if (!cfg.treasury || Number.isNaN(cfg.chainId)) { + return c.json({ error: "purchase_unconfigured" }, 503); + } + + let body: ConfirmBody; + try { + body = await c.req.json(); + } catch { + return badRequest(c, "invalid_json"); + } + + const packId = typeof body.pack_id === "number" ? body.pack_id : NaN; + if (!Number.isFinite(packId) || packId < 1) return badRequest(c, "invalid_pack_id"); + if (!isTxHash(body.tx_hash)) return badRequest(c, "invalid_tx_hash"); + + const pack = await getPackById(c.env.DB, packId); + if (!pack) return c.json({ error: "pack_not_found" }, 404); + + const expectedTreasury = getAddress(cfg.treasury as Hex); + const client = createPublicClient({ transport: http(cfg.rpcUrl) }); + + let fromAddress: Hex; + let valueWei: bigint; + try { + const [tx, receipt] = await Promise.all([ + client.getTransaction({ hash: body.tx_hash }), + client.getTransactionReceipt({ hash: body.tx_hash }), + ]); + if (receipt.status !== "success") return badRequest(c, "tx_reverted", 409); + if (!tx.to || getAddress(tx.to) !== expectedTreasury) { + return badRequest(c, "wrong_recipient", 409); + } + if (tx.value < BigInt(pack.price_wei)) { + return badRequest(c, "underpaid", { + required_wei: pack.price_wei, + sent_wei: tx.value.toString(), + }); + } + fromAddress = tx.from; + valueWei = tx.value; + } catch (e) { + console.error("purchase confirm verify failed", e); + return c.json({ error: "rpc_unavailable" }, 503); + } + + const purchase = await claimPurchase(c.env.DB, { + userId: session.uid, + packId: pack.id, + chainId: cfg.chainId, + txHash: body.tx_hash, + fromAddress, + valueWei: valueWei.toString(), + tokens: pack.tokens, + }); + if (!purchase) { + // UNIQUE(chain_id, tx_hash) already claimed — this tx was already + // redeemed (by this user or, if stolen, by whoever posted it first). + return c.json({ error: "already_claimed" }, 409); + } + + const ledger = await applyLedgerEntry(c.env.DB, { + userId: session.uid, + delta: pack.tokens, + kind: "purchase", + idempotencyKey: `purchase:${cfg.chainId}:${body.tx_hash.toLowerCase()}`, + refKind: "purchase", + refId: purchase.id, + memo: `${pack.title} pack`, + }); + // A purchase credit can never be "insufficient" — it's additive — so + // the only real outcomes are applied/replayed. Both carry the entry. + if (ledger.status === "insufficient") { + return c.json({ error: "ledger_error" }, 500); + } + + const account = await getAccount(c.env.DB, session.uid); + return c.json( + { + purchase: { + id: purchase.id, + pack: publicPack(pack), + tx_hash: purchase.tx_hash, + tokens: purchase.tokens, + }, + ledger_entry: publicLedgerEntry(ledger.entry), + balance: account.balance, + }, + 201, + ); +} diff --git a/workers/api/src/types.ts b/workers/api/src/types.ts index 3d5a9ae..916dd96 100644 --- a/workers/api/src/types.ts +++ b/workers/api/src/types.ts @@ -63,6 +63,32 @@ export interface Env { ADMIN_HANDLES?: string; // csv allowlist for /v1/internal/* UPTIME_PUBLIC_BASE?: string; UPTIME_PROJECT_PROBE_URL?: string; + // --- Render-token service --------------------------------------------- + // Claude API key for the `anthropic` render-model provider (code + // generation). Set via `wrangler secret put ANTHROPIC_API_KEY + // --env production`. When unset, jobs against an `anthropic`-provider + // model fail with 503 `provider_unconfigured` — no silent fallback. + ANTHROPIC_API_KEY?: string; + // Cloudflare Workers AI binding. Powers the `workers_ai` render-model + // provider (image/texture generation). Hand-typed rather than pulled + // from @cloudflare/workers-types because the pinned SDK version + // predates the Ai binding type; the shape below is the subset this + // Worker actually calls. + AI?: { + run(model: string, input: Record): Promise; + }; + // Address that receives ETH for token-pack purchases. Non-secret (an + // on-chain address is public by nature) so it lives in [vars]. Until + // set, POST /v1/tokens/purchase/confirm returns 503 + // `purchase_unconfigured`. Reuses GA_CHAIN_ID / GA_RPC_URL — the same + // chain the mint flow already reads receipts from. + TOKEN_TREASURY_ADDRESS?: string; + // OPT-IN ONLY, mirrors GITHUB_MOCK / PINNING_MOCK. When exactly "1", + // runInference() returns deterministic canned output with no network + // call — makes the render pipeline exercisable in `wrangler dev` and + // in scripts/smoke_api.mjs without an API key or Workers AI binding. + // Production must NEVER set RENDER_MOCK. + RENDER_MOCK?: string; } export interface ProjectMintState { diff --git a/workers/api/test/units.test.mjs b/workers/api/test/units.test.mjs index f66ff4e..ffd064f 100644 --- a/workers/api/test/units.test.mjs +++ b/workers/api/test/units.test.mjs @@ -31,6 +31,8 @@ export { sanitizeRoute, minuteBucket } from ${JSON.stringify(join(srcDir, "lib", export { buildFtsQuery } from ${JSON.stringify(join(srcDir, "db", "search"))}; export { parseAllowlist } from ${JSON.stringify(join(srcDir, "auth", "admin"))}; export { parseFeedCursor, encodeFeedCursor } from ${JSON.stringify(join(srcDir, "db", "events"))}; +export { lifetimeDeltas } from ${JSON.stringify(join(srcDir, "db", "tokens"))}; +export { providerAllowedForKind } from ${JSON.stringify(join(srcDir, "db", "render"))}; `, ); @@ -254,3 +256,79 @@ test("feed cursor rejects malformed input rather than poisoning SQL", () => { assert.equal(m.parseFeedCursor(bad), null, `expected null for ${JSON.stringify(bad)}`); } }); + +// --------------------------------------------------------------------------- +// Render-token ledger — lifetime counters. +// +// Regression this pins: a refund must NOT reduce lifetime_spent (a +// refunded render would otherwise read as having spent a negative +// amount), and only a genuine 'debit' should ever advance it. +// --------------------------------------------------------------------------- +test("lifetimeDeltas: purchase and grant both count as purchased", () => { + assert.deepEqual(m.lifetimeDeltas("purchase", 500), { + purchased: 500, + spent: 0, + earned: 0, + }); + assert.deepEqual(m.lifetimeDeltas("grant", 200), { + purchased: 200, + spent: 0, + earned: 0, + }); +}); + +test("lifetimeDeltas: debit counts as spent using the positive magnitude", () => { + assert.deepEqual(m.lifetimeDeltas("debit", -30), { + purchased: 0, + spent: 30, + earned: 0, + }); +}); + +test("lifetimeDeltas: refund advances nothing — it is not a negative spend", () => { + assert.deepEqual(m.lifetimeDeltas("refund", 30), { + purchased: 0, + spent: 0, + earned: 0, + }); +}); + +test("lifetimeDeltas: earn counts as earned only", () => { + assert.deepEqual(m.lifetimeDeltas("earn", 21), { + purchased: 0, + spent: 0, + earned: 21, + }); +}); + +test("lifetimeDeltas: adjust never touches a lifetime counter", () => { + assert.deepEqual(m.lifetimeDeltas("adjust", 999), { + purchased: 0, + spent: 0, + earned: 0, + }); + assert.deepEqual(m.lifetimeDeltas("adjust", -999), { + purchased: 0, + spent: 0, + earned: 0, + }); +}); + +// --------------------------------------------------------------------------- +// Render-model provider/kind gating. +// +// Regression this prevents: registering an image model against the +// code-only `anthropic` provider (or vice versa) would only fail once +// someone had already been charged for a job against it. +// --------------------------------------------------------------------------- +test("providerAllowedForKind: code models accept anthropic and mock only", () => { + assert.equal(m.providerAllowedForKind("code", "anthropic"), true); + assert.equal(m.providerAllowedForKind("code", "mock"), true); + assert.equal(m.providerAllowedForKind("code", "workers_ai"), false); +}); + +test("providerAllowedForKind: image models accept workers_ai and mock only", () => { + assert.equal(m.providerAllowedForKind("image", "workers_ai"), true); + assert.equal(m.providerAllowedForKind("image", "mock"), true); + assert.equal(m.providerAllowedForKind("image", "anthropic"), false); +}); diff --git a/workers/api/wrangler.toml b/workers/api/wrangler.toml index e151011..3bd3ed8 100644 --- a/workers/api/wrangler.toml +++ b/workers/api/wrangler.toml @@ -55,6 +55,17 @@ bucket_name = "generatedart-assets-dev" [images] binding = "IMAGES" +# Render-token service: image-model provider (Task: render-token +# service). Deliberately NOT bound here — Workers AI has no local +# simulator, so wrangler dev would silently switch to remote mode and +# require a CLOUDFLARE_API_TOKEN just to boot, breaking local dev for +# everyone who hasn't set one up. The `workers_ai` provider path is +# gated on `env.AI` being present (see src/ai/inference.ts) and returns +# 503 provider_unconfigured when it's absent, same as every other +# optional binding in this file. Set RENDER_MOCK=1 in .dev.vars to +# exercise the render pipeline locally instead; the binding is real in +# [env.production.ai] below. + # ============================================================================ # Production environment (api.generatedart.com) # ============================================================================ @@ -105,6 +116,9 @@ bucket_name = "generatedart-assets" [env.production.images] binding = "IMAGES" +[env.production.ai] +binding = "AI" + # --- Async render + IPFS pin jobs — uncomment with Task #4 / Task #5 -------- # [[env.production.queues.producers]] # binding = "RENDER_QUEUE" @@ -144,6 +158,18 @@ GA_CHAIN_ID = "84532" GA_RPC_URL = "https://sepolia.base.org" # GA_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000000" +# --- Render-token service ---------------------------------------------- +# Address that receives ETH for token-pack purchases (reuses GA_CHAIN_ID +# / GA_RPC_URL above — same chain the mint flow reads receipts from). +# Non-secret — an on-chain address is public. Until set, POST +# /v1/tokens/purchase/confirm returns 503 purchase_unconfigured. +# ANTHROPIC_API_KEY is a secret, set via: +# wrangler secret put ANTHROPIC_API_KEY --env production +# Until set, render jobs against an `anthropic`-provider model return +# 503 provider_unconfigured. RENDER_MOCK must NEVER be set in +# production (see src/ai/inference.ts). +# TOKEN_TREASURY_ADDRESS = "0x0000000000000000000000000000000000000000" + # --- Task #20: Observability & error monitoring ----------------------------- # All four are optional — when unset the relevant surface no-ops: # * SENTRY_DSN → Sentry envelope POST is skipped From cb88a5ea8fe3c1562a6b47908202a194b14a8792 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 08:01:32 +0000 Subject: [PATCH 2/2] Add fal_custom provider: creator-trained AI art models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeneratedArt's render-model registry previously covered two lanes: Claude for code generation and Workers AI's fixed catalogue for reference images. This adds the lane the platform is actually positioned on — coders publishing their own trained models (a fine-tune or LoRA on a diffusion base), the way Refik Anadol builds a bespoke model on a curated dataset rather than prompting Midjourney or DALL-E. Workers AI can't host this: its LoRA fine-tuning (open beta) only accepts text model_types (mistral/gemma/llama), no image/diffusion LoRA support. fal.ai's private-model inference fills the gap without breaking the budget stance — pay-per-run, no idle GPU cost, unlike self-hosting or Replicate's cold-start-billed custom deploys. - migrations/0019_custom_model_provider.sql: adds training_method, base_model, dataset_note, weights_ref to render_model_versions (append-only, matches the 0002_projects_extras.sql convention). Lives on the version row, not the model shell, since a creator retraining on a new dataset publishes a new version. - src/db/render.ts: `fal_custom` added to MODEL_PROVIDERS, allowed for kind=image. Training lineage fields are public on publicModelVersion (the provenance disclosure); weights_ref stays owner-only like system_prompt — it's what would let someone else clone the model outright. - src/ai/inference.ts: runFalCustom() — raw HTTP against fal.run (Authorization: Key header, images[0].url response), not an SDK call, since this is a third-party non-Anthropic provider. Fails closed on missing FAL_KEY or a version with no weights_ref. - src/render/handlers.ts: publishVersionHandler validates the new fields and requires weights_ref when provider=fal_custom — there's no catalogue fallback the way provider_model_id alone works for the other two providers, so a version without it would publish and then fail every render. - types.ts / .dev.vars.example: FAL_KEY as a secret, same fail-closed/mock convention as ANTHROPIC_API_KEY and RENDER_MOCK. - 4 new unit tests (provider/kind gating, isTrainingMethod) — 28/28 pass. 4 new smoke checks (registration, weights_ref requirement, provenance-public/weights-private on the anon read path) — 112/112 pass. Typecheck clean; wrangler d1 migrations apply --local clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QuSa99HABSrWRarT9BQ5Tz --- workers/api/.dev.vars.example | 11 ++- .../migrations/0019_custom_model_provider.sql | 29 +++++++ workers/api/scripts/smoke_api.mjs | 60 ++++++++++++- workers/api/src/ai/inference.ts | 85 +++++++++++++++++++ workers/api/src/db/render.ts | 58 ++++++++++++- workers/api/src/render/handlers.ts | 55 ++++++++++++ workers/api/src/types.ts | 7 ++ workers/api/test/units.test.mjs | 21 ++++- 8 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 workers/api/migrations/0019_custom_model_provider.sql diff --git a/workers/api/.dev.vars.example b/workers/api/.dev.vars.example index 02c9a61..dc59996 100644 --- a/workers/api/.dev.vars.example +++ b/workers/api/.dev.vars.example @@ -70,10 +70,17 @@ TOKEN_TREASURY_ADDRESS="" # wrangler secret put ANTHROPIC_API_KEY --env production ANTHROPIC_API_KEY="" +# fal.ai API key for the `fal_custom` render-model provider — creator- +# trained custom art models (fine-tune / LoRA on a diffusion base). +# Workers AI can't host these: its LoRA fine-tuning only covers text +# model_types. Production: +# wrangler secret put FAL_KEY --env production +FAL_KEY="" + # OPT-IN ONLY. When exactly "1", runInference() returns deterministic # canned output with no network call, so the render pipeline is -# exercisable without an Anthropic key or a Workers AI binding. -# Production must NEVER set RENDER_MOCK. +# exercisable without an Anthropic key, a Workers AI binding, or a +# fal.ai key. Production must NEVER set RENDER_MOCK. RENDER_MOCK="1" # --- Task #20: observability (all optional; unset = the surface no-ops) ---- diff --git a/workers/api/migrations/0019_custom_model_provider.sql b/workers/api/migrations/0019_custom_model_provider.sql new file mode 100644 index 0000000..6f303c9 --- /dev/null +++ b/workers/api/migrations/0019_custom_model_provider.sql @@ -0,0 +1,29 @@ +-- Adds the `fal_custom` provider lane: creator-trained custom art models +-- (a fine-tuned checkpoint or LoRA on a diffusion base, in the vein of +-- Refik Anadol's bespoke, dataset-trained visual signatures) as distinct +-- from prompting a fixed catalogue model. +-- +-- Why not Workers AI for this: its LoRA fine-tuning (open beta) only +-- accepts text model_types (mistral/gemma/llama) — no image/diffusion +-- LoRA support as of this writing. A creative coder's own trained +-- checkpoint has to run somewhere that hosts custom weights on demand; +-- fal.ai's private-model inference fits the platform's no-idle-GPU +-- budget stance (pay-per-run, no fixed hosting cost) better than +-- self-hosting a GPU or Replicate's cold-start-billed custom deploys. +-- +-- These columns describe training lineage, not runtime params, so they +-- live on the immutable per-version row alongside provider_model_id — +-- a creator retraining on a new dataset publishes a new version rather +-- than rewriting one that jobs already reference. + +ALTER TABLE render_model_versions ADD COLUMN training_method TEXT; +ALTER TABLE render_model_versions ADD COLUMN base_model TEXT; +-- Creator's own description of what the model was trained on. This is +-- the platform's provenance/disclosure surface for a custom model — +-- public alongside the version, unlike weights_ref and system_prompt. +ALTER TABLE render_model_versions ADD COLUMN dataset_note TEXT; +-- Pointer to the trained weights at the provider (e.g. a fal.ai model +-- id or LoRA URL). Required when provider = 'fal_custom'. Treated with +-- the same owner-only visibility as system_prompt — it's how another +-- creator could clone the model's exact output. +ALTER TABLE render_model_versions ADD COLUMN weights_ref TEXT; diff --git a/workers/api/scripts/smoke_api.mjs b/workers/api/scripts/smoke_api.mjs index de8ea14..08feedb 100644 --- a/workers/api/scripts/smoke_api.mjs +++ b/workers/api/scripts/smoke_api.mjs @@ -32,7 +32,11 @@ function record(name, ok, detail) { async function api(method, path, body, opts = {}) { const headers = { Origin: ORIGIN }; - if (cookie) headers.Cookie = cookie; + // opts.anon: skip the session cookie even though a global `cookie` is + // set — lets a still-logged-in suite probe how a public endpoint + // renders for an unauthenticated viewer without a full logout/login + // round-trip. + if (cookie && !opts.anon) headers.Cookie = cookie; if (body !== undefined && !(body instanceof FormData) && !(body instanceof ArrayBuffer) && !ArrayBuffer.isView(body)) { headers["Content-Type"] = "application/json"; } @@ -328,6 +332,60 @@ let modelSlug = null; JSON.stringify(r.data).slice(0, 200), ); } + +// fal_custom — the creator-trained-model lane (see +// migrations/0019_custom_model_provider.sql). Registration and publish +// validation are exercised for real here even though the actual render +// still runs mocked (no live FAL_KEY in CI). +let falModelSlug = null; +{ + const r = await api("POST", "/v1/models", { + title: `Smoke Custom Model ${RUN}`, + kind: "image", + provider: "fal_custom", + visibility: "public", + }); + falModelSlug = r.data?.model?.slug ?? null; + record("POST /v1/models (fal_custom)", r.status === 201 && !!falModelSlug, JSON.stringify(r.data).slice(0, 200)); +} +{ + const r = await api("POST", `/v1/models/${falModelSlug}/versions`, { + provider_model_id: "fal-ai/flux-lora", + price_tokens: 25, + }); + record( + "POST /v1/models/:slug/versions (fal_custom) rejects missing weights_ref", + r.status === 400 && r.data?.error === "weights_ref_required_for_fal_custom", + JSON.stringify(r.data), + ); +} +{ + const r = await api("POST", `/v1/models/${falModelSlug}/versions`, { + provider_model_id: "fal-ai/flux-lora", + price_tokens: 25, + training_method: "lora", + base_model: "FLUX.1 [dev]", + dataset_note: "2,400 hand-collected botanical scans, own photography", + weights_ref: "smoke-test/botanical-lora-v1", + }); + record( + "POST /v1/models/:slug/versions (fal_custom) accepts training lineage + weights_ref", + r.status === 201 && r.data?.version?.training_method === "lora" && r.data?.version?.weights_ref === "smoke-test/botanical-lora-v1", + JSON.stringify(r.data).slice(0, 250), + ); +} +{ + // Public listing surfaces the provenance disclosure (training_method, + // base_model, dataset_note) but never the owner-only weights_ref. + const r = await api("GET", `/v1/models/${falModelSlug}`, undefined, { anon: true }); + const v0 = r.data?.versions?.[0]; + record( + "GET /v1/models/:slug (anon) shows provenance, hides weights_ref", + r.status === 200 && v0?.dataset_note?.includes("botanical") && v0?.weights_ref === undefined, + JSON.stringify(r.data).slice(0, 250), + ); +} + let renderJobId = null; { const r = await api("POST", `/v1/models/${modelSlug}/render`, { diff --git a/workers/api/src/ai/inference.ts b/workers/api/src/ai/inference.ts index 0bfd631..1fd29e4 100644 --- a/workers/api/src/ai/inference.ts +++ b/workers/api/src/ai/inference.ts @@ -22,6 +22,12 @@ export interface InferenceInput { params: Record | null; /** Deterministic seed threaded into the provider call where supported. */ seed: string; + /** + * fal_custom only: the trained model/LoRA identifier at the provider + * (render_model_versions.weights_ref). Absent for every other + * provider. + */ + weightsRef?: string | null; } export interface InferenceOutput { @@ -203,6 +209,84 @@ async function runWorkersAi( }; } +/** + * fal.ai — creator-trained custom art models (fine-tune / LoRA on a + * diffusion base). This is the one lane Workers AI can't serve: its + * LoRA fine-tuning only accepts text model_types (mistral/gemma/llama), + * so a coder's own trained checkpoint runs through fal.ai's private- + * model inference instead — pay-per-run, no idle GPU cost, matching the + * budget stance behind the rest of this stack. + * + * Raw HTTP, not an SDK: fal.ai has no first-party client bundled here + * and the request shape is stable and small enough not to warrant one. + * `weightsRef` is `render_model_versions.weights_ref` — the model or + * LoRA identifier the creator registered when they published the + * version — and is REQUIRED; there is no catalogue fallback for this + * provider the way `provider_model_id` alone is enough for the others. + */ +async function runFalCustom( + env: Env, + input: InferenceInput, +): Promise { + if (!env.FAL_KEY) { + throw new InferenceError("FAL_KEY is not configured", "provider_unconfigured"); + } + if (!input.weightsRef) { + throw new InferenceError( + "fal_custom model version has no weights_ref", + "model_misconfigured", + ); + } + let res: Response; + try { + res = await fetch(`https://fal.run/${input.providerModelId}`, { + method: "POST", + headers: { + Authorization: `Key ${env.FAL_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: input.prompt, + loras: [{ path: input.weightsRef }], + seed: /^\d+$/.test(input.seed) ? parseInt(input.seed, 10) : undefined, + ...(input.params ?? {}), + }), + }); + } catch (e) { + throw new InferenceError( + e instanceof Error ? e.message : "fal request failed", + "provider_error", + ); + } + if (!res.ok) { + throw new InferenceError(`fal request failed: ${res.status}`, "provider_error"); + } + const data = (await res.json()) as { images?: { url?: string }[] }; + const imageUrl = data.images?.[0]?.url; + if (!imageUrl) { + throw new InferenceError("fal response had no image url", "provider_error"); + } + let bytes: Uint8Array; + try { + const imgRes = await fetch(imageUrl); + if (!imgRes.ok) throw new Error(`fetch image failed: ${imgRes.status}`); + bytes = new Uint8Array(await imgRes.arrayBuffer()); + } catch (e) { + throw new InferenceError( + e instanceof Error ? e.message : "fal image fetch failed", + "provider_error", + ); + } + const outputHash = await sha256Hex(bytes); + return { + outputKind: "image", + text: null, + bytes, + contentType: "image/png", + outputHash, + }; +} + export async function runInference( env: Env, input: InferenceInput, @@ -210,5 +294,6 @@ export async function runInference( if (isRenderMock(env)) return runMock(input); if (input.provider === "anthropic") return runAnthropic(env, input); if (input.provider === "workers_ai") return runWorkersAi(env, input); + if (input.provider === "fal_custom") return runFalCustom(env, input); throw new InferenceError(`unsupported provider: ${input.provider}`, "provider_unconfigured"); } diff --git a/workers/api/src/db/render.ts b/workers/api/src/db/render.ts index adff8d6..6062d87 100644 --- a/workers/api/src/db/render.ts +++ b/workers/api/src/db/render.ts @@ -13,9 +13,34 @@ import { slugify } from "../lib/slug"; export const MODEL_KINDS = ["code", "image"] as const; export type ModelKind = (typeof MODEL_KINDS)[number]; -export const MODEL_PROVIDERS = ["anthropic", "workers_ai", "mock"] as const; +// `fal_custom` — a creator-trained custom art model (fine-tuned +// checkpoint or LoRA on a diffusion base), run through fal.ai's private- +// model inference. This is the platform's Refik-Anadol-style lane: a +// bespoke model trained on a curated dataset, distinct from prompting a +// fixed catalogue model. See migrations/0019_custom_model_provider.sql +// for why Workers AI can't host it (its LoRA fine-tuning is text-only). +export const MODEL_PROVIDERS = [ + "anthropic", + "workers_ai", + "fal_custom", + "mock", +] as const; export type ModelProvider = (typeof MODEL_PROVIDERS)[number]; +export const TRAINING_METHODS = [ + "prompt_recipe", + "lora", + "dreambooth", + "full_finetune", +] as const; +export type TrainingMethod = (typeof TRAINING_METHODS)[number]; + +export function isTrainingMethod(v: unknown): v is TrainingMethod { + return ( + typeof v === "string" && (TRAINING_METHODS as readonly string[]).includes(v) + ); +} + export const MODEL_VISIBILITIES = ["public", "unlisted", "private"] as const; export type ModelVisibility = (typeof MODEL_VISIBILITIES)[number]; @@ -52,7 +77,7 @@ export function isModelVisibility(v: unknown): v is ModelVisibility { */ export const PROVIDERS_BY_KIND: Record = { code: ["anthropic", "mock"], - image: ["workers_ai", "mock"], + image: ["workers_ai", "fal_custom", "mock"], }; export function providerAllowedForKind( @@ -85,6 +110,12 @@ export interface ModelVersionRow { params_schema_json: string | null; price_tokens: number; created_at: number; + // Training lineage — see migrations/0019_custom_model_provider.sql. + // Null for anthropic/workers_ai versions; populated for fal_custom. + training_method: string | null; + base_model: string | null; + dataset_note: string | null; + weights_ref: string | null; } export interface ModelListItem extends ModelRow { @@ -296,6 +327,10 @@ export async function insertModelVersion( systemPrompt: string | null; paramsSchemaJson: string | null; priceTokens: number; + trainingMethod?: string | null; + baseModel?: string | null; + datasetNote?: string | null; + weightsRef?: string | null; }, ): Promise { const now = Math.floor(Date.now() / 1000); @@ -303,11 +338,12 @@ export async function insertModelVersion( .prepare( `INSERT INTO render_model_versions (model_id, version, provider_model_id, system_prompt, - params_schema_json, price_tokens, created_at) + params_schema_json, price_tokens, training_method, base_model, + dataset_note, weights_ref, created_at) SELECT ?, COALESCE((SELECT MAX(version) FROM render_model_versions WHERE model_id = ?), 0) + 1, - ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ? RETURNING *`, ) .bind( @@ -317,6 +353,10 @@ export async function insertModelVersion( input.systemPrompt, input.paramsSchemaJson, input.priceTokens, + input.trainingMethod ?? null, + input.baseModel ?? null, + input.datasetNote ?? null, + input.weightsRef ?? null, now, ) .first(); @@ -405,6 +445,16 @@ export function publicModelVersion(v: ModelVersionRow, includePrompt = false) { ? safeParse(v.params_schema_json) : null, price_tokens: v.price_tokens, + // Training lineage is the provenance disclosure for a custom model + // (the Anadol-style "what this was trained on") — shown to every + // viewer, same as a project's frozen CID. `weights_ref` is the + // pointer to the actual trained weights at the provider; gated + // owner-only like system_prompt, since it's what another creator + // would need to clone the model outright. + training_method: v.training_method, + base_model: v.base_model, + dataset_note: v.dataset_note, + weights_ref: includePrompt ? v.weights_ref : undefined, created_at: v.created_at, }; } diff --git a/workers/api/src/render/handlers.ts b/workers/api/src/render/handlers.ts index 5303c67..9db4ab8 100644 --- a/workers/api/src/render/handlers.ts +++ b/workers/api/src/render/handlers.ts @@ -11,6 +11,7 @@ import { isModelKind, isModelProvider, isModelVisibility, + isTrainingMethod, providerAllowedForKind, uniqueModelSlug, insertModel, @@ -248,8 +249,19 @@ interface PublishVersionBody { system_prompt?: unknown; params_schema?: unknown; price_tokens?: unknown; + // Training lineage — see migrations/0019_custom_model_provider.sql. + // Only meaningful (and, for weights_ref, required) when the model's + // provider is `fal_custom`. + training_method?: unknown; + base_model?: unknown; + dataset_note?: unknown; + weights_ref?: unknown; } +const BASE_MODEL_MAX = 100; +const DATASET_NOTE_MAX = 2000; +const WEIGHTS_REF_MAX = 300; + /** POST /v1/models/:slug/versions — owner-only, append-only publish. */ export async function publishVersionHandler( c: Context<{ Bindings: Env; Variables: AuthVariables }>, @@ -301,12 +313,54 @@ export async function publishVersionHandler( return badRequest(c, "invalid_price_tokens", { max: PRICE_MAX }); } + let trainingMethod: string | null = null; + if (body.training_method !== undefined && body.training_method !== null) { + if (!isTrainingMethod(body.training_method)) { + return badRequest(c, "invalid_training_method"); + } + trainingMethod = body.training_method; + } + + let baseModel: string | null = null; + if (body.base_model !== undefined && body.base_model !== null) { + if (typeof body.base_model !== "string" || body.base_model.length > BASE_MODEL_MAX) { + return badRequest(c, "invalid_base_model", { max: BASE_MODEL_MAX }); + } + baseModel = body.base_model.trim() || null; + } + + let datasetNote: string | null = null; + if (body.dataset_note !== undefined && body.dataset_note !== null) { + if (typeof body.dataset_note !== "string" || body.dataset_note.length > DATASET_NOTE_MAX) { + return badRequest(c, "invalid_dataset_note", { max: DATASET_NOTE_MAX }); + } + datasetNote = body.dataset_note.trim() || null; + } + + let weightsRef: string | null = null; + if (body.weights_ref !== undefined && body.weights_ref !== null) { + if (typeof body.weights_ref !== "string" || body.weights_ref.length > WEIGHTS_REF_MAX) { + return badRequest(c, "invalid_weights_ref", { max: WEIGHTS_REF_MAX }); + } + weightsRef = body.weights_ref.trim() || null; + } + // fal_custom has no catalogue fallback the way `provider_model_id` + // alone works for anthropic/workers_ai — without a weights pointer + // the version would publish successfully and then fail every render. + if (model.provider === "fal_custom" && !weightsRef) { + return badRequest(c, "weights_ref_required_for_fal_custom"); + } + const version = await insertModelVersion(c.env.DB, { modelId: model.id, providerModelId, systemPrompt, paramsSchemaJson, priceTokens, + trainingMethod, + baseModel, + datasetNote, + weightsRef, }); return c.json({ version: publicModelVersion(version, true) }, 201); } @@ -466,6 +520,7 @@ export async function renderHandler( prompt, params, seed, + weightsRef: version.weights_ref, }); let outputKey: string | null = null; diff --git a/workers/api/src/types.ts b/workers/api/src/types.ts index 916dd96..508bfc2 100644 --- a/workers/api/src/types.ts +++ b/workers/api/src/types.ts @@ -77,6 +77,13 @@ export interface Env { AI?: { run(model: string, input: Record): Promise; }; + // fal.ai API key for the `fal_custom` render-model provider — + // creator-trained custom art models (fine-tune / LoRA on a diffusion + // base) that Workers AI can't host, since its LoRA fine-tuning only + // covers text model_types. Set via `wrangler secret put FAL_KEY + // --env production`. Unset: jobs against a `fal_custom` model fail + // with 503 `provider_unconfigured`. + FAL_KEY?: string; // Address that receives ETH for token-pack purchases. Non-secret (an // on-chain address is public by nature) so it lives in [vars]. Until // set, POST /v1/tokens/purchase/confirm returns 503 diff --git a/workers/api/test/units.test.mjs b/workers/api/test/units.test.mjs index ffd064f..1a534fa 100644 --- a/workers/api/test/units.test.mjs +++ b/workers/api/test/units.test.mjs @@ -32,7 +32,7 @@ export { buildFtsQuery } from ${JSON.stringify(join(srcDir, "db", "search"))}; export { parseAllowlist } from ${JSON.stringify(join(srcDir, "auth", "admin"))}; export { parseFeedCursor, encodeFeedCursor } from ${JSON.stringify(join(srcDir, "db", "events"))}; export { lifetimeDeltas } from ${JSON.stringify(join(srcDir, "db", "tokens"))}; -export { providerAllowedForKind } from ${JSON.stringify(join(srcDir, "db", "render"))}; +export { providerAllowedForKind, isTrainingMethod } from ${JSON.stringify(join(srcDir, "db", "render"))}; `, ); @@ -327,8 +327,25 @@ test("providerAllowedForKind: code models accept anthropic and mock only", () => assert.equal(m.providerAllowedForKind("code", "workers_ai"), false); }); -test("providerAllowedForKind: image models accept workers_ai and mock only", () => { +test("providerAllowedForKind: image models accept workers_ai, fal_custom, and mock", () => { assert.equal(m.providerAllowedForKind("image", "workers_ai"), true); + assert.equal(m.providerAllowedForKind("image", "fal_custom"), true); assert.equal(m.providerAllowedForKind("image", "mock"), true); assert.equal(m.providerAllowedForKind("image", "anthropic"), false); }); + +// fal_custom is the creator-trained-model lane (fine-tune/LoRA on a +// diffusion base) — see migrations/0019_custom_model_provider.sql for +// why it can't run through Workers AI. +test("providerAllowedForKind: code models still reject fal_custom", () => { + assert.equal(m.providerAllowedForKind("code", "fal_custom"), false); +}); + +test("isTrainingMethod: accepts the four documented methods only", () => { + assert.equal(m.isTrainingMethod("lora"), true); + assert.equal(m.isTrainingMethod("dreambooth"), true); + assert.equal(m.isTrainingMethod("full_finetune"), true); + assert.equal(m.isTrainingMethod("prompt_recipe"), true); + assert.equal(m.isTrainingMethod("magic"), false); + assert.equal(m.isTrainingMethod(undefined), false); +});