@dvmkit/dvmctl 0.3.3-rc.1 → 0.3.5-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.
@@ -1,231 +0,0 @@
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
- With no configured rail, dev mode skips payment verification and says so in its banner. Configure
15
- Cashu, x402, or Tempo and the same server performs real rail verification with disposable in-process
16
- storage; its banner names every rail, verification state, and whether the SDK can prove the funds are
17
- test funds. The paid-completion observer prints each settled job and a deduplicated session total.
18
- `/_dev` can satisfy an interactive ask with the synthetic `dev_auto` flag, which no non-dev server
19
- admits; that shortcut applies only to the test page, never to caller CLI payments. Follow the
20
- rail-specific paid-test references for bounded calls. Drive a no-rail server from another terminal:
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 # load handler + validate capability/schema/example/price/tags and package metadata
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 emits `{"status":"ok", ...}` with agent-facing display and hint fields.
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
- ---
@@ -1,47 +0,0 @@
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.
@@ -1,181 +0,0 @@
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
- ---