@dvmkit/dvmctl 0.1.0-rc.2 → 0.2.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ ## Tags
2
+
3
+ Tags are freeform strings describing what a DVM does, advertised in `/v1/info` and used by
4
+ clients to discover providers:
5
+
6
+ ```typescript
7
+ tag: "text"; // single tag
8
+ tags: ["web", "extraction"]; // multiple tags
9
+ ```
10
+
11
+ ---
12
+
13
+ ## Pricing
14
+
15
+ Static prices are USD literals (`PriceValue`) — the only static price form:
16
+
17
+ ```typescript
18
+ price: "$0.05"; // USD → converted at current BTC rate
19
+ ```
20
+
21
+ A raw-msats price throws at `configureDVM`. So does anything below `"$0.0001"` or finer than four
22
+ decimals — that's the precision every caller-facing surface advertises at, so a finer price would
23
+ advertise one number and charge another. Free capabilities omit `price`. Need finer granularity?
24
+ Price dynamically with `onQuote`; dynamic upfronts carry full micro-unit precision.
25
+
26
+ Dynamic pricing with `onQuote` — fiat-first. The handler returns an `upfront` envelope
27
+ (`{ amount, currency: "usd" }`) plus a `shape`:
28
+
29
+ ```typescript
30
+ onQuote: {
31
+ schema: z.object({ duration: z.number() }),
32
+ async handler(ctx) {
33
+ return {
34
+ upfront: { amount: ctx.data.duration * 0.001, currency: "usd" },
35
+ description: `${ctx.data.duration}s of synthesis`,
36
+ shape: "fixed",
37
+ };
38
+ },
39
+ },
40
+ ```
41
+
42
+ (`ctx.data` here is the quote-time parsed input, distinct from a job's `ctx.input`.)
43
+
44
+ Two-phase metered pricing — `shape: "rate"` returns an `upfront` floor plus a `rate` (per-unit)
45
+ so the SDK advertises a max-units cap; the handler calls `ctx.requestPayment(...)` again mid-job
46
+ once actual usage is known (this is scribe's pattern):
47
+
48
+ ```typescript
49
+ onQuote: {
50
+ schema: transcribeSchema,
51
+ async handler(ctx) {
52
+ return {
53
+ upfront: { amount: 0.02, currency: "usd" }, // spam-gate floor
54
+ description: "Up to 15 minutes; metered per-minute after",
55
+ shape: "rate",
56
+ rate: {
57
+ per_unit: { amount: 0.02, currency: "usd" },
58
+ unit: "minute",
59
+ max_units: 15,
60
+ },
61
+ };
62
+ },
63
+ },
64
+ ```
65
+
66
+ All first-party DVMs quote in `currency: "usd"`; the SDK converts to sats at the 402 handshake.
67
+
68
+ To price in something else, declare it once at the DVM level — `currency: "eur"` on `configureDVM`
69
+ (ISO 4217 lowercase; the bundled fx fetcher carries usd/eur/gbp/jpy). That one field is what the
70
+ credit ledger, the funding menu, and `POST /v1/credit` all denominate in, so the two ways it could
71
+ drift are both refused rather than converted: an `onQuote` returning a different currency answers
72
+ `quote_currency_mismatch` (500), and a static `price` on a non-USD DVM throws at `configureDVM`
73
+ (`"$0.05"` is a USD literal — a non-USD DVM prices with `onQuote`). `credit.min`/`max` stay USD
74
+ literals whatever you price in; the menu converts them.
75
+
76
+ ---
77
+
78
+ ## Prepaid credit
79
+
80
+ Every payment settles through a per-DVM **credit ledger**: the rail funds a balance, the job draws from it.
81
+ Per-call payment is the N=1 case of that (fund the paid amount, draw it, same transaction) and happens
82
+ whether or not you opt in. Declaring a `credit` block is the opt-in for callers to _hold_ a balance across
83
+ jobs — the ledger, `POST /v1/credit`, the funding menu on quotes and 402s, per-draw idempotency and the
84
+ quote balance echo are then wired for you:
85
+
86
+ ```typescript
87
+ configureDVM({
88
+ name: "assessor",
89
+ capability: "assess",
90
+ price: "$0.01",
91
+ auth: secp256k1Auth(), // required — a balance belongs to a verified pubkey
92
+ credit: {
93
+ min: "$0.10", // smallest top-up accepted (default "$0.10")
94
+ max: "$5.00", // ceiling on a caller's *residual balance* (default "$5.00")
95
+ ttl: 30 * 24 * 60 * 60, // seconds; default 30 days
96
+ allowOneShotStablecoin: false, // default; stablecoin credit uses reusable channels
97
+ },
98
+ onJob(ctx) {
99
+ /* unchanged */
100
+ },
101
+ });
102
+ ```
103
+
104
+ `max` bounds what a caller may sit on, not what they may pay: a job priced above `max` still clears, because
105
+ that request funds and draws in one step. Needs a Postgres-backed host — balances must survive a restart and
106
+ be visible fleet-wide.
107
+
108
+ - **`credit_id` / `draw_id` / `fund` are reserved at the top level of `data`.** A caller draws by putting
109
+ them in the same signed object your input schema validates. The SDK removes them before your schema sees
110
+ them and before the auth gate's schema check, so declare nothing for them, and **do** use `.strict()` if
111
+ you want it — unknown-key rejection still catches a caller's typo'd field with a 400 naming it.
112
+ The signature covers those keys either way, so tampering with one is still a 401. Don't name a field of
113
+ your own after one of them.
114
+ - **Never hand-roll credit state.** The SDK ships the primitive (`credits` + `credit_draws`, `SELECT … FOR
115
+ UPDATE` debits). `ctx.store` is **prohibited** for balances: last-write-wins KV on a multi-machine fleet
116
+ loses money under concurrency.
117
+ - **Stablecoin credit is reusable-only unless you deliberately opt in.** Tempo `session` and x402
118
+ `batch-settlement` fund prepaid balances by default; Tempo `charge` and x402 `exact` still pay per call.
119
+ Set `allowOneShotStablecoin: true` only when you accept that every unused one-payment balance becomes a
120
+ manual refund obligation. Missing, malformed and older config fails closed.
121
+ - **No debit on job failure**, with nothing to wire. A draw is a pending hold — settled on `completed`,
122
+ released on `failed` / `cancelled`, including `ctx.fail`, caller cancels and the stale-job reaper.
123
+ - **Quote handlers see the caller.** `ctx.callerPubkey` is the verified pubkey (undefined without auth);
124
+ `ctx.credit` is a read-only snapshot `{ creditId, currency, balanceMicro, availableMicro, expiryMs,
125
+ expired }` in micro-units — price against it, don't try to spend it (spending happens on the job path,
126
+ under the ledger's lock).
127
+ - **Menu missing when you expected one?** The refusal ladder is ordered, and **only its last two rungs say
128
+ anything**. Isolate runtime (never offers credit), no `credit` block, and no descriptor auth all return
129
+ silently — check those three first, in that order, because no log will point you at them. A non-durable
130
+ ledger outside devMode warns `credit_menu_suppressed_non_durable`; bounds not expressible in the quote
131
+ currency warn `credit_menu_bounds_unavailable` (note the different prefix — don't grep for one pattern).
132
+ Both fire once per process (`warnOnce`), so a restart is what re-arms them. A rung never fails the quote —
133
+ the sale survives without credit.
134
+ - **Reclaim is a builder obligation on the rails you hold the money on — and only those.** Who fulfils a
135
+ drain is decided by the rail, and treating one shape as another is how a self-settling refund lands on
136
+ your worklist or a real debt goes unpaid. **Ecash is the whole of your automatic obligation**:
137
+ `dvmctl melt-pending` parks notes P2PK-locked to the caller's refund key and holds your payout melt back
138
+ while refund liability is outstanding. A **Lightning-funded** credit reclaims that same way — direct
139
+ Lightning payout is not a reclaim method, so a `method: "lightning"` drain is refused
140
+ `drain_method_unsupported` and your deployment never needs a send-capable Lightning credential.
141
+ **Channel-backed x402 and Tempo drains settle themselves** on the caller's signed refund
142
+ voucher — no builder spending key, nothing to record; `drains-owed` never lists one and `drain-settle`
143
+ refuses it (`channel_bound_drain`), because a hand-recorded payout takes the row out of the reconciliation
144
+ that would have paid it. **One-payment x402 and Tempo drains are the only shape a person settles**: they
145
+ queue behind `drain_manual_settlement_required`, and `dvmctl credit drains-owed <handle>` then
146
+ `dvmctl credit drain-settle <handle> <credit-id> <drain-id> --tx <hash>` is the path. That one is manual
147
+ by design — automating it would put a chain spending key with your whole stablecoin balance behind it on
148
+ the `melt-pending` host. On the rails you hold, an
149
+ unfulfilled drain is a signed IOU the caller can prove. One-payment stablecoin credit exists only when
150
+ `allowOneShotStablecoin` explicitly enabled it; do not take that opt-in without an operating refund path.
151
+
152
+ For wire shapes and supported options, consult the installed SDK declarations and the local `dvmctl`
153
+ help for the version you installed.
154
+
155
+ ---
@@ -0,0 +1,231 @@
1
+ ## Running
2
+
3
+ ### dvmctl dev (development)
4
+
5
+ ```bash
6
+ dvmctl dev handler.ts # http://localhost:3000, hot reload, /_dev test page
7
+ dvmctl dev handler.ts --port 4000 # custom port
8
+ ```
9
+
10
+ Port resolution: `--port` > `.env PORT` > `process.env.PORT` > `3000`.
11
+
12
+ Handler resolution: default export > `dvm` named export > first `DVMDescriptor` export.
13
+
14
+ The dev server auto-credits payments — upfront and mid-job alike — **only while no mints are
15
+ configured**, so a priced handler runs with no wallet in sight. A mint alone starts Cashu accumulator
16
+ mode but does not supply its lock pubkey, durable DVM ID, or Postgres store, so it does not advertise
17
+ Cashu. Use the complete [Local Cashu paid test](local-cashu-test.md) for the self-hosted `dvmctl serve
18
+ --dvm` path. `dvmctl dev` itself serves a test page at `/_dev` for
19
+ interactive testing, whose auto-approve button satisfies a mid-job ask with a proofless `dev_auto`
20
+ flag no non-dev server admits. Drive it from another terminal with the `dvm` CLI:
21
+
22
+ ```bash
23
+ dvm request --endpoint http://localhost:3000 -i "input" --human
24
+ ```
25
+
26
+ `--endpoint` and `-d <identifier>` are mutually exclusive — pass one or the other, not both.
27
+ Against a raw endpoint the capability auto-resolves from `/v1/info`, so a single-cap DVM needs
28
+ nothing more. A multi-cap DVM has no capability to auto-resolve and errors `ambiguous_capability`
29
+ with the available tags listed — pin one via the slash form (`-d <identifier>/<tag>`), where
30
+ `<identifier>` is an owner-qualified `<handle>--<slug>` or a saved favorite.
31
+
32
+ ### serve(dvm) — production one-liner
33
+
34
+ ```typescript
35
+ import { serve } from "@dvmkit/sdk/server";
36
+ import dvm from "./handler";
37
+
38
+ await serve(dvm);
39
+ // or with overrides:
40
+ await serve(dvm, { port: 8080, mints: ["https://mint.example.com"] });
41
+ ```
42
+
43
+ `serve(dvm, opts?)` is a thin wrapper over `createDVMHost(opts).mount(dvm).serve()` and returns
44
+ `{ url, close }`. All wiring (port, `DATABASE_URL`, `DVMKIT_CASHU_MINTS`, Tempo/x402 rails, FX,
45
+ platform reporter) is resolved from env by default — `opts` only overrides.
46
+
47
+ ### createDVMHost(opts) — advanced
48
+
49
+ Use directly when you need to register `host.app` routes, mount several DVMs, or graceful-shutdown
50
+ alongside DVM-owned resources:
51
+
52
+ ```typescript
53
+ import { createDVMHost } from "@dvmkit/sdk/server";
54
+ import { createCastDVM, buildCastRuntime } from "./handler";
55
+
56
+ const { runtime } = await buildCastRuntime(process.env);
57
+
58
+ const host = createDVMHost({
59
+ fx: runtime.fxFetcher,
60
+ // database / mints / port etc default from env
61
+ });
62
+
63
+ host.mount(createCastDVM(runtime));
64
+
65
+ const { url } = await host.serve();
66
+ console.log(`cast running at ${url}`);
67
+ ```
68
+
69
+ ---
70
+
71
+ ## createDVMHost / serve options
72
+
73
+ `DVMHostOpts` (passed to either function):
74
+
75
+ | Option | Type | Default | Description |
76
+ | ------------------ | ------------------------ | ----------------------------- | ----------------------------------------------------------- |
77
+ | `port` | `number` | `PORT` env or `8080` | HTTP listen port |
78
+ | `database` | `string` | `DATABASE_URL` env | Postgres URL — auto-creates job + KV stores (`pg` peer dep) |
79
+ | `jobStore` | `JobStore` | derived from `database` | Explicit job store; precedence over `database` |
80
+ | `store` | `KVStore` | in-memory | KV store for `ctx.store` |
81
+ | `env` | `Record<string, string>` | `process.env` | Injected as `ctx.env` |
82
+ | `mints` | `string[]` | `DVMKIT_CASHU_MINTS` env | Cashu mints accepted |
83
+ | `mpp` | `MppxServer` | from `DVMKIT_TEMPO_*` env | Tempo rail handle (mppx server) |
84
+ | `x402` | `X402Config` | from `DVMKIT_X402_*` env | x402 stablecoin rail config |
85
+ | `paymentMethods` | `PaymentMethod[]` | derived from configured rails | Restrict accepted rails |
86
+ | `fx` | `FxFetcher` | env-derived | FX rate fetcher (override for tests or alt sources) |
87
+ | `builder` | `BuilderIdentity` | env-derived | `{ id?, name?, url? }` for `/v1/info` |
88
+ | `healthHandler` | `(c) => Response` | default `/health` | Custom `/health` handler |
89
+ | `platformReporter` | `PlatformReporterOpts` | `DVMKIT_PLATFORM_*` env | `{ token?, url? }` for revenue reporting |
90
+ | `devMode` | `boolean` | `false` | Skips DB requirement; auto-credits only when mint-less |
91
+
92
+ `DVMHost` returned by `createDVMHost`:
93
+
94
+ | Property / method | Description |
95
+ | ----------------------------- | --------------------------------------------------------------- |
96
+ | `host.app` | Underlying Hono app — register host-level routes here |
97
+ | `host.pool` | Postgres pool, set after `host.serve()` resolves |
98
+ | `host.mount(dvm, { prefix })` | Mount a DVM; optional prefix nests its routes under `/<prefix>` |
99
+ | `host.serve({ port })` | Start listening; returns `{ url, close }` |
100
+ | `host.shutdown()` | Graceful shutdown |
101
+
102
+ ---
103
+
104
+ ## Persistence
105
+
106
+ Pass `database` (or set `DATABASE_URL`) to persist jobs, state, step caches, and KV data to
107
+ Postgres:
108
+
109
+ ```typescript
110
+ await serve(dvm, { database: process.env.DATABASE_URL });
111
+ ```
112
+
113
+ From the handler author's perspective:
114
+
115
+ - `ctx.state` is typed from your `state: { ... }` defaults and is **auto-persisted transactionally
116
+ on every yield** (prompt, requestPayment) and at terminal states (complete, fail). After a
117
+ process restart, the SDK reloads state from Postgres and resumes.
118
+ - `ctx.step("id", fn)` results are persisted alongside state. On replay, cached steps return their
119
+ stored value without re-executing, and replay the step's `ctx.cost()` declarations exactly once.
120
+ - `ctx.requestPayment(amount, reason, opts?)` resolves on **counter-based auto-credit**: if the
121
+ upfront payment already covers `amount - alreadyCreditedToThisHandler`, the call returns
122
+ synchronously without round-tripping to the client. Only when the upfront pool is exhausted does
123
+ the handler suspend waiting for a new payment.
124
+
125
+ Auto-creates `PostgresJobStore` + `PostgresKVStore`; tables are created on first run. `jobStore`
126
+ and `store` opts take precedence over `database` for advanced use. `pg` is an optional peer dep —
127
+ the scaffold already lists it.
128
+
129
+ ---
130
+
131
+ ## Deployment
132
+
133
+ The scaffold ships a Dockerfile and wires `npm run deploy` → `dvmctl deploy`. The cloud path:
134
+
135
+ ```bash
136
+ dvmctl validate # fast local check: slug, package.json, vmConfig — JSON out, no Docker
137
+ dvmctl deploy # build container, push, deploy to the dvmkit platform
138
+ dvmctl deploy --dry-run # full validation incl. a real `docker build` (slow, cold) — no provisioning
139
+ ```
140
+
141
+ Use `dvmctl validate` for fast iteration; it's instant and emits `{"status":"ok", ...}`.
142
+ `dvmctl deploy --dry-run` adds a real container build on top, so it's slower and needs Docker.
143
+
144
+ On dvmkit Cloud, the active org owns each deployed DVM. Its treasury holds payout configuration — Tempo recipient, Base/x402 address, Cashu mints, and lock pubkey — rather than the DVM descriptor or builder profile. The lock pubkey propagates from org to DVM to SDK; see the three-level lock pubkey model.
145
+
146
+ Provide the deployed DVM's API keys and payment credentials through its host's runtime environment
147
+ facility; keep them out of source control. That is separate from the platform org treasury's payout
148
+ configuration. System dependencies (ffmpeg, python, …) go in the scaffolded `Dockerfile`'s runtime
149
+ stage. VM resources go in `package.json`'s `dvmkit` field (`{ "dvmkit": { "memory_mb": 1024 } }`).
150
+
151
+ ### Environment variables
152
+
153
+ The SDK reads only `DVMKIT_*`-prefixed variables. Old unprefixed names (`MINTS`,
154
+ `MPP_TEMPO_RECIPIENT`, `X402_PAY_TO`, etc.) and the legacy `DVMKIT_FIRST_PARTY` /
155
+ `DVMKIT_ACCESS_TOKENS` / `DVMKIT_SKIP_PAYMENT` are gone — don't use them.
156
+
157
+ **Standard (unprefixed):**
158
+
159
+ | Var | Description |
160
+ | -------------- | ------------------------------------------------ |
161
+ | `PORT` | HTTP port (default: 8080) |
162
+ | `DATABASE_URL` | Postgres connection string (enables persistence) |
163
+
164
+ **Cashu rail:**
165
+
166
+ | Var | Description |
167
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
168
+ | `DVMKIT_CASHU_MINTS` | Comma-separated Cashu mint URLs |
169
+ | `DVMKIT_CASHU_LOCK_PUBKEY` | P2PK lock pubkey for the accumulator |
170
+ | `DVMKIT_DVM_ID` | DVM identifier (replay key + scheduler lock) |
171
+ | `DVMKIT_CANONICAL_DVM_ID` | Override `DVMKIT_DVM_ID` for cross-instance canonical ID |
172
+ | `DVMKIT_ALLOW_TEST_MINTS` | `true` to allow a test mint (`localhost:3338`, `testnut.cashu.space`) on a platform-hosted boot (`DVMKIT_PLATFORM_URL` set). Otherwise such a boot refuses to start — a test mint settles no real money. Local dev never trips it. |
173
+
174
+ **Tempo rail:**
175
+
176
+ | Var | Description |
177
+ | ---------------------------- | ----------------------------------------------- |
178
+ | `DVMKIT_TEMPO_RECIPIENT` | 0x-prefixed 40-char hex Tempo recipient address |
179
+ | `DVMKIT_TEMPO_SECRET_KEY` | HMAC key for Tempo challenges |
180
+ | `DVMKIT_TEMPO_METHODS` | Comma-separated allowlist of Tempo methods |
181
+
182
+ **x402 rail:**
183
+
184
+ | Var | Description |
185
+ | ------------------------- | ---------------------------------------- |
186
+ | `DVMKIT_X402_PAY_TO` | EVM address receiving stablecoin |
187
+ | `DVMKIT_X402_NETWORK` | Validated CAIP-2 id or known slug (default `eip155:8453`) |
188
+ | `DVMKIT_X402_ASSET` | Asset symbol |
189
+ | `DVMKIT_X402_FACILITATOR` | Facilitator URL; use `https://api.cdp.coinbase.com/platform/v2/x402` for CDP |
190
+ | `DVMKIT_X402_FACILITATOR_KEY_ID` | CDP API key ID; requires the secret below |
191
+ | `DVMKIT_X402_FACILITATOR_KEY_SECRET` | CDP SEC1 ES256 PEM (`BEGIN EC PRIVATE KEY`) or base64 Ed25519 secret; provide it through the deployment environment |
192
+ | `DVMKIT_X402_DUAL_SERVE` | `"false"` disables dual-serve |
193
+ | `DVMKIT_X402_DESCRIPTION` | Prose description |
194
+ | `DVMKIT_X402_BATCH_SETTLEMENT` | `true` enables reusable channels: Base Sepolia uses hosted facilitator support; Base mainnet also requires the self-relay key below and keeps batch credit funding independent from hosted exact health |
195
+ | `DVMKIT_X402_RECEIVER_AUTHORIZER_KEY` | Receiver-authorizer private key when the facilitator supplies none; mainnet self-relay requires this separate signing-only key |
196
+ | `DVMKIT_X402_SELF_RELAY_KEY` | Dedicated funded EOA key for one DVM's Base-mainnet relay; never reuse it as the authorizer or across DVMs |
197
+ | `DVMKIT_X402_SELF_RELAY_MIN_BALANCE_WEI` | Optional positive Base ETH paging floor; defaults to 0.001 ETH |
198
+ | `DVMKIT_X402_RPC_URL` | Required explicit Base RPC for self-relay transaction submission and settlement evidence |
199
+ | `DVMKIT_X402_WITHDRAW_DELAY_SECONDS` | Channel withdrawal grace; minimum/default 86400 |
200
+
201
+ **Lightning receive (credit funding only —:**
202
+
203
+ | Var | Description |
204
+ | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
205
+ | `DVMKIT_NWC_RECEIVE_URI` | **Receive-only** NWC connection the DVM issues credit-funding bolt11s over. Adds `lightning` to the credit funding menu; never to `payment_methods` — there is no per-call Lightning rail. |
206
+ | `DVMKIT_NWC_RECEIVE_INVOICE_TTL_SECONDS` | Invoice lifetime (default `900`, floor `600`) |
207
+ | `DVMKIT_LIGHTNING_FUNDING_MIN_SATS` | The deployment's receive floor in sats (default `2000`,. Advertised on the menu as `lightning_min_micro` (×1.15 fx margin); a sub-floor fund is refused `below_rail_minimum` before any invoice exists — a bolt11 under the channel's `htlc_minimum_msat` can never route. |
208
+
209
+ The connection must permit `get_info`, `get_balance`, `make_invoice`, `lookup_invoice` and **not** `pay_invoice` — the SDK probes it at boot and refuses to start on a spend-capable connection, or on a wallet that won't state what its connection permits. A DVM machine never holds a spending credential; the drain sender is a deliberately separate, budget-capped connection.
210
+
211
+ Crediting is **pull-based**. The caller sends `op: "fund"` with `fund: { amount_micro, fund_id, method: "lightning" }` and no `commitment` (there is no artifact to hash — the invoice is minted for the credit and amount in their signed body, which is the binding), gets a 402 carrying the bolt11, and their _next_ request — a re-poll, a `balance` read, or a job submit naming the credit — asks the wallet and credits the ledger in one transaction. No background watcher, so a suspend-to-zero fleet works unchanged. A re-poll always returns the same bolt11; a second invoice per `fund_id` would be two payable invoices against one creditable funding. If the wallet is unreachable, `lightning` drops off the menu and the other rails carry the sale.
212
+
213
+ **Platform / ops:**
214
+
215
+ | Var | Description |
216
+ | ----------------------- | ------------------------------------------------------------------------------------------------ |
217
+ | `DVMKIT_FAIL_FAST` | `"1"` or `"true"` → escalate warning-level SDK boot checks to fatal (strict mode) |
218
+ | `DVMKIT_FX_SOURCE` | FX rate source override |
219
+ | `DVMKIT_PLATFORM_TOKEN` | Bearer token for platform revenue reporter |
220
+ | `DVMKIT_PLATFORM_URL` | Platform internal URL — setting it marks the boot platform-hosted |
221
+ | `DVMKIT_RECEIPT_KEY` | Receipt-signing secret. Provisioned by `dvmctl deploy` — see [Signed receipts](patterns-auth.md#signed-receipts) |
222
+
223
+ Setting `DVMKIT_PLATFORM_URL` makes `DVMKIT_PLATFORM_TOKEN`, `DVMKIT_DVM_ID`, and `DATABASE_URL`
224
+ mandatory: partial revenue-reporter wiring is fatal at boot whether or not `DVMKIT_FAIL_FAST` is set
225
+ . `devMode` boots — `dvmctl dev`, `dvm serve`, the e2e/smoke harnesses — are exempt, so a
226
+ `DVMKIT_PLATFORM_URL` exported in your shell won't stop a dev server booting.
227
+
228
+ `DVMKIT_FAIL_FAST` is the general strict-mode flag for the SDK's other boot checks (e.g.
229
+ `secp256k1Auth`'s in-memory replay store outside `devMode`); set it in production.
230
+
231
+ ---
@@ -0,0 +1,47 @@
1
+ # SDK reference
2
+
3
+ Use this after the first-build brief is approved. Keep the first version flat and single-capability unless the product requires distinct inputs, prices, or handlers.
4
+
5
+ ```ts
6
+ import { configureDVM, z } from "@dvmkit/sdk";
7
+
8
+ export default configureDVM({
9
+ name: "Uppercase",
10
+ description: "Convert text to uppercase.",
11
+ capability: "uppercase",
12
+ tags: ["text"],
13
+ input: z.object({ text: z.string().min(1) }),
14
+ price: "$0.01",
15
+ async onJob(ctx) {
16
+ ctx.complete(ctx.input.text.toUpperCase());
17
+ },
18
+ });
19
+ ```
20
+
21
+ Import `serve` or `createDVMHost` from `@dvmkit/sdk/server`, and `createTestContext` from `@dvmkit/sdk/testing`. `serve(dvm)` is sufficient for a normal DVM. Use `createDVMHost` only for custom HTTP routes, mounting several DVMs, or explicit lifecycle control.
22
+
23
+ ## Descriptor choices
24
+
25
+ - `input: z.object(...)` validates requests before `onJob` and types `ctx.input`. Omit it only for deliberately raw-string input.
26
+ - `price: "$0.01"` is a static USD price. Omit it for free work. Use `onQuote` for dynamic pricing; never calculate a static price in sats.
27
+ - `capabilities: { ... }` replaces the flat capability fields for a multi-capability DVM. Keep name, tags, auth, routes, and lifecycle hooks at the parent level.
28
+ - `state` provides typed per-job defaults. Use a persistent store only when jobs must survive process restarts.
29
+ - `paymentMethods` is normally derived from configured server rails. Do not advertise a rail that the server cannot settle.
30
+
31
+ ## Job handler rules
32
+
33
+ `ctx.text()` sends progress, `ctx.artifact()` sends named data, `ctx.working()` yields with an estimate, `ctx.prompt()` waits for a caller response, and `ctx.complete()` finishes. Call `ctx.cost({ amount, currency })` immediately after a paid provider call. Check `ctx.signal.aborted` around long work and pass the signal to fetches where supported. A cancellation is terminal; never attempt to complete it afterward.
34
+
35
+ Use `ctx.fail(message, { refund: true })` for a provider or network failure that should return a Cashu payment. Configuration errors and invalid user input should fail without a refund.
36
+
37
+ ## Tests and secrets
38
+
39
+ Write deterministic unit tests with `createTestContext`; assert messages, completion, failure, and artifacts. Mock an upstream boundary in unit tests and keep live-provider tests separate. Load credentials from environment variables. Never place a key, mnemonic, private key, or test wallet state in a repository.
40
+
41
+ ## Advanced routing
42
+
43
+ Use descriptor `auth: secp256k1Auth(...)` only when the product needs authenticated callers. The verified identity is `ctx.auth`; do not accept an identity asserted in input. Use `routes(app)` for non-protocol endpoints and keep `/v1/*` protocol endpoints under SDK control. See the installed package's TypeScript declarations for exact option types; they match the installed SDK version.
44
+
45
+ ## Deploy preparation
46
+
47
+ Before deployment, run the generated tests and validate the descriptor. A production deployment needs its own account, identity, database, environment secrets, and payment configuration. Do not reuse a local FakeWallet mint or its test key in production.
@@ -0,0 +1,181 @@
1
+ ## Input validation
2
+
3
+ Pass an `input:` Zod schema and `ctx.input` is parsed and typed. Zod ships with the SDK —
4
+ `import { z } from "@dvmkit/sdk"`, no extra install. Skip the schema and `ctx.input` stays a raw
5
+ string you parse yourself.
6
+
7
+ ```typescript
8
+ import { configureDVM, z } from "@dvmkit/sdk";
9
+
10
+ export default configureDVM({
11
+ name: "Image Gen",
12
+ capability: "generate",
13
+ tags: ["image-generation"],
14
+ input: z.object({
15
+ prompt: z.string(),
16
+ width: z.number().default(1024),
17
+ height: z.number().default(1024),
18
+ }),
19
+
20
+ async onJob(ctx) {
21
+ // ctx.input is typed: { prompt: string; width: number; height: number }
22
+ ctx.text(`Generating: ${ctx.input.prompt}`);
23
+ },
24
+ });
25
+ ```
26
+
27
+ **Document fields with `.describe()`, not JSDoc.** `/v1/info` renders each field via
28
+ `toJSONSchema(input, { io: "input" })`, which carries **only** Zod `.describe()` metadata — a
29
+ `/** … */` JSDoc comment on a schema field is silently dropped and never reaches the cold-calling
30
+ agent. Put field docs on `.describe("…")`; reserve JSDoc for internal notes. When a cross-field
31
+ rule lives in a `.refine()` (e.g. "exactly one of `audio_url` / `audio_upload_handle`"), name it in
32
+ the fields' `.describe()` and also ship an `example` so the CLI renders a runnable `--data` payload
33
+ instead of a schema skeleton. The `dvmkit/no-jsdoc-on-zod-schema` ESLint rule (scoped to
34
+ the DVM source tree) flags JSDoc on Zod-object properties.
35
+
36
+ ```typescript
37
+ // ❌ dropped from /v1/info ✅ reaches the wire
38
+ z.object({ z.object({
39
+ /** Omit to auto-detect. */ language: z.string().describe("Omit to auto-detect.").optional(),
40
+ language: z.string().optional(), });
41
+ });
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Testing
47
+
48
+ ### createTestContext()
49
+
50
+ ```typescript
51
+ import { createTestContext } from "@dvmkit/sdk/testing";
52
+
53
+ const ctx = createTestContext({
54
+ input: "test input", // string or parsed type
55
+ tags: ["web"],
56
+ params: { key: "value" },
57
+ env: { API_KEY: "test" }, // ctx.env
58
+ state: { step: 0 }, // ctx.state (typed, structured-cloned)
59
+ paidMsats: 10000,
60
+ responses: {
61
+ // pre-programmed prompt answers
62
+ q1: { text: "answer", prompt_id: "q1" },
63
+ },
64
+ payment: { cashu_token: "..." }, // pre-programmed requestPayment answer
65
+ jobId: "test-job-1",
66
+ requesterId: "test-requester",
67
+ });
68
+ ```
69
+
70
+ `createTestContext` bypasses `JobStore` entirely: `step()` runs `fn()` directly with no caching,
71
+ state is a structured clone of `opts.state`, and there is no Postgres dependency. `ctx.auth` is
72
+ always `undefined` in tests (there's no `auth` option) — exercise signed-request auth through
73
+ `createSignedRequestVerifier` round-trips instead.
74
+
75
+ ### Inspection properties
76
+
77
+ | Property | Type | Description |
78
+ | ----------------- | --------------------- | ----------------------------------------- |
79
+ | `messages` | `RecordedMessage[]` | All outbound messages `[{type, content}]` |
80
+ | `completed` | `boolean` | Whether `complete()` was called |
81
+ | `failed` | `boolean` | Whether `fail()` was called |
82
+ | `refundRequested` | `boolean` | Whether `fail()` was called with refund |
83
+ | `summary` | `string \| undefined` | The string passed to `complete()` |
84
+ | `failError` | `string \| undefined` | The string passed to `fail()` |
85
+
86
+ `ctx.cancel(reason?)` aborts `ctx.signal` exactly as a real cancel would and puts the context into the
87
+ same post-terminal shape as the runtime: every emitter (`text` / `artifact` / `sendMessage` /
88
+ `working` / `progress` / `complete` / `fail`) is a no-op, and `prompt` / `requestPayment` reject with
89
+ `JobCancelledError`. Use it to assert your handler stops work:
90
+
91
+ ```typescript
92
+ const ctx = createTestContext({ input: "long job" });
93
+ const running = dvm.capabilities.transcribe.onJob(ctx);
94
+ ctx.cancel("caller changed their mind");
95
+ await running;
96
+ expect(ctx.completed).toBe(false);
97
+ ```
98
+
99
+ ### Calling the handler
100
+
101
+ The descriptor exposes `dvm.capabilities[<name>].onJob` in both shapes (flat single-capability or
102
+ multi-capability):
103
+
104
+ ```typescript
105
+ import { describe, expect, it } from "vitest";
106
+
107
+ import { createTestContext } from "@dvmkit/sdk/testing";
108
+
109
+ import { createTranslatorDVM } from "./handler";
110
+
111
+ describe("Translator", () => {
112
+ const dvm = createTranslatorDVM();
113
+
114
+ it("translates with language prompt", async () => {
115
+ const ctx = createTestContext({
116
+ input: "Hello",
117
+ tags: ["translation"],
118
+ state: { detectedLanguage: "", targetLanguage: "" },
119
+ responses: { "target-lang": { text: "Spanish", prompt_id: "target-lang" } },
120
+ });
121
+
122
+ await dvm.capabilities.translate.onJob(ctx);
123
+
124
+ expect(ctx.completed).toBe(true);
125
+ expect(ctx.summary).toContain("Spanish");
126
+ expect(ctx.messages).toContainEqual(expect.objectContaining({ type: "artifact" }));
127
+ });
128
+ });
129
+ ```
130
+
131
+ ### Live provider contracts
132
+
133
+ **Invariant: a capability that charges _and_ whose handler calls a paid third-party API must ship a
134
+ live provider-contract test.** Both halves of the conjunction are required, and the rule is
135
+ deliberately narrow. A free capability, or one that only talks to your database, your own endpoints,
136
+ or a local FakeWallet mint, falls outside it. Give those paths appropriate integration checks in
137
+ your project.
138
+
139
+ Why: `createTestContext` and a stubbed `globalThis.fetch` pin your request shape against your own
140
+ expectation. They do not prove the provider accepts it. A provider can reject a request after the
141
+ job has taken payment. No debit lands either way — the draw releases (see
142
+ [Refund-on-failure policy](patterns-auth.md#refund-on-failure-policy)) — but reaching that released
143
+ value needs a verified caller. Add `auth` when the product needs callers to use released credit again;
144
+ a rail-level reversal is a separate payment-design choice.
145
+
146
+ The pattern has three parts:
147
+
148
+ 1. **Name the file `<provider>.live.test.ts`.** Configure your project's default test command to
149
+ exclude `**/*.live.test.ts`, so ordinary checks never spend vendor money.
150
+ 2. **Gate the suite on `describe.skipIf(!process.env.<PROVIDER_KEY>)`.** This is what makes the test
151
+ **merge inert**: a keyless checkout and a keyless CI run both skip cleanly rather than reddening.
152
+ The rule does **not** mean CI needs your API key.
153
+ 3. **Add an opt-in `test:providers:live` script** that includes only `**/*.live.test.ts`. Nothing
154
+ else runs it.
155
+
156
+ ```typescript
157
+ const API_KEY = process.env.ELEVENLABS_API_KEY;
158
+
159
+ describe.skipIf(!API_KEY)("ElevenLabs live provider contract (real EL spend)", () => {
160
+ it("accepts the expressive multi-chunk shape we emit", async () => {
161
+ // Real call. Assert the provider ACCEPTS what we send…
162
+ expect(call.status).toBe(200);
163
+ expect(call.body.model_id).toBe(ELEVENLABS_V3_MODEL_ID);
164
+ // …and returns what we actually parse.
165
+ expectMp3(audio);
166
+ });
167
+ });
168
+ ```
169
+
170
+ **Assert the provider accepts every request shape the handler actually emits, and returns something
171
+ the parser actually parses.** A live 2xx alone is not the contract — if the handler branches (model
172
+ × chunking, streaming vs. batch, each option a caller can select), each branch is a distinct wire
173
+ shape and needs a cell. This is a _contract_ test, not a quality test: "the provider accepts what we
174
+ send", never "the output is good".
175
+
176
+ It spends **real vendor money** (never caller funds, never production). Keep request and response
177
+ evidence small: record URL, body shape, and status; never print headers or keys. A provider 429/5xx
178
+ may retry once, then skip with a clear reason rather than reporting a false product regression.
179
+ Shipping this test with the capability keeps a new paid boundary from depending only on mocks.
180
+
181
+ ---