@agentcash/router 1.5.2 → 1.7.0
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/AGENTS.md +85 -0
- package/README.md +138 -526
- package/dist/index.cjs +2380 -1244
- package/dist/index.d.cts +451 -317
- package/dist/index.d.ts +451 -317
- package/dist/index.js +2372 -1227
- package/package.json +16 -24
- package/.claude/CLAUDE.md +0 -229
- package/.claude/skills/router-guide/SKILL.md +0 -585
package/README.md
CHANGED
|
@@ -1,156 +1,130 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://agentcash.dev/logo-dark-striped.svg">
|
|
4
|
+
<img alt="AgentCash" src="https://agentcash.dev/logo-light-striped.svg" width="360">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
<h1 align="center">@agentcash/router</h1>
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
<p align="center">
|
|
11
|
+
<strong>The fastest way to ship an API on x402 and MPP.</strong><br/>
|
|
12
|
+
x402 and MPP payments, compatible discovery, and minimal boilerplate. With @agentcash/router, agents on <a href="https://agentcash.dev">AgentCash</a> and across the agentic commerce ecosystem are compatible and call your endpoints from day one.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://www.npmjs.com/package/@agentcash/router"><img alt="npm" src="https://img.shields.io/npm/v/@agentcash/router.svg?color=111&label=npm"></a>
|
|
17
|
+
<a href="https://agentcash.dev/docs"><img alt="docs" src="https://img.shields.io/badge/docs-agentcash.dev-111"></a>
|
|
18
|
+
<a href="#install"><img alt="next.js" src="https://img.shields.io/badge/Next.js-App%20Router-111"></a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
---
|
|
6
22
|
|
|
7
23
|
## Install
|
|
8
24
|
|
|
9
25
|
```bash
|
|
10
26
|
pnpm add @agentcash/router
|
|
27
|
+
pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi # peer dependencies
|
|
28
|
+
pnpm add mppx # optional, for MPP support
|
|
11
29
|
```
|
|
12
30
|
|
|
13
|
-
|
|
31
|
+
## Environment
|
|
14
32
|
|
|
15
|
-
|
|
16
|
-
pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi
|
|
17
|
-
# Optional: for MPP support
|
|
18
|
-
pnpm add mppx
|
|
19
|
-
```
|
|
33
|
+
The recommended entry point reads its config from `process.env`. A copy-paste `.env.example` lives at the repo root.
|
|
20
34
|
|
|
21
|
-
|
|
35
|
+
### x402
|
|
22
36
|
|
|
23
|
-
|
|
37
|
+
| Var | Required | Purpose |
|
|
38
|
+
|-----|----------|---------|
|
|
39
|
+
| `EVM_PAYEE_ADDRESS` | yes | EVM address that receives x402 and MPP payments (`0x…`, 20 bytes). Canonicalized to lowercase. The zero address is rejected. |
|
|
40
|
+
| `CDP_API_KEY_ID`, `CDP_API_KEY_SECRET` | yes (production) | Coinbase Developer Platform credentials for the default EVM facilitator. T3 / `@t3-oss/env-nextjs` users must declare these in their env schema. |
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
CDP_API_KEY_ID=your-key-id
|
|
27
|
-
CDP_API_KEY_SECRET=your-key-secret
|
|
28
|
-
```
|
|
42
|
+
### Solana
|
|
29
43
|
|
|
30
|
-
|
|
44
|
+
| Var | Required | Purpose |
|
|
45
|
+
|-----|----------|---------|
|
|
46
|
+
| `SOLANA_PAYEE_ADDRESS` | no | When set, adds a Solana `exact` accept so the router takes Solana payments. **Dynamic pricing (`upto`) is Base-only** — Solana clients can only pay static-priced routes. |
|
|
47
|
+
| `SOLANA_FACILITATOR_URL` | no | Override the Solana x402 facilitator. Defaults to `DEFAULT_SOLANA_FACILITATOR_URL`. |
|
|
31
48
|
|
|
32
|
-
|
|
33
|
-
// src/env.js
|
|
34
|
-
import { createEnv } from "@t3-oss/env-nextjs";
|
|
35
|
-
import { z } from "zod";
|
|
36
|
-
|
|
37
|
-
export const env = createEnv({
|
|
38
|
-
server: {
|
|
39
|
-
CDP_API_KEY_ID: z.string(),
|
|
40
|
-
CDP_API_KEY_SECRET: z.string(),
|
|
41
|
-
},
|
|
42
|
-
runtimeEnv: {
|
|
43
|
-
CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
|
|
44
|
-
CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
```
|
|
49
|
+
### MPP (auto-enabled when `MPP_SECRET_KEY` is set)
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
| Var | Required | Purpose |
|
|
52
|
+
|-----|----------|---------|
|
|
53
|
+
| `MPP_SECRET_KEY` | when MPP is enabled | Server-side MPP secret. Presence toggles MPP on. |
|
|
54
|
+
| `MPP_CURRENCY` | when MPP is enabled | Tempo currency address. Use `TEMPO_USDC_ADDRESS` for Tempo USDC. |
|
|
55
|
+
| `TEMPO_RPC_URL` | when MPP is enabled | Authenticated Tempo JSON-RPC endpoint. Public `rpc.tempo.xyz` returns 401. |
|
|
56
|
+
| `MPP_OPERATOR_KEY` | no | Signs server-side close/settle. When set, MPP session mode is enabled automatically (required for streaming + `.paid({ dynamic: true })` on MPP). Address must equal the payee. |
|
|
57
|
+
| `MPP_FEE_PAYER_KEY` | no | Sponsors client gas for channel open/topUp. Must resolve to a different address than `MPP_OPERATOR_KEY` (Tempo rejects fee-delegated txs where `sender === feePayer`). |
|
|
54
58
|
|
|
55
|
-
###
|
|
59
|
+
### Other
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
validateRouterConfig,
|
|
62
|
-
x402AcceptsFromEnv,
|
|
63
|
-
type ProtocolType,
|
|
64
|
-
} from '@agentcash/router';
|
|
65
|
-
|
|
66
|
-
const payeeAddress = process.env.X402_WALLET_ADDRESS ?? process.env.X402_PAYEE_ADDRESS;
|
|
67
|
-
const accepts = x402AcceptsFromEnv(process.env, { payeeAddress });
|
|
68
|
-
const protocols: ProtocolType[] = process.env.MPP_SECRET_KEY ? ['x402', 'mpp'] : ['x402'];
|
|
69
|
-
|
|
70
|
-
const config = {
|
|
71
|
-
payeeAddress: payeeAddress!,
|
|
72
|
-
baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
|
|
73
|
-
strictRoutes: true,
|
|
74
|
-
protocols,
|
|
75
|
-
x402: { accepts },
|
|
76
|
-
mpp: mppFromEnv(process.env, {
|
|
77
|
-
recipient: payeeAddress,
|
|
78
|
-
useDefaultStore: true,
|
|
79
|
-
}),
|
|
80
|
-
discovery: {
|
|
81
|
-
title: 'My API',
|
|
82
|
-
version: '1.0.0',
|
|
83
|
-
},
|
|
84
|
-
};
|
|
61
|
+
| Var | Required | Purpose |
|
|
62
|
+
|-----|----------|---------|
|
|
63
|
+
| `BASE_URL` | yes | Origin URL (`https://api.example.com`). Load-bearing — used as the 402 realm, OpenAPI server URL, and MPP memo prefix. Must match the public domain. |
|
|
64
|
+
| `KV_REST_API_URL`, `KV_REST_API_TOKEN` | no | Upstash / Vercel KV. Backs SIWX nonce, SIWX entitlement, and MPP replay. In-memory fallback is unsafe in serverless production. Providing a Kv Store is highly recommended. |
|
|
85
65
|
|
|
86
|
-
|
|
87
|
-
export const router = createRouter(config);
|
|
88
|
-
```
|
|
66
|
+
## Quick start
|
|
89
67
|
|
|
90
|
-
|
|
91
|
-
mainnet (`SOLANA_MAINNET_NETWORK`) when `SOLANA_PAYEE_ADDRESS` is set. Solana
|
|
92
|
-
addresses are case-sensitive and are preserved as-is. It reads
|
|
93
|
-
`X402_WALLET_ADDRESS` by default; older services that still use
|
|
94
|
-
`X402_PAYEE_ADDRESS` can pass `{ payeeEnv: 'X402_PAYEE_ADDRESS' }`.
|
|
68
|
+
### 1. Create the router
|
|
95
69
|
|
|
96
|
-
|
|
97
|
-
env var is present, the full trio is required: `MPP_SECRET_KEY`, `MPP_CURRENCY`,
|
|
98
|
-
and `TEMPO_RPC_URL`. `MPP_CURRENCY` must be the Tempo currency address; for
|
|
99
|
-
Tempo USDC use `TEMPO_USDC_CURRENCY`. Optional `MPP_FEE_PAYER_KEY` is included
|
|
100
|
-
when present and validated as a 32-byte EVM private key. `mppFromEnv()` only
|
|
101
|
-
builds config; call `validateRouterConfig(config)` before `createRouter(config)`
|
|
102
|
-
to fail fast on `mpp.useDefaultStore` store env (`KV_REST_API_URL` and
|
|
103
|
-
`KV_REST_API_TOKEN`) when you use the default store.
|
|
70
|
+
There are two ways to initialize. Pick one.
|
|
104
71
|
|
|
105
|
-
|
|
72
|
+
**Option A — `createRouterFromEnv` (recommended).** Reads `process.env`, validates every value up front, and throws a single `RouterConfigError` with every problem at once. Auto-enables MPP when `MPP_SECRET_KEY` is set, auto-adds a Solana accept when `SOLANA_PAYEE_ADDRESS` is set, auto-enables MPP session mode when `MPP_OPERATOR_KEY` is set.
|
|
106
73
|
|
|
107
|
-
|
|
74
|
+
```typescript
|
|
75
|
+
// lib/router.ts
|
|
76
|
+
import { createRouterFromEnv } from '@agentcash/router';
|
|
77
|
+
|
|
78
|
+
export const router = createRouterFromEnv({
|
|
79
|
+
title: 'My API',
|
|
80
|
+
description: 'Pay-per-call search.',
|
|
81
|
+
guidance: 'POST /search with { q: string }. Returns top 10 results.',
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Option B — build a `RouterConfig` and pass it to `createRouter`.** Use this when you need custom networks, multiple payees, non-standard assets, or any setting `createRouterFromEnv` doesn't expose. `createRouter` runs the same validation against the `RouterConfig` shape.
|
|
108
86
|
|
|
109
87
|
```typescript
|
|
110
|
-
// lib/
|
|
111
|
-
import { createRouter } from '@agentcash/router';
|
|
88
|
+
// lib/router.ts
|
|
89
|
+
import { createRouter, BASE_MAINNET_NETWORK } from '@agentcash/router';
|
|
112
90
|
|
|
113
91
|
export const router = createRouter({
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
92
|
+
baseUrl: 'https://api.example.com',
|
|
93
|
+
payeeAddress: '0x…',
|
|
94
|
+
network: BASE_MAINNET_NETWORK,
|
|
95
|
+
protocols: ['x402'],
|
|
96
|
+
x402: { accepts: [/* … */] },
|
|
117
97
|
discovery: {
|
|
118
98
|
title: 'My API',
|
|
119
99
|
version: '1.0.0',
|
|
120
|
-
description: 'Pay-per-call
|
|
100
|
+
description: 'Pay-per-call search.',
|
|
101
|
+
guidance: 'POST /search with { q: string }. Returns top 10 results.',
|
|
121
102
|
},
|
|
122
103
|
});
|
|
123
104
|
```
|
|
124
105
|
|
|
125
106
|
### 2. Define routes
|
|
126
107
|
|
|
127
|
-
**Paid route (x402)**
|
|
128
|
-
|
|
129
108
|
```typescript
|
|
130
109
|
// app/api/search/route.ts
|
|
131
|
-
import { router } from '@/lib/
|
|
132
|
-
import { searchSchema
|
|
110
|
+
import { router } from '@/lib/router';
|
|
111
|
+
import { searchSchema } from '@/lib/schemas';
|
|
133
112
|
|
|
134
113
|
export const POST = router.route({ path: 'search' })
|
|
135
114
|
.paid('0.01')
|
|
136
115
|
.body(searchSchema)
|
|
137
|
-
.output(searchResponseSchema)
|
|
138
|
-
.description('Search the web')
|
|
139
116
|
.handler(async ({ body }) => search(body));
|
|
140
117
|
```
|
|
141
118
|
|
|
142
|
-
**SIWX-authenticated route**
|
|
143
|
-
|
|
144
119
|
```typescript
|
|
120
|
+
// app/api/inbox/status/route.ts
|
|
145
121
|
export const GET = router.route({ path: 'inbox/status' })
|
|
146
122
|
.siwx()
|
|
147
|
-
.
|
|
148
|
-
.handler(async ({ query, wallet }) => getStatus(query, wallet));
|
|
123
|
+
.handler(async ({ wallet }) => getStatus(wallet));
|
|
149
124
|
```
|
|
150
125
|
|
|
151
|
-
**Unprotected route**
|
|
152
|
-
|
|
153
126
|
```typescript
|
|
127
|
+
// app/api/health/route.ts
|
|
154
128
|
export const GET = router.route({ path: 'health' })
|
|
155
129
|
.unprotected()
|
|
156
130
|
.handler(async () => ({ status: 'ok' }));
|
|
@@ -158,479 +132,117 @@ export const GET = router.route({ path: 'health' })
|
|
|
158
132
|
|
|
159
133
|
### 3. Auto-discovery
|
|
160
134
|
|
|
161
|
-
Discovery metadata (`title`, `version`, `description`, `guidance`) is configured once in `createRouter({ discovery })`. The discovery handlers are zero-arg:
|
|
162
|
-
|
|
163
135
|
```typescript
|
|
164
|
-
// app/.well-known/x402/route.ts
|
|
165
|
-
import { router } from '@/lib/routes';
|
|
166
|
-
import '@/lib/routes/barrel'; // ensures all routes are imported
|
|
167
|
-
export const GET = router.wellKnown();
|
|
168
|
-
|
|
169
136
|
// app/openapi.json/route.ts
|
|
170
|
-
import { router } from '@/lib/
|
|
171
|
-
import '@/lib/routes
|
|
137
|
+
import { router } from '@/lib/router';
|
|
138
|
+
import '@/lib/routes-barrel'; // imports every route module so the registry is populated
|
|
172
139
|
export const GET = router.openapi();
|
|
173
|
-
|
|
174
|
-
// app/llms.txt/route.ts
|
|
175
|
-
import { router } from '@/lib/routes';
|
|
176
|
-
export const GET = router.llmsTxt();
|
|
177
140
|
```
|
|
178
141
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
- Paid signaling via `responses.402` + `x-payment-info`
|
|
182
|
-
- Auth signaling via `security` + `components.securitySchemes`
|
|
183
|
-
- Optional top-level metadata via `x-discovery` (`ownershipProofs`)
|
|
142
|
+
The barrel forces every route module to load before the discovery handler walks the registry — Next.js otherwise lazy-loads route files on first hit, and unloaded routes don't appear in the spec.
|
|
184
143
|
|
|
185
|
-
##
|
|
144
|
+
## Auth modes
|
|
186
145
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
|
192
|
-
|
|
193
|
-
| `payeeAddress` | `string` | — | Wallet address to receive payments |
|
|
194
|
-
| `baseUrl` | `string` | **required** | Service origin used for discovery/OpenAPI/realm |
|
|
195
|
-
| `discovery` | `DiscoveryConfig` | **required** | Title, version, description, guidance for OpenAPI/well-known/llms.txt |
|
|
196
|
-
| `network` | `string` | `'eip155:8453'` | Blockchain network |
|
|
197
|
-
| `plugin` | `RouterPlugin` | `undefined` | Observability plugin |
|
|
198
|
-
| `prices` | `Record<string, string>` | `undefined` | Central pricing map (auto-applied) |
|
|
199
|
-
| `siwx.nonceStore` | `NonceStore` | `MemoryNonceStore` | Custom nonce store |
|
|
200
|
-
| `mpp` | `{ secretKey, currency, recipient?, rpcUrl?, feePayerKey?, useDefaultStore? }` | `undefined` | MPP config |
|
|
201
|
-
| `protocols` | `('x402' \| 'mpp')[]` | `['x402']` | Default protocols for paid routes |
|
|
202
|
-
| `strictRoutes` | `boolean` | `false` | Enforce `route({ path })` and prevent key/path divergence |
|
|
203
|
-
|
|
204
|
-
### Config validation helpers
|
|
146
|
+
| Method | Purpose |
|
|
147
|
+
|--------|---------|
|
|
148
|
+
| `.paid(price)` | Payment required (x402, MPP, or both). |
|
|
149
|
+
| `.siwx()` | Wallet identity, no payment. Returns 402 with a SIWX challenge. |
|
|
150
|
+
| `.apiKey(resolver)` | `X-API-Key` or `Authorization: Bearer <key>`. Composes with `.paid()`. |
|
|
151
|
+
| `.unprotected()` | No auth. |
|
|
205
152
|
|
|
206
153
|
```typescript
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
TEMPO_USDC_CURRENCY,
|
|
211
|
-
getRouterConfigIssues,
|
|
212
|
-
mppFromEnv,
|
|
213
|
-
paidOptionsForProtocols,
|
|
214
|
-
validateRouterConfig,
|
|
215
|
-
x402AcceptsFromEnv,
|
|
216
|
-
} from '@agentcash/router';
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
- `validateRouterConfig(config)` throws `RouterConfigError` with structured
|
|
220
|
-
issues. Use it when you want invalid env/config to fail at startup in every
|
|
221
|
-
environment; `createRouter(config)` still performs its own validation and
|
|
222
|
-
keeps deferred protocol init errors in development for request-time feedback.
|
|
223
|
-
- `getRouterConfigIssues(config)` returns the same structured issues without
|
|
224
|
-
throwing.
|
|
225
|
-
- `x402AcceptsFromEnv(env)` builds Base and optional Solana x402 accepts from
|
|
226
|
-
`X402_WALLET_ADDRESS` and `SOLANA_PAYEE_ADDRESS`.
|
|
227
|
-
- `mppFromEnv(env)` builds MPP config only when MPP env is present, and rejects
|
|
228
|
-
partial MPP env.
|
|
229
|
-
- `paidOptionsForProtocols(protocols)` copies a protocol array into a
|
|
230
|
-
route-level `PaidOptions` object.
|
|
231
|
-
- Manual `.paid(price)` routes inherit `createRouter({ protocols })` unless
|
|
232
|
-
the route passes its own `options.protocols`.
|
|
233
|
-
|
|
234
|
-
### Path-First Routing
|
|
235
|
-
|
|
236
|
-
Use path-first route definitions to keep runtime, OpenAPI, and discovery aligned:
|
|
237
|
-
|
|
238
|
-
```typescript
|
|
239
|
-
router.route({ path: 'flightaware/airports/id/flights/arrivals', method: 'GET' })
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
If you need a custom internal key (legacy pricing map), you can pass:
|
|
154
|
+
router.route({ path: 'admin/users' })
|
|
155
|
+
.apiKey(async (key) => db.admin.findByKey(key)) // null => 401
|
|
156
|
+
.handler(async ({ account }) => db.user.findMany());
|
|
243
157
|
|
|
244
|
-
|
|
245
|
-
|
|
158
|
+
router.route({ path: 'gated' })
|
|
159
|
+
.apiKey(resolver).paid('0.01') // key AND payment
|
|
160
|
+
.handler(fn);
|
|
246
161
|
```
|
|
247
162
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
### Route Builder
|
|
251
|
-
|
|
252
|
-
The fluent builder ensures compile-time safety:
|
|
253
|
-
|
|
254
|
-
- `.paid(price)` / `.paid(fn, { maxPrice })` / `.paid({ field, tiers })` - Payment auth
|
|
255
|
-
- `.siwx()` - SIWX wallet auth
|
|
256
|
-
- `.apiKey(resolver)` - API key auth (composable with `.paid()`)
|
|
257
|
-
- `.unprotected()` - No auth
|
|
258
|
-
- `.body(zodSchema, example?)` - Request body validation with optional discovery example
|
|
259
|
-
- `.query(zodSchema, example?)` - Query parameter validation with optional discovery example
|
|
260
|
-
- `.output(zodSchema, example?)` - Response schema with optional discovery example
|
|
261
|
-
- `.inputExample(sample)` / `.outputExample(sample)` - Optional examples when a separate call reads better
|
|
262
|
-
- `.description(text)` - Route description (for OpenAPI)
|
|
263
|
-
- `.provider(name, config?)` - Provider monitoring (see [Provider Monitoring](#provider-monitoring))
|
|
264
|
-
- `.settlement({ beforeSettle, afterSettle, onSettledHandlerError, onSettlementError })` - Payment lifecycle hooks
|
|
265
|
-
- `.handler(fn)` - Terminal method, returns Next.js handler
|
|
163
|
+
## Pricing
|
|
266
164
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
**Static** - Fixed price for all requests:
|
|
165
|
+
**Static.**
|
|
270
166
|
```typescript
|
|
271
|
-
|
|
167
|
+
.paid('0.02')
|
|
272
168
|
```
|
|
273
169
|
|
|
274
|
-
**
|
|
170
|
+
**Args-driven.**
|
|
275
171
|
```typescript
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
.body(imageGenSchema)
|
|
279
|
-
.handler(async ({ body }) => generate(body));
|
|
172
|
+
.paid((body) => calculateCost(body), { maxPrice: '5.00' })
|
|
173
|
+
.body(genSchema)
|
|
280
174
|
```
|
|
281
175
|
|
|
282
|
-
|
|
283
|
-
```typescript
|
|
284
|
-
router.route('compute')
|
|
285
|
-
.paid((body) => calculateExpensiveOperation(body), { maxPrice: '10.00' })
|
|
286
|
-
.body(computeSchema)
|
|
287
|
-
.handler(async ({ body }) => compute(body));
|
|
288
|
-
```
|
|
176
|
+
`maxPrice` caps the computed amount and acts as a fallback if the pricing function throws. Without `maxPrice`, the route trusts your function fully (no cap, no fallback) and returns 500 on errors.
|
|
289
177
|
|
|
290
|
-
**Tiered
|
|
178
|
+
**Tiered.**
|
|
291
179
|
```typescript
|
|
292
|
-
|
|
180
|
+
.paid({
|
|
293
181
|
field: 'tier',
|
|
294
182
|
tiers: {
|
|
295
183
|
'10mb': { price: '0.02', label: '10 MB' },
|
|
296
184
|
'100mb': { price: '0.20', label: '100 MB' },
|
|
297
185
|
},
|
|
298
|
-
})
|
|
186
|
+
})
|
|
187
|
+
.body(uploadSchema)
|
|
299
188
|
```
|
|
300
189
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
`maxPrice` is **optional** for dynamic pricing and acts as a safety net:
|
|
304
|
-
|
|
305
|
-
1. **Capping**: If `calculateCost(body)` returns `"15.00"` but `maxPrice: "10.00"`, the client is charged `$10.00` (capped) and a warning alert fires.
|
|
306
|
-
|
|
307
|
-
2. **Fallback**: If `calculateCost(body)` throws an error and `maxPrice` is set, the route falls back to `maxPrice` (degraded mode) and an alert fires. Without `maxPrice`, the route returns 500.
|
|
308
|
-
|
|
309
|
-
3. **Trust mode**: No `maxPrice` means full trust in your pricing function (no cap, no fallback).
|
|
310
|
-
|
|
311
|
-
**Best practices:**
|
|
312
|
-
- ✅ Always set `maxPrice` for production routes (safety net)
|
|
313
|
-
- ✅ Use `maxPrice` for routes with external dependencies (pricing APIs)
|
|
314
|
-
- ✅ Monitor alerts for capping events (indicates pricing bug)
|
|
315
|
-
- ⚠️ Skip `maxPrice` only for well-tested, unbounded pricing (e.g., per-GB storage)
|
|
316
|
-
|
|
317
|
-
**Example with safety net:**
|
|
190
|
+
**Handler-driven (request-mode).** Bills exactly `tickCost` per request:
|
|
318
191
|
```typescript
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
// External pricing API (can fail)
|
|
322
|
-
const res = await fetch('https://pricing.example.com/calculate', {
|
|
323
|
-
method: 'POST',
|
|
324
|
-
body: JSON.stringify(body),
|
|
325
|
-
});
|
|
326
|
-
return res.json().price;
|
|
327
|
-
}, { maxPrice: '5.00' }) // Fallback if API is down
|
|
328
|
-
.body(genSchema)
|
|
329
|
-
.handler(async ({ body }) => generate(body));
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
### Dual Protocol (x402 + MPP)
|
|
333
|
-
|
|
334
|
-
```typescript
|
|
335
|
-
router.route('search')
|
|
336
|
-
.paid('0.01', { protocols: ['x402', 'mpp'] })
|
|
337
|
-
.body(schema)
|
|
338
|
-
.handler(fn);
|
|
192
|
+
.paid({ dynamic: true, tickCost: '0.01', unitType: 'request', maxPrice: '0.01' })
|
|
193
|
+
.handler(async ({ body }) => { ... });
|
|
339
194
|
```
|
|
340
195
|
|
|
341
|
-
|
|
342
|
-
|
|
196
|
+
**Streaming (MPP only).** One `charge()` call bills one tick:
|
|
343
197
|
```typescript
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
alert: AlertFn; // Fire observability alerts
|
|
352
|
-
setVerifiedWallet: (addr: string) => void;
|
|
353
|
-
}
|
|
198
|
+
.paid({ dynamic: true, tickCost: '0.0001', unitType: 'token', maxPrice: '0.05', protocols: ['mpp'] })
|
|
199
|
+
.stream(async function* ({ body, charge }) {
|
|
200
|
+
for await (const token of streamLLM(body.prompt)) {
|
|
201
|
+
await charge();
|
|
202
|
+
yield token;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
354
205
|
```
|
|
355
206
|
|
|
356
|
-
|
|
357
|
-
paid requests it includes `protocol`, `status`, `payer`, `amount`, `network`,
|
|
358
|
-
and best-effort recipient/transaction/receipt metadata when the protocol
|
|
359
|
-
provides it. x402 handlers currently see `status: 'verified'` because settlement
|
|
360
|
-
happens after a successful handler response.
|
|
207
|
+
## Pre-payment validation
|
|
361
208
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
For paid routes, use `.settlement()` when final checks belong after handler work
|
|
365
|
-
but before router-controlled settlement:
|
|
209
|
+
For checks that need a DB lookup before quoting a price:
|
|
366
210
|
|
|
367
211
|
```typescript
|
|
368
|
-
router.route('
|
|
369
|
-
.paid('
|
|
370
|
-
.body(
|
|
371
|
-
.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
}
|
|
376
|
-
},
|
|
377
|
-
afterSettle: async ({ payment, result }) => {
|
|
378
|
-
await ledger.record({ tx: payment.transaction, result });
|
|
379
|
-
},
|
|
380
|
-
onSettledHandlerError: async ({ payment, error }) => {
|
|
381
|
-
await compensationQueue.enqueue({ receipt: payment.receipt, error });
|
|
382
|
-
},
|
|
212
|
+
router.route({ path: 'domain/register' })
|
|
213
|
+
.paid(calculatePrice, { maxPrice: '10.00' })
|
|
214
|
+
.body(RegisterSchema)
|
|
215
|
+
.validate(async (body) => {
|
|
216
|
+
if (await isDomainTaken(body.domain)) {
|
|
217
|
+
throw Object.assign(new Error('Domain taken'), { status: 409 });
|
|
218
|
+
}
|
|
383
219
|
})
|
|
384
|
-
.handler(async ({ body }) =>
|
|
220
|
+
.handler(async ({ body, wallet }) => registerDomain(body.domain, wallet));
|
|
385
221
|
```
|
|
386
222
|
|
|
387
|
-
`
|
|
388
|
-
transaction-payload flows. `afterSettle` is for durable ledgers, analytics, and
|
|
389
|
-
post-settlement bookkeeping. `onSettledHandlerError` covers already-settled
|
|
390
|
-
MPP requests whose handler returns an error response, which is the right place
|
|
391
|
-
to enqueue app-owned refund or compensation work. The router cannot generically
|
|
392
|
-
refund protocol payments because it does not hold merchant signing keys.
|
|
223
|
+
Pipeline order: `body parse -> validate -> 402 challenge -> payment -> handler`.
|
|
393
224
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
Pluggable observability. All hooks are optional and fire-and-forget.
|
|
225
|
+
## Plugin Hooks
|
|
397
226
|
|
|
398
227
|
```typescript
|
|
399
|
-
import {
|
|
228
|
+
import { createRouterFromEnv, type RouterPlugin } from '@agentcash/router';
|
|
400
229
|
|
|
401
230
|
const myPlugin: RouterPlugin = {
|
|
402
|
-
onRequest(meta) {
|
|
403
|
-
onPaymentVerified(ctx, payment) {
|
|
404
|
-
onPaymentSettled(ctx, settlement) {
|
|
405
|
-
onResponse(ctx, response) {
|
|
406
|
-
onError(ctx, error) {
|
|
407
|
-
onAlert(ctx, alert) {
|
|
408
|
-
onProviderQuota(ctx, event) { /* ... */ },
|
|
231
|
+
onRequest(meta) {},
|
|
232
|
+
onPaymentVerified(ctx, payment) {},
|
|
233
|
+
onPaymentSettled(ctx, settlement) {},
|
|
234
|
+
onResponse(ctx, response) {},
|
|
235
|
+
onError(ctx, error) {},
|
|
236
|
+
onAlert(ctx, alert) {},
|
|
409
237
|
};
|
|
410
238
|
|
|
411
|
-
export const router =
|
|
412
|
-
|
|
413
|
-
|
|
239
|
+
export const router = createRouterFromEnv({
|
|
240
|
+
title: 'My API',
|
|
241
|
+
description: '…',
|
|
242
|
+
guidance: '…',
|
|
414
243
|
plugin: myPlugin,
|
|
415
|
-
discovery: {
|
|
416
|
-
title: 'My API',
|
|
417
|
-
version: '1.0.0',
|
|
418
|
-
},
|
|
419
244
|
});
|
|
420
245
|
```
|
|
421
246
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
```typescript
|
|
425
|
-
import { createRouter, consolePlugin } from '@agentcash/router';
|
|
426
|
-
|
|
427
|
-
export const router = createRouter({
|
|
428
|
-
payeeAddress: process.env.X402_WALLET_ADDRESS!,
|
|
429
|
-
baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
|
|
430
|
-
plugin: consolePlugin(),
|
|
431
|
-
discovery: {
|
|
432
|
-
title: 'My API',
|
|
433
|
-
version: '1.0.0',
|
|
434
|
-
},
|
|
435
|
-
});
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
### Central Pricing Map
|
|
439
|
-
|
|
440
|
-
For services with many static-priced routes:
|
|
441
|
-
|
|
442
|
-
```typescript
|
|
443
|
-
const router = createRouter({
|
|
444
|
-
payeeAddress: process.env.X402_WALLET_ADDRESS!,
|
|
445
|
-
baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
|
|
446
|
-
discovery: {
|
|
447
|
-
title: 'My API',
|
|
448
|
-
version: '1.0.0',
|
|
449
|
-
},
|
|
450
|
-
prices: {
|
|
451
|
-
'search': '0.02',
|
|
452
|
-
'lookup': '0.05',
|
|
453
|
-
},
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
// Price auto-applied, no .paid() needed
|
|
457
|
-
export const POST = router.route('search')
|
|
458
|
-
.body(schema)
|
|
459
|
-
.handler(fn);
|
|
460
|
-
```
|
|
461
|
-
|
|
462
|
-
### Provider Monitoring
|
|
463
|
-
|
|
464
|
-
Routes that wrap third-party APIs can declare monitoring behavior per-provider. This surfaces quota/balance information through the plugin system and registers cron-checkable monitors.
|
|
465
|
-
|
|
466
|
-
#### Why
|
|
467
|
-
|
|
468
|
-
Upstream providers report remaining quota in different ways:
|
|
469
|
-
|
|
470
|
-
| Pattern | Example | How detected |
|
|
471
|
-
|---------|---------|-------------|
|
|
472
|
-
| Balance in response headers | `X-RateLimit-Remaining: 482` | `extractQuota` reads headers |
|
|
473
|
-
| Balance in response body | `{ rateLimit: { remaining: 50 } }` | `extractQuota` reads result |
|
|
474
|
-
| Separate health-check endpoint | Apollo `/credits` endpoint | `monitor` function (cron) |
|
|
475
|
-
| Overages auto-charged at same rate | Exa, Firecrawl | `overage: 'same-rate'` |
|
|
476
|
-
| Overages at increased rate | Some SaaS APIs | `overage: 'increased-rate'` |
|
|
477
|
-
| No overages, immediate stoppage | Whitepages | `overage: 'hard-stop'` |
|
|
478
|
-
|
|
479
|
-
The `.provider()` method handles all six patterns through a single interface.
|
|
480
|
-
|
|
481
|
-
#### Basic usage
|
|
482
|
-
|
|
483
|
-
```typescript
|
|
484
|
-
export const POST = router.route('search')
|
|
485
|
-
.paid('0.01')
|
|
486
|
-
.provider('exa', {
|
|
487
|
-
extractQuota: (result, headers) => ({
|
|
488
|
-
remaining: (result as any).rateLimit?.remaining ?? null,
|
|
489
|
-
limit: (result as any).rateLimit?.limit ?? null,
|
|
490
|
-
}),
|
|
491
|
-
warn: 100,
|
|
492
|
-
critical: 10,
|
|
493
|
-
})
|
|
494
|
-
.body(searchSchema)
|
|
495
|
-
.handler(async ({ body }) => exaClient.search(body));
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
After every successful handler response, `extractQuota` runs with the raw handler result and the response headers. The router computes a level (`healthy`, `warn`, `critical`) based on thresholds and fires `onProviderQuota` on the plugin.
|
|
499
|
-
|
|
500
|
-
#### ProviderConfig
|
|
501
|
-
|
|
502
|
-
| Field | Type | Default | Description |
|
|
503
|
-
|-------|------|---------|-------------|
|
|
504
|
-
| `extractQuota` | `(result, headers) => QuotaInfo \| null` | — | Inline quota extraction after each request |
|
|
505
|
-
| `monitor` | `() => Promise<QuotaInfo \| null>` | — | Standalone health check (for cron) |
|
|
506
|
-
| `overage` | `'same-rate' \| 'increased-rate' \| 'hard-stop'` | `'same-rate'` | What happens when quota hits zero |
|
|
507
|
-
| `warn` | `number` | — | Fire `warn` level when remaining <= this |
|
|
508
|
-
| `critical` | `number` | — | Fire `critical` level when remaining <= this |
|
|
509
|
-
|
|
510
|
-
#### QuotaInfo
|
|
511
|
-
|
|
512
|
-
```typescript
|
|
513
|
-
interface QuotaInfo {
|
|
514
|
-
remaining: number | null; // Credits/calls remaining
|
|
515
|
-
limit: number | null; // Total quota (null if unknown)
|
|
516
|
-
spend?: number; // Credits consumed this request
|
|
517
|
-
}
|
|
518
|
-
```
|
|
519
|
-
|
|
520
|
-
#### Threshold logic
|
|
521
|
-
|
|
522
|
-
| Condition | Level |
|
|
523
|
-
|-----------|-------|
|
|
524
|
-
| `remaining === null` | `healthy` (no data to compare) |
|
|
525
|
-
| `remaining <= critical` | `critical` |
|
|
526
|
-
| `remaining <= warn` | `warn` |
|
|
527
|
-
| Otherwise | `healthy` |
|
|
528
|
-
|
|
529
|
-
#### Plugin hook
|
|
530
|
-
|
|
531
|
-
```typescript
|
|
532
|
-
interface ProviderQuotaEvent {
|
|
533
|
-
provider: string; // Provider name from .provider()
|
|
534
|
-
route: string; // Route key
|
|
535
|
-
remaining: number | null;
|
|
536
|
-
limit: number | null;
|
|
537
|
-
spend?: number;
|
|
538
|
-
level: 'healthy' | 'warn' | 'critical';
|
|
539
|
-
overage: 'same-rate' | 'increased-rate' | 'hard-stop';
|
|
540
|
-
message: string; // Human-readable summary
|
|
541
|
-
}
|
|
542
|
-
```
|
|
543
|
-
|
|
544
|
-
Handle in your plugin:
|
|
545
|
-
|
|
546
|
-
```typescript
|
|
547
|
-
const myPlugin: RouterPlugin = {
|
|
548
|
-
onProviderQuota(ctx, event) {
|
|
549
|
-
if (event.level === 'critical') {
|
|
550
|
-
discord.alert(`${event.provider}: ${event.remaining} remaining`);
|
|
551
|
-
}
|
|
552
|
-
clickhouse.insert('provider_quota', event);
|
|
553
|
-
},
|
|
554
|
-
};
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
#### Cron monitors
|
|
558
|
-
|
|
559
|
-
For providers that require a separate API call to check balance (not available inline in response), register a `monitor` function:
|
|
560
|
-
|
|
561
|
-
```typescript
|
|
562
|
-
export const POST = router.route('people/search')
|
|
563
|
-
.paid('0.05')
|
|
564
|
-
.provider('apollo', {
|
|
565
|
-
monitor: async () => {
|
|
566
|
-
const res = await fetch('https://api.apollo.io/v1/credits', {
|
|
567
|
-
headers: { 'X-Api-Key': process.env.APOLLO_KEY! },
|
|
568
|
-
});
|
|
569
|
-
const data = await res.json();
|
|
570
|
-
return { remaining: data.credits, limit: null };
|
|
571
|
-
},
|
|
572
|
-
overage: 'hard-stop',
|
|
573
|
-
warn: 500,
|
|
574
|
-
critical: 50,
|
|
575
|
-
})
|
|
576
|
-
.body(searchSchema)
|
|
577
|
-
.handler(fn);
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
Retrieve all registered monitors via `router.monitors()`:
|
|
581
|
-
|
|
582
|
-
```typescript
|
|
583
|
-
// cron.ts — run every 5 minutes
|
|
584
|
-
import { router } from '@/lib/routes';
|
|
585
|
-
import '@/lib/routes/barrel';
|
|
586
|
-
|
|
587
|
-
for (const entry of router.monitors()) {
|
|
588
|
-
const quota = await entry.monitor();
|
|
589
|
-
if (!quota) continue;
|
|
590
|
-
|
|
591
|
-
const level = quota.remaining !== null && quota.remaining <= (entry.critical ?? 0)
|
|
592
|
-
? 'critical'
|
|
593
|
-
: quota.remaining !== null && quota.remaining <= (entry.warn ?? 0)
|
|
594
|
-
? 'warn'
|
|
595
|
-
: 'healthy';
|
|
596
|
-
|
|
597
|
-
if (level !== 'healthy') {
|
|
598
|
-
alert(`${entry.provider} (${entry.route}): ${quota.remaining} remaining [${level}]`);
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
```
|
|
602
|
-
|
|
603
|
-
`monitors()` returns:
|
|
604
|
-
|
|
605
|
-
```typescript
|
|
606
|
-
interface MonitorEntry {
|
|
607
|
-
provider: string;
|
|
608
|
-
route: string;
|
|
609
|
-
monitor: () => Promise<QuotaInfo | null>;
|
|
610
|
-
overage: OveragePolicy;
|
|
611
|
-
warn?: number;
|
|
612
|
-
critical?: number;
|
|
613
|
-
}
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
#### Provider name only
|
|
617
|
-
|
|
618
|
-
If you just want to tag a route with its provider for logging/tracing, pass only the name:
|
|
619
|
-
|
|
620
|
-
```typescript
|
|
621
|
-
router.route('health')
|
|
622
|
-
.unprotected()
|
|
623
|
-
.provider('internal')
|
|
624
|
-
.handler(async () => ({ status: 'ok' }));
|
|
625
|
-
```
|
|
626
|
-
|
|
627
|
-
#### Safety guarantees
|
|
628
|
-
|
|
629
|
-
- `extractQuota` runs fire-and-forget — exceptions are caught and swallowed
|
|
630
|
-
- `extractQuota` only runs when `response.status < 400` (no quota extraction on errors)
|
|
631
|
-
- The plugin hook is non-blocking — it never delays the response to the caller
|
|
632
|
-
- Missing thresholds are fine — without `warn`/`critical`, level is always `healthy`
|
|
633
|
-
|
|
634
|
-
## License
|
|
247
|
+
All hooks are optional and fire-and-forget; they never delay the response. Use hooks to add additional telemetry or flexibility to your resource's lifecycle.
|
|
635
248
|
|
|
636
|
-
MIT
|