agent402-client 0.5.1 → 0.6.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/LICENSE +21 -0
- package/README.md +12 -3
- package/index.js +76 -11
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Havok Holdings LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -117,9 +117,18 @@ try {
|
|
|
117
117
|
|
|
118
118
|
Only **settled** paid calls count against the rolling window — a blocked or failed
|
|
119
119
|
call never consumes budget. Free proof-of-work calls are never counted. Omit a cap
|
|
120
|
-
(or leave it `null`) for no limit; with none set, behavior is unchanged.
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
(or leave it `null`) for no limit; with none set, behavior is unchanged.
|
|
121
|
+
|
|
122
|
+
**What the caps check.** When a cap is set, the client preflights the `402` and
|
|
123
|
+
checks the ceiling against the **larger** of the advertised price (from the
|
|
124
|
+
seller's `/api/pricing`) and the amount the `402` challenge actually quotes — so a
|
|
125
|
+
server that under-advertises and then quotes more in the `402` is refused *before*
|
|
126
|
+
your wallet fetch signs anything. If the `402` can't be read (FREE_MODE, or an
|
|
127
|
+
unrecognized challenge shape) it falls back to the advertised price rather than
|
|
128
|
+
block a legitimate payment. Caps hold under **concurrency** too: each call reserves
|
|
129
|
+
its amount synchronously, so N simultaneous calls can't each pass against the same
|
|
130
|
+
pre-commit total. (The `402` amount is derived assuming stablecoin settlement —
|
|
131
|
+
`atomic / 10^decimals ≈ USD` — which matches x402's USDC/USDG rails.)
|
|
123
132
|
|
|
124
133
|
- **Zero dependencies** for the free/proof-of-work path (uses `node:crypto`).
|
|
125
134
|
- **Non-custodial:** paid settlement is your `@x402/fetch` + wallet; this client never sees your key.
|
package/index.js
CHANGED
|
@@ -183,15 +183,38 @@ export class Agent402 {
|
|
|
183
183
|
// Wallet-only tool → settle in USDC via the provided x402 fetch.
|
|
184
184
|
if (!tool.computePayable) {
|
|
185
185
|
if (this.payFetch) {
|
|
186
|
-
// Spending policy: refuse to pay BEFORE signing if the
|
|
187
|
-
//
|
|
188
|
-
const usd = parseUsd(tool.price);
|
|
186
|
+
// Spending policy: refuse to pay BEFORE signing if the price breaks a
|
|
187
|
+
// configured ceiling (per-call / rolling-24h / per-host).
|
|
189
188
|
const host = hostOf(this.baseUrl);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
let usd = parseUsd(tool.price);
|
|
190
|
+
// The catalog price is seller-ADVERTISED — a hostile server could under-
|
|
191
|
+
// state it and then quote more in the 402. When a cap is set, preflight
|
|
192
|
+
// the 402 to learn the price the wallet will actually be asked to sign and
|
|
193
|
+
// check the cap against the larger of the two. Fail-open: if the 402 can't
|
|
194
|
+
// be read (FREE_MODE / non-402 / unparseable), fall back to the advertised
|
|
195
|
+
// price — never block a legitimate payment on a parse miss.
|
|
196
|
+
if (this._spendCapsConfigured()) {
|
|
197
|
+
try {
|
|
198
|
+
const pre = await send();
|
|
199
|
+
if (pre.status === 402) {
|
|
200
|
+
const quoted = parse402Usd(await pre.json().catch(() => null));
|
|
201
|
+
if (quoted != null) usd = Math.max(usd, quoted);
|
|
202
|
+
}
|
|
203
|
+
} catch { /* fail-open to the advertised price */ }
|
|
204
|
+
}
|
|
205
|
+
// Reserve the amount synchronously (before the await) so concurrent calls
|
|
206
|
+
// can't each observe the pre-commit total and collectively blow a rolling
|
|
207
|
+
// cap; release the reservation if the call doesn't settle.
|
|
208
|
+
const reservation = this._spendReserve(host, usd, slug);
|
|
209
|
+
try {
|
|
210
|
+
const r = await send({}, this.payFetch);
|
|
211
|
+
if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
|
|
212
|
+
this._spendSettle(reservation); // confirm the reservation as settled spend
|
|
213
|
+
return this._store(cacheKey, await r.json(), cache);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
this._spendRelease(reservation); // roll back — nothing settled
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
195
218
|
}
|
|
196
219
|
const r = await send(); // no wallet — succeeds only on a FREE_MODE instance
|
|
197
220
|
if (r.ok) return this._store(cacheKey, await r.json(), cache);
|
|
@@ -249,13 +272,36 @@ export class Agent402 {
|
|
|
249
272
|
}
|
|
250
273
|
}
|
|
251
274
|
|
|
252
|
-
/**
|
|
253
|
-
|
|
275
|
+
/** True if any spending ceiling is configured (worth preflighting the 402). */
|
|
276
|
+
_spendCapsConfigured() {
|
|
277
|
+
const s = this._spend;
|
|
278
|
+
return s.maxPerCall != null || s.daily != null || s.perHost != null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Check caps AND reserve the amount atomically (no await in between), so
|
|
282
|
+
* concurrent calls account for each other's in-flight reservations instead of
|
|
283
|
+
* all passing against the same pre-commit total. Returns a reservation handle
|
|
284
|
+
* (or null for a $0 call). Throws SpendingLimitError before reserving if over. */
|
|
285
|
+
_spendReserve(host, usd, slug) {
|
|
286
|
+
this._spendCheck(host, usd, slug);
|
|
287
|
+
if (!(usd > 0)) return null;
|
|
288
|
+
const entry = { ts: Date.now(), host, usd, pending: true };
|
|
289
|
+
this._spend.log.push(entry);
|
|
290
|
+
return entry;
|
|
291
|
+
}
|
|
292
|
+
/** Confirm a reservation as settled spend. */
|
|
293
|
+
_spendSettle(entry) { if (entry) entry.pending = false; }
|
|
294
|
+
/** Roll back a reservation whose call did not settle (failed / errored). */
|
|
295
|
+
_spendRelease(entry) {
|
|
296
|
+
if (!entry) return;
|
|
297
|
+
const i = this._spend.log.indexOf(entry);
|
|
298
|
+
if (i >= 0) this._spend.log.splice(i, 1);
|
|
299
|
+
}
|
|
254
300
|
|
|
255
301
|
/** Rolling-24h spend summary (settled paid calls only) — for observability. */
|
|
256
302
|
spendingSummary() {
|
|
257
303
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
258
|
-
const log = this._spend.log.filter((e) => e.ts >= cutoff);
|
|
304
|
+
const log = this._spend.log.filter((e) => e.ts >= cutoff && !e.pending);
|
|
259
305
|
const byHost = {};
|
|
260
306
|
for (const e of log) byHost[e.host] = Number(((byHost[e.host] || 0) + e.usd).toFixed(6));
|
|
261
307
|
return {
|
|
@@ -277,6 +323,25 @@ export class SpendingLimitError extends Error {
|
|
|
277
323
|
}
|
|
278
324
|
}
|
|
279
325
|
|
|
326
|
+
// Parse the USD amount an x402 `402` challenge actually requires — the max across
|
|
327
|
+
// the offered rails. x402 is stablecoin-settled (USDC/USDG), so
|
|
328
|
+
// atomic / 10^decimals ≈ USD. Returns null if the body isn't a parseable 402
|
|
329
|
+
// challenge, so the caller fails open to the advertised catalog price.
|
|
330
|
+
function parse402Usd(body) {
|
|
331
|
+
const accepts = body && body.accepts;
|
|
332
|
+
if (!Array.isArray(accepts) || !accepts.length) return null;
|
|
333
|
+
let maxUsd = 0;
|
|
334
|
+
for (const a of accepts) {
|
|
335
|
+
const atomic = Number(a && a.maxAmountRequired);
|
|
336
|
+
if (!Number.isFinite(atomic) || atomic < 0) return null;
|
|
337
|
+
const decimals = Number((a && a.extra && a.extra.decimals) ?? (a && a.decimals) ?? 6);
|
|
338
|
+
if (!Number.isFinite(decimals) || decimals < 0 || decimals > 30) return null;
|
|
339
|
+
const usd = atomic / 10 ** decimals;
|
|
340
|
+
if (usd > maxUsd) maxUsd = usd;
|
|
341
|
+
}
|
|
342
|
+
return maxUsd;
|
|
343
|
+
}
|
|
344
|
+
|
|
280
345
|
function numOrNull(v) { if (v == null) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : null; }
|
|
281
346
|
function parseUsd(price) {
|
|
282
347
|
if (typeof price === "number") return Number.isFinite(price) ? price : 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Buyer-side SDK for Agent402.Tools (x402): resolve a task to a tool across 1,407 pay-per-call endpoints and call it with payment handled for you — free tools settle via built-in proof-of-work (no wallet), wallet-only tools via your x402 fetch — with a withNetworkPreference helper to pick the settlement chain (USDC on Base/Solana/Polygon/Arbitrum/Stellar or USDG on Robinhood Chain). Caching, idempotent retries, cross-seller Smart Order Router.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"usdg",
|
|
42
42
|
"multi-chain"
|
|
43
43
|
],
|
|
44
|
-
"author": "
|
|
44
|
+
"author": "Havok Holdings LLC <mike@agent402.tools> (https://agent402.tools)",
|
|
45
45
|
"license": "MIT",
|
|
46
46
|
"repository": {
|
|
47
47
|
"type": "git",
|