@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.
- package/NOTICE +1983 -0
- package/README.md +34 -1
- package/dist/dvmctl.js +106 -59
- package/package.json +9 -4
- package/skills/build-dvm/SKILL.md +93 -0
- package/skills/build-dvm/references/configure-context.md +273 -0
- package/skills/build-dvm/references/local-cashu-test.md +141 -0
- package/skills/build-dvm/references/operating-feedback.md +153 -0
- package/skills/build-dvm/references/patterns-auth.md +390 -0
- package/skills/build-dvm/references/payment-rails.md +11 -0
- package/skills/build-dvm/references/pricing-credit.md +155 -0
- package/skills/build-dvm/references/running-deployment.md +231 -0
- package/skills/build-dvm/references/sdk-reference.md +47 -0
- package/skills/build-dvm/references/testing.md +181 -0
|
@@ -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
|
+
---
|