agent402-client 0.8.1 → 0.8.3
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/README.md +87 -15
- package/index.js +149 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,8 +5,10 @@ instance) - the buy side of [Agentic Finance](https://agent402.tools/agentic-fin
|
|
|
5
5
|
agents paying per request over x402 or MPP. **Resolve a task to a tool, then call it - with payment handled for
|
|
6
6
|
you.** Free pure-CPU tools settle with a built-in proof-of-work (no wallet, zero
|
|
7
7
|
dependencies); wallet-only tools settle via an x402- or MPP-wrapped fetch you
|
|
8
|
-
provide, or by card through a prepaid credits key. Results are cached, and
|
|
9
|
-
|
|
8
|
+
provide, or by card through a prepaid credits key. Results are cached, and every
|
|
9
|
+
send carries an `Idempotency-Key` that is stable per client and per operation, so
|
|
10
|
+
a retried lost response replays the paid answer on the credits and proof-of-work
|
|
11
|
+
paths (the wallet path is different: see "Retries and double charges").
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
14
|
npm install agent402-client
|
|
@@ -89,14 +91,32 @@ credits call reserves its price like a wallet call). A payment `fetch` still win
|
|
|
89
91
|
when both are given, and free pure-CPU tools keep settling with proof-of-work.
|
|
90
92
|
The same Bearer header also reads the balance directly, e.g. `curl -H "Authorization: Bearer a402_..." https://agent402.tools/api/credits/balance`.
|
|
91
93
|
|
|
92
|
-
## Retries
|
|
93
|
-
|
|
94
|
-
Every
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
## Retries and double charges
|
|
95
|
+
|
|
96
|
+
Every send carries an `Idempotency-Key`. By default it is derived from the
|
|
97
|
+
client instance plus the operation (slug, params, output validator), so it is
|
|
98
|
+
the same key when the SDK retries inside one `call()` AND when your framework
|
|
99
|
+
calls `client.call(slug, params)` again after a timeout or a dropped connection.
|
|
100
|
+
With `{ cache: false }` on the call, identical calls are treated as distinct
|
|
101
|
+
purchases and the key is fresh per invocation. Pass `idempotencyKey` yourself to
|
|
102
|
+
override either behavior. Keys are scoped to the client instance: two processes
|
|
103
|
+
buying the same thing are two purchases.
|
|
104
|
+
|
|
105
|
+
What the server does with it depends on how the call was paid:
|
|
106
|
+
|
|
107
|
+
- **Prepaid credits and proof-of-work:** the server binds the key to the credits
|
|
108
|
+
key (or the accepted solution) plus the route and body, and replays the
|
|
109
|
+
original 200 for ten minutes. A retry of a lost response is not debited again.
|
|
110
|
+
- **x402 wallet:** the server binds the key to the exact signed authorization.
|
|
111
|
+
An exact retry (same signed header, same key) replays without a second
|
|
112
|
+
settlement, and that is what the `payment-identifier` extension gives a stock
|
|
113
|
+
x402 client. A fresh `call()` through an `@x402/fetch`-wrapped fetch signs a
|
|
114
|
+
fresh authorization, which the server deliberately treats as a new payment
|
|
115
|
+
(a client-chosen id on an unverified payload is never a cross-authorization
|
|
116
|
+
dedupe). So on the wallet path a retry after a lost response can settle twice;
|
|
117
|
+
the `Idempotency-Key` cannot prevent that on its own. If that matters for your
|
|
118
|
+
workload, use a credits key for the tools you retry, or keep the signed
|
|
119
|
+
request and resend it yourself.
|
|
100
120
|
|
|
101
121
|
## Workflows (skill packs)
|
|
102
122
|
|
|
@@ -165,13 +185,13 @@ await a.topSellers({ sort: "calls", include: "all" });
|
|
|
165
185
|
|
|
166
186
|
| Method | What |
|
|
167
187
|
|---|---|
|
|
168
|
-
| `new Agent402({ baseUrl?, fetch?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd?, maxResponseBytes? })` | `fetch` is your x402- or MPP-wrapped fetch for paid tools (optional); `creditsKey` is a prepaid card-credits key (`a402_...`) used for paid tools when no `fetch` is given; `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below); `maxResponseBytes` (default 32MB, `null` to disable) refuses an oversized response body before it is parsed |
|
|
188
|
+
| `new Agent402({ baseUrl?, fetch?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd?, maxResponseBytes?, outputValidator? })` | `fetch` is your x402- or MPP-wrapped fetch for paid tools (optional); `creditsKey` is a prepaid card-credits key (`a402_...`) used for paid tools when no `fetch` is given; `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below); `maxResponseBytes` (default 32MB, `null` to disable) refuses an oversized response body before it is parsed; `outputValidator` binds a caller-supplied delivery check |
|
|
169
189
|
| `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools on **this host** (route, price, schema, example) |
|
|
170
190
|
| `await a.route(task, { k?, include?, network? })` | Cross-seller Smart Order Router: rank eligible/routable seller rows from the host's current index (free, read-only; opaque results may include server-reported `executeVia`) |
|
|
171
191
|
| `await a.findWorkflows(task, { k = 2 })` | Resolve a task to matching multi-tool workflow templates (skill packs) |
|
|
172
192
|
| `await a.getWorkflowPrompt(slug, args)` | Fetch the rendered prompt messages for a skill pack with arguments substituted in |
|
|
173
193
|
| `await a.topSellers({ limit?, sort?, include? })` | Live x402 leaderboard: which sellers are settling the most USDC (primarily on Base) in the last ~24h (free, no payment) |
|
|
174
|
-
| `await a.call(slug, params, { idempotencyKey?, cache?, maxResponseBytes? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); returns the JSON result |
|
|
194
|
+
| `await a.call(slug, params, { idempotencyKey?, cache?, maxResponseBytes?, outputValidator? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); returns the JSON result |
|
|
175
195
|
| `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
|
|
176
196
|
| `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
|
|
177
197
|
| `a.clearCache()` | Drop the in-memory result cache |
|
|
@@ -196,6 +216,56 @@ An oversized body throws `ResponseTooLargeError` carrying `size`, `cap`,
|
|
|
196
216
|
`source` and `paid`. Check `paid`: on a wallet-only tool the money moved before
|
|
197
217
|
the body arrived, so a refused response is still a spend you made.
|
|
198
218
|
|
|
219
|
+
## Buyer-owned output validation
|
|
220
|
+
|
|
221
|
+
A successful payment and HTTP 200 do not prove that the delivered result is
|
|
222
|
+
useful. Supply a stable contract id and a local callback to enforce the exact
|
|
223
|
+
output your agent needs:
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
const a = new Agent402({
|
|
227
|
+
fetch: payFetch,
|
|
228
|
+
outputValidator: {
|
|
229
|
+
id: "unemployment-result/v1",
|
|
230
|
+
validate: (body) =>
|
|
231
|
+
typeof body.current === "number" &&
|
|
232
|
+
Array.isArray(body.history) &&
|
|
233
|
+
typeof body.source === "string",
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`validate` may return `false` or throw to reject delivery, and may be async.
|
|
239
|
+
Use any local validator you prefer: Ajv, Zod, agent-payment-policy, or ordinary
|
|
240
|
+
application code. The client adds no dependency and never sends the validator
|
|
241
|
+
or its id to the seller.
|
|
242
|
+
|
|
243
|
+
Validation runs after bounded JSON parsing and before cache admission. A
|
|
244
|
+
rejected paid response throws `OutputValidationError` with `paid: true`, stays
|
|
245
|
+
in `spendingSummary()`, and is never cached. Contracted cache entries are
|
|
246
|
+
namespaced by a SHA-256 digest of `id` and revalidated on every hit, so a
|
|
247
|
+
different contract or a caller-mutated cached object cannot bypass the check.
|
|
248
|
+
The caller owns the meaning and stability of `id`; changing validation
|
|
249
|
+
semantics requires a new id. A per-call validator may replace the constructor
|
|
250
|
+
validator, but `null` or omission preserves the constructor policy rather than
|
|
251
|
+
silently weakening it. Use a separate client when a route intentionally has no
|
|
252
|
+
output contract.
|
|
253
|
+
|
|
254
|
+
**Two costs worth knowing.** `validate` is awaited inside `call()`, so it is
|
|
255
|
+
bounded: a validator that has not settled in **5 seconds** rejects delivery with
|
|
256
|
+
`OutputValidationError` rather than hanging the call. On a paid route the money
|
|
257
|
+
has already moved by then, so an indefinite hang would be the worst place to
|
|
258
|
+
have one; a timeout is a rejection, because an unfinished contract is not a
|
|
259
|
+
satisfied one. Override per validator with `timeoutMs`:
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
outputValidator: { id: "big-schema/v1", validate: slowCheck, timeoutMs: 30_000 }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
And because contracted cache entries are revalidated on **every** hit, an
|
|
266
|
+
expensive validator is paid for on cache hits too, not just on the network call.
|
|
267
|
+
Keep it cheap, or accept that cost knowingly.
|
|
268
|
+
|
|
199
269
|
## Spending caps (never overpay)
|
|
200
270
|
|
|
201
271
|
By default the client pays whatever a tool costs. Set optional hard ceilings and a
|
|
@@ -219,9 +289,11 @@ try {
|
|
|
219
289
|
}
|
|
220
290
|
```
|
|
221
291
|
|
|
222
|
-
Only **settled** paid calls count against the rolling window
|
|
223
|
-
|
|
224
|
-
|
|
292
|
+
Only **settled** paid calls count against the rolling window. A call refused or
|
|
293
|
+
failed before settlement never consumes budget. A paid HTTP-success response
|
|
294
|
+
that later fails byte, JSON, or output validation remains settled spend. Free
|
|
295
|
+
proof-of-work calls are never counted. Omit a cap (or leave it `null`) for no
|
|
296
|
+
limit; with none set, behavior is unchanged.
|
|
225
297
|
|
|
226
298
|
**What the caps check.** When a cap is set, the client preflights the `402` and
|
|
227
299
|
checks the ceiling against the **larger** of the advertised price (from the
|
package/index.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
// instance). Resolve a task to a tool, then call it with payment handled for you:
|
|
3
3
|
// - free pure-CPU tools settle with a built-in proof-of-work (no wallet, zero deps),
|
|
4
4
|
// - wallet-only tools settle via an x402-wrapped fetch you provide (@x402/fetch),
|
|
5
|
-
// results are cached (tools are deterministic), and
|
|
6
|
-
// Idempotency-Key
|
|
5
|
+
// results are cached (tools are deterministic), and every send carries an
|
|
6
|
+
// Idempotency-Key that is stable per client instance and per operation while
|
|
7
|
+
// caching is on, so a retry of a lost response (inside call() or by the caller
|
|
8
|
+
// invoking call() again) replays the paid answer on the credits and PoW paths
|
|
9
|
+
// instead of paying twice. A wallet buyer's fresh call() signs a fresh
|
|
10
|
+
// authorization, which the server treats as a new payment by design - see
|
|
11
|
+
// README "Retries and double charges".
|
|
7
12
|
//
|
|
8
13
|
// import { Agent402 } from "agent402-client";
|
|
9
14
|
// const a = new Agent402(); // free tier, proof-of-work
|
|
@@ -12,13 +17,13 @@
|
|
|
12
17
|
//
|
|
13
18
|
// // paid tools: pass an x402-wrapped fetch (your wallet signs)
|
|
14
19
|
// const a = new Agent402({ fetch: payFetch });
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
20
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
16
21
|
|
|
17
22
|
// Keep in lockstep with package.json. Every request the SDK issues carries
|
|
18
23
|
// `User-Agent: agent402-client/<version>` - a standard header, no extra
|
|
19
24
|
// network calls - so a seller can attribute traffic (and settled payments)
|
|
20
25
|
// to this SDK. Product token only; nothing about the caller rides along.
|
|
21
|
-
const VERSION = "0.
|
|
26
|
+
const VERSION = "0.8.3";
|
|
22
27
|
const USER_AGENT = `agent402-client/${VERSION}`;
|
|
23
28
|
// 32MB: about a hundred times any realistic response from this catalog (the
|
|
24
29
|
// largest is a base64 image at a few MB), so it cannot break a legitimate
|
|
@@ -42,10 +47,13 @@ export class Agent402 {
|
|
|
42
47
|
* pays wallet-only tools by card when no payFetch is given
|
|
43
48
|
* @param {number|null} [opts.maxResponseBytes=33554432]
|
|
44
49
|
* Hard ceiling on a response body, enforced BEFORE it is parsed. null disables it.
|
|
50
|
+
* @param {{id:string, validate:function}} [opts.outputValidator]
|
|
51
|
+
* Optional buyer-owned result validator. `id` namespaces cache entries;
|
|
52
|
+
* `validate` may return false or throw to reject delivery.
|
|
45
53
|
*/
|
|
46
54
|
constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = globalThis.fetch,
|
|
47
55
|
maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null,
|
|
48
|
-
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES } = {}) {
|
|
56
|
+
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null } = {}) {
|
|
49
57
|
this.creditsKey = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{32,64}$/.test(creditsKey) ? creditsKey : null;
|
|
50
58
|
if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
|
|
51
59
|
this.baseUrl = String(baseUrl).replace(/\/$/, "");
|
|
@@ -55,6 +63,14 @@ export class Agent402 {
|
|
|
55
63
|
this.f = (url, init = {}) => fetchImpl(url, { ...init, headers: { "User-Agent": USER_AGENT, ...(init.headers || {}) } });
|
|
56
64
|
this._catalog = null;
|
|
57
65
|
this._cache = cache ? new Map() : null;
|
|
66
|
+
// Salt for the default Idempotency-Key: one per client instance, so the key
|
|
67
|
+
// for (slug, params) is stable across call() invocations on THIS client
|
|
68
|
+
// (an agent framework retrying a lost response gets the replay) and still
|
|
69
|
+
// distinct between sessions (two processes buying the same thing are two
|
|
70
|
+
// purchases). Issue #1126: the old default mixed Date.now() + Math.random()
|
|
71
|
+
// per invocation, which protected retries inside one call() and nothing
|
|
72
|
+
// the header promised beyond it.
|
|
73
|
+
this._idemSalt = randomBytes(12).toString("hex");
|
|
58
74
|
// Spending policy (defends the x402 "wallet drain via uncapped spending"
|
|
59
75
|
// failure mode): optional hard ceilings enforced BEFORE any payment is
|
|
60
76
|
// signed. A malicious or misconfigured 402 that quotes an inflated price is
|
|
@@ -69,6 +85,9 @@ export class Agent402 {
|
|
|
69
85
|
};
|
|
70
86
|
// A response-size ceiling, enforced before parsing. See _readJson.
|
|
71
87
|
this.maxResponseBytes = maxResponseBytes === null ? null : (numOrNull(maxResponseBytes) ?? DEFAULT_MAX_RESPONSE_BYTES);
|
|
88
|
+
// Buyer-owned delivery policy. The callback stays entirely local and may
|
|
89
|
+
// use Ajv, Zod, agent-payment-policy, or ordinary application code.
|
|
90
|
+
this.outputValidator = normalizeOutputValidator(outputValidator, "constructor");
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
/**
|
|
@@ -266,15 +285,41 @@ export class Agent402 {
|
|
|
266
285
|
* Call a tool by slug; pays automatically (PoW for free tools, x402 for
|
|
267
286
|
* wallet-only) and returns the parsed JSON result.
|
|
268
287
|
*/
|
|
269
|
-
async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes } = {}) {
|
|
288
|
+
async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes, outputValidator } = {}) {
|
|
289
|
+
// Resolve and validate the buyer-owned contract before catalog fetch,
|
|
290
|
+
// credential access, payment preflight, or any other network action.
|
|
291
|
+
const validator = outputValidator == null
|
|
292
|
+
? this.outputValidator
|
|
293
|
+
: normalizeOutputValidator(outputValidator, "call");
|
|
270
294
|
const cat = await this._loadCatalog();
|
|
271
295
|
const tool = cat.get(slug);
|
|
272
296
|
if (!tool) throw new Error(`unknown tool "${slug}" - use client.find(task) to discover one`);
|
|
273
297
|
|
|
274
|
-
const cacheKey =
|
|
275
|
-
if (this._cache && cache && this._cache.has(cacheKey))
|
|
298
|
+
const cacheKey = resultCacheKey(slug, params, validator);
|
|
299
|
+
if (this._cache && cache && this._cache.has(cacheKey)) {
|
|
300
|
+
const stored = this._cache.get(cacheKey);
|
|
301
|
+
// Callers receive the stored object by reference and may mutate it.
|
|
302
|
+
// Revalidate contracted hits so a now-invalid object cannot pass merely
|
|
303
|
+
// because it was valid before the caller changed it.
|
|
304
|
+
try {
|
|
305
|
+
await this._assertOutput(slug, stored, validator, { paid: false, cacheHit: true });
|
|
306
|
+
} catch (error) {
|
|
307
|
+
this._cache.delete(cacheKey);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
return stored;
|
|
311
|
+
}
|
|
276
312
|
|
|
277
|
-
|
|
313
|
+
// Default key = stable per (client instance, slug, params, validator) while
|
|
314
|
+
// caching is on: the SDK already treats that tuple as one operation (a
|
|
315
|
+
// second identical call is served from cache without payment), so the
|
|
316
|
+
// idempotency key agrees with the cache key instead of contradicting it.
|
|
317
|
+
// With { cache: false } the caller has said identical calls are distinct
|
|
318
|
+
// purchases, so the key is fresh per invocation, as before. An explicit
|
|
319
|
+
// idempotencyKey always wins.
|
|
320
|
+
const idem = idempotencyKey || (cache
|
|
321
|
+
? `a402-${createHash("sha256").update(`${this._idemSalt}:${cacheKey}`).digest("hex").slice(0, 24)}`
|
|
322
|
+
: `a402-${createHash("sha256").update(`${cacheKey}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`);
|
|
278
323
|
const send = (extraHeaders = {}, useFetch = this.f) => {
|
|
279
324
|
// UA set here too (not only in the this.f wrapper) so the x402 payFetch
|
|
280
325
|
// path - the one that settles real payments - always carries it.
|
|
@@ -317,13 +362,17 @@ export class Agent402 {
|
|
|
317
362
|
// can't each observe the pre-commit total and collectively blow a rolling
|
|
318
363
|
// cap; release the reservation if the call doesn't settle.
|
|
319
364
|
const reservation = this._spendReserve(host, usd, slug);
|
|
365
|
+
let settled = false;
|
|
320
366
|
try {
|
|
321
367
|
const r = await send({}, this.payFetch);
|
|
322
368
|
if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
|
|
323
369
|
this._spendSettle(reservation); // confirm the reservation as settled spend
|
|
324
|
-
|
|
370
|
+
settled = true;
|
|
371
|
+
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
325
372
|
} catch (e) {
|
|
326
|
-
|
|
373
|
+
// An invalid or oversized paid HTTP-success body is failed delivery,
|
|
374
|
+
// not a rollback of money that already moved.
|
|
375
|
+
if (!settled) this._spendRelease(reservation);
|
|
327
376
|
throw e;
|
|
328
377
|
}
|
|
329
378
|
}
|
|
@@ -334,6 +383,7 @@ export class Agent402 {
|
|
|
334
383
|
const host = hostOf(this.baseUrl);
|
|
335
384
|
const usd = parseUsd(tool.price);
|
|
336
385
|
const reservation = this._spendReserve(host, usd, slug);
|
|
386
|
+
let settled = false;
|
|
337
387
|
try {
|
|
338
388
|
const r = await send({ Authorization: `Bearer ${this.creditsKey}` });
|
|
339
389
|
if (r.status === 402) {
|
|
@@ -342,11 +392,12 @@ export class Agent402 {
|
|
|
342
392
|
}
|
|
343
393
|
if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
|
|
344
394
|
this._spendSettle(reservation);
|
|
345
|
-
|
|
346
|
-
|
|
395
|
+
settled = true;
|
|
396
|
+
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
397
|
+
} catch (e) { if (!settled) this._spendRelease(reservation); throw e; }
|
|
347
398
|
}
|
|
348
399
|
const r = await send(); // no wallet - succeeds only on a FREE_MODE instance
|
|
349
|
-
if (r.ok) return this.
|
|
400
|
+
if (r.ok) return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
|
|
350
401
|
throw new Error(`call "${slug}" failed: HTTP ${r.status} - wallet-only tool; construct with { fetch: payFetch } (an @x402/fetch-wrapped fetch) or { creditsKey } (prepaid card credits from ${this.baseUrl}/credits)`);
|
|
351
402
|
}
|
|
352
403
|
|
|
@@ -359,7 +410,30 @@ export class Agent402 {
|
|
|
359
410
|
r = await send({ "X-Pow-Solution": Agent402.solvePow(chal) });
|
|
360
411
|
}
|
|
361
412
|
if (!r.ok) throw new Error(`call "${slug}" failed after proof-of-work: HTTP ${r.status}`);
|
|
362
|
-
return this.
|
|
413
|
+
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async _assertOutput(slug, body, validator, { paid = false, cacheHit = false } = {}) {
|
|
417
|
+
if (!validator) return body;
|
|
418
|
+
try {
|
|
419
|
+
// BOUNDED. The validator is caller-owned code we await inside call(), so
|
|
420
|
+
// one that never settles hangs the buyer's call forever - and on a paid
|
|
421
|
+
// route the money has already moved by the time it runs, which makes an
|
|
422
|
+
// indefinite hang the worst possible place to have one. A timeout is a
|
|
423
|
+
// REJECTION, not a pass: an unfinished contract has not been satisfied.
|
|
424
|
+
const result = await withValidatorTimeout(validator.validate(body), validator.timeoutMs ?? OUTPUT_VALIDATOR_TIMEOUT_MS, validator.id);
|
|
425
|
+
if (result === false) throw new Error("validator returned false");
|
|
426
|
+
return body;
|
|
427
|
+
} catch (cause) {
|
|
428
|
+
throw new OutputValidationError(`output for "${slug}" failed buyer validator "${validator.id}"${paid ? "; this call WAS paid" : ""}`,
|
|
429
|
+
{ slug, contractId: validator.id, paid, cacheHit, cause });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async _deliverResponse(slug, response, cacheKey, cache, validator, { maxResponseBytes, paid } = {}) {
|
|
434
|
+
const body = await this._readJson(response, { maxBytes: maxResponseBytes, slug, paid });
|
|
435
|
+
await this._assertOutput(slug, body, validator, { paid });
|
|
436
|
+
return this._store(cacheKey, body, cache);
|
|
363
437
|
}
|
|
364
438
|
|
|
365
439
|
async _powChallenge(slug) {
|
|
@@ -458,6 +532,16 @@ export class ResponseTooLargeError extends Error {
|
|
|
458
532
|
}
|
|
459
533
|
}
|
|
460
534
|
|
|
535
|
+
/** A buyer-owned validator rejected the delivered body. `paid` records whether
|
|
536
|
+
* this invocation had already settled before validation ran. */
|
|
537
|
+
export class OutputValidationError extends Error {
|
|
538
|
+
constructor(message, { slug, contractId, paid, cacheHit, cause } = {}) {
|
|
539
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
540
|
+
this.name = "OutputValidationError";
|
|
541
|
+
Object.assign(this, { slug, contractId, paid: Boolean(paid), cacheHit: Boolean(cacheHit) });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
461
545
|
export class SpendingLimitError extends Error {
|
|
462
546
|
constructor(message, details = {}) {
|
|
463
547
|
super(message);
|
|
@@ -493,6 +577,56 @@ function parseUsd(price) {
|
|
|
493
577
|
}
|
|
494
578
|
function hostOf(url) { try { return new URL(url).host; } catch { return String(url); } }
|
|
495
579
|
|
|
580
|
+
const OUTPUT_VALIDATOR_ID_MAX_CHARS = 256;
|
|
581
|
+
// How long a buyer-owned validator may take before delivery is refused.
|
|
582
|
+
const OUTPUT_VALIDATOR_TIMEOUT_MS = 5_000;
|
|
583
|
+
|
|
584
|
+
function normalizeOutputValidator(value, where) {
|
|
585
|
+
if (value == null) return null;
|
|
586
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
587
|
+
throw new TypeError(`${where} outputValidator must be null or { id, validate }`);
|
|
588
|
+
}
|
|
589
|
+
const id = value.id;
|
|
590
|
+
if (typeof id !== "string" || id.trim() !== id || id.length < 1 || id.length > OUTPUT_VALIDATOR_ID_MAX_CHARS) {
|
|
591
|
+
throw new TypeError(`${where} outputValidator.id must be a nonempty trimmed string of at most ${OUTPUT_VALIDATOR_ID_MAX_CHARS} characters`);
|
|
592
|
+
}
|
|
593
|
+
if (typeof value.validate !== "function") {
|
|
594
|
+
throw new TypeError(`${where} outputValidator.validate must be a function`);
|
|
595
|
+
}
|
|
596
|
+
// Opt-in per-validator override; anything unusable falls back to the default
|
|
597
|
+
// rather than silently disabling the bound.
|
|
598
|
+
const t = Number(value.timeoutMs);
|
|
599
|
+
const timeoutMs = Number.isFinite(t) && t > 0 ? t : OUTPUT_VALIDATOR_TIMEOUT_MS;
|
|
600
|
+
return Object.freeze({ id, validate: value.validate, timeoutMs });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Reject if the caller's validator has not settled in `ms`. The timer is
|
|
604
|
+
* cleared on both paths so a fast validator leaves nothing pending (an
|
|
605
|
+
* un-cleared timer would hold the event loop open on a short-lived script). */
|
|
606
|
+
function withValidatorTimeout(promise, ms, id) {
|
|
607
|
+
let timer;
|
|
608
|
+
return Promise.race([
|
|
609
|
+
Promise.resolve(promise),
|
|
610
|
+
new Promise((_, reject) => {
|
|
611
|
+
// NOT unref'd: an unref'd timer lets a short-lived script exit before the
|
|
612
|
+
// rejection fires, so the buyer gets a silent process exit instead of an
|
|
613
|
+
// OutputValidationError - which is the failure this bound exists to
|
|
614
|
+
// prevent. clearTimeout in finally() is what stops it lingering.
|
|
615
|
+
timer = setTimeout(() => reject(new Error(`validator ${JSON.stringify(id)} did not settle within ${ms}ms`)), ms);
|
|
616
|
+
}),
|
|
617
|
+
]).finally(() => clearTimeout(timer));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function resultCacheKey(slug, params, validator) {
|
|
621
|
+
const requestKey = `${slug}:${JSON.stringify(params)}`;
|
|
622
|
+
if (!validator) return requestKey;
|
|
623
|
+
// The caller supplies a stable semantic identity because function source is
|
|
624
|
+
// neither stable nor meaningful. Hashing keeps arbitrary labels out of map
|
|
625
|
+
// keys and avoids an unbounded readable cache-key surface.
|
|
626
|
+
const digest = createHash("sha256").update(validator.id, "utf8").digest("hex");
|
|
627
|
+
return `${requestKey}#output-validator/v1/${digest}`;
|
|
628
|
+
}
|
|
629
|
+
|
|
496
630
|
/**
|
|
497
631
|
* Restrict + order which chains an @x402 client will pay on (duck-typed - any
|
|
498
632
|
* client version with createPaymentPayload works, zero new dependencies).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-client",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Agent402 buyer SDK: resolve a task to a tool across 500+ pay-per-call endpoints and call it with payment handled - free via proof-of-work, or over x402 or MPP (a stock mppx fetch works) with a wallet you keep; the buy side of Agentic Finance. Caching, idempotent retries (Idempotency-Key), non-custodial, zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|