agent402-client 0.4.2 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +31 -1
  2. package/index.js +88 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -82,15 +82,45 @@ await a.topSellers({ sort: "calls", include: "all" });
82
82
 
83
83
  | Method | What |
84
84
  |---|---|
85
- | `new Agent402({ baseUrl?, fetch?, cache?, fetchImpl? })` | `fetch` is your x402-wrapped fetch for paid tools (optional); `cache` (default `true`) memoizes deterministic results |
85
+ | `new Agent402({ baseUrl?, fetch?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd? })` | `fetch` is your x402-wrapped fetch for paid tools (optional); `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below) |
86
86
  | `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools (route, price, schema, example) |
87
87
  | `await a.findWorkflows(task, { k = 2 })` | Resolve a task to matching multi-tool workflow templates (skill packs) |
88
88
  | `await a.getWorkflowPrompt(slug, args)` | Fetch the rendered prompt messages for a skill pack with arguments substituted in |
89
89
  | `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) |
90
90
  | `await a.call(slug, params, { idempotencyKey?, cache? })` | Call a tool; auto-pays (PoW for free tools, x402 for wallet-only); returns the JSON result |
91
91
  | `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
92
+ | `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
92
93
  | `a.clearCache()` | Drop the in-memory result cache |
93
94
 
95
+ ## Spending caps (never overpay)
96
+
97
+ By default the client pays whatever a tool costs. Set optional hard ceilings and a
98
+ call that would exceed one is **refused before any payment is signed** (it throws
99
+ `SpendingLimitError` — no funds move):
100
+
101
+ ```js
102
+ import { Agent402, SpendingLimitError } from "agent402-client";
103
+
104
+ const a = new Agent402({
105
+ fetch: payFetch,
106
+ maxPerCallUsd: 0.05, // reject any single call priced above $0.05
107
+ dailyLimitUsd: 5, // rolling-24h ceiling across all sellers
108
+ maxPerHostUsd: 1, // rolling-24h ceiling per seller host
109
+ });
110
+
111
+ try {
112
+ await a.call("some-expensive-tool", { … });
113
+ } catch (e) {
114
+ if (e instanceof SpendingLimitError) console.log(e.limit, e.priceUsd, e.cap);
115
+ }
116
+ ```
117
+
118
+ Only **settled** paid calls count against the rolling window — a blocked or failed
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. This is a
121
+ buyer-side circuit breaker against a malicious or misconfigured `402` that quotes an
122
+ inflated price.
123
+
94
124
  - **Zero dependencies** for the free/proof-of-work path (uses `node:crypto`).
95
125
  - **Non-custodial:** paid settlement is your `@x402/fetch` + wallet; this client never sees your key.
96
126
  - MIT licensed. Part of [Agent402](https://github.com/MikeyPetrillo/Agent402).
package/index.js CHANGED
@@ -23,14 +23,30 @@ export class Agent402 {
23
23
  * @param {typeof fetch} [opts.fetch] an x402-wrapped fetch for wallet-only tools (optional)
24
24
  * @param {boolean} [opts.cache=true] cache results in memory (deterministic tools)
25
25
  * @param {typeof fetch} [opts.fetchImpl] plain fetch (defaults to global fetch)
26
+ * @param {number} [opts.maxPerCallUsd] hard ceiling on a single paid call (USD); over → SpendingLimitError before paying
27
+ * @param {number} [opts.dailyLimitUsd] hard ceiling on rolling-24h paid spend (USD)
28
+ * @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
26
29
  */
27
- constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = globalThis.fetch } = {}) {
30
+ constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = globalThis.fetch,
31
+ maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null } = {}) {
28
32
  if (typeof fetchImpl !== "function") throw new Error("No fetch available — pass { fetchImpl } on Node < 18");
29
33
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
30
34
  this.payFetch = payFetch || null;
31
35
  this.f = fetchImpl;
32
36
  this._catalog = null;
33
37
  this._cache = cache ? new Map() : null;
38
+ // Spending policy (defends the x402 "wallet drain via uncapped spending"
39
+ // failure mode): optional hard ceilings enforced BEFORE any payment is
40
+ // signed. A malicious or misconfigured 402 that quotes an inflated price is
41
+ // refused instead of paid. null = no limit (default — behavior unchanged).
42
+ // Amounts commit to the rolling window only on a settled paid call, so a
43
+ // failed/blocked call never counts against the budget.
44
+ this._spend = {
45
+ maxPerCall: numOrNull(maxPerCallUsd),
46
+ daily: numOrNull(dailyLimitUsd),
47
+ perHost: numOrNull(maxPerHostUsd),
48
+ log: [], // [{ ts, host, usd }] — settled paid calls in the last 24h
49
+ };
34
50
  }
35
51
 
36
52
  async _loadCatalog() {
@@ -167,8 +183,14 @@ export class Agent402 {
167
183
  // Wallet-only tool → settle in USDC via the provided x402 fetch.
168
184
  if (!tool.computePayable) {
169
185
  if (this.payFetch) {
186
+ // Spending policy: refuse to pay BEFORE signing if the quoted price
187
+ // breaks a configured ceiling (per-call / rolling-24h / per-host).
188
+ const usd = parseUsd(tool.price);
189
+ const host = hostOf(this.baseUrl);
190
+ this._spendCheck(host, usd, slug);
170
191
  const r = await send({}, this.payFetch);
171
192
  if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
193
+ this._spendCommit(host, usd); // record spend only once the call actually settled
172
194
  return this._store(cacheKey, await r.json(), cache);
173
195
  }
174
196
  const r = await send(); // no wallet — succeeds only on a FREE_MODE instance
@@ -196,7 +218,72 @@ export class Agent402 {
196
218
 
197
219
  _store(key, val, cache) { if (this._cache && cache) this._cache.set(key, val); return val; }
198
220
  clearCache() { this._cache?.clear(); }
221
+
222
+ /** Throw SpendingLimitError if paying `usd` to `host` now would break a cap.
223
+ * Prunes the rolling 24h window first; a null cap is unlimited. */
224
+ _spendCheck(host, usd, slug) {
225
+ const s = this._spend;
226
+ if (s.maxPerCall == null && s.daily == null && s.perHost == null) return;
227
+ if (s.maxPerCall != null && usd > s.maxPerCall) {
228
+ throw new SpendingLimitError(
229
+ `refusing to pay $${usd} for "${slug}" — exceeds maxPerCallUsd $${s.maxPerCall}`,
230
+ { limit: "maxPerCallUsd", slug, priceUsd: usd, cap: s.maxPerCall });
231
+ }
232
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
233
+ s.log = s.log.filter((e) => e.ts >= cutoff);
234
+ if (s.daily != null) {
235
+ const spent = s.log.reduce((a, e) => a + e.usd, 0);
236
+ if (spent + usd > s.daily) {
237
+ throw new SpendingLimitError(
238
+ `refusing to pay $${usd} for "${slug}" — would bring 24h spend to $${(spent + usd).toFixed(6)}, over dailyLimitUsd $${s.daily}`,
239
+ { limit: "dailyLimitUsd", slug, priceUsd: usd, spent, cap: s.daily });
240
+ }
241
+ }
242
+ if (s.perHost != null) {
243
+ const spentHost = s.log.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0);
244
+ if (spentHost + usd > s.perHost) {
245
+ throw new SpendingLimitError(
246
+ `refusing to pay $${usd} for "${slug}" — would bring 24h spend to ${host} to $${(spentHost + usd).toFixed(6)}, over maxPerHostUsd $${s.perHost}`,
247
+ { limit: "maxPerHostUsd", slug, host, priceUsd: usd, spent: spentHost, cap: s.perHost });
248
+ }
249
+ }
250
+ }
251
+
252
+ /** Record a settled paid call against the rolling budget. */
253
+ _spendCommit(host, usd) { if (usd > 0) this._spend.log.push({ ts: Date.now(), host, usd }); }
254
+
255
+ /** Rolling-24h spend summary (settled paid calls only) — for observability. */
256
+ spendingSummary() {
257
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
258
+ const log = this._spend.log.filter((e) => e.ts >= cutoff);
259
+ const byHost = {};
260
+ for (const e of log) byHost[e.host] = Number(((byHost[e.host] || 0) + e.usd).toFixed(6));
261
+ return {
262
+ dailyUsd: Number(log.reduce((a, e) => a + e.usd, 0).toFixed(6)),
263
+ calls: log.length,
264
+ byHost,
265
+ limits: { maxPerCallUsd: this._spend.maxPerCall, dailyLimitUsd: this._spend.daily, maxPerHostUsd: this._spend.perHost },
266
+ };
267
+ }
268
+ }
269
+
270
+ /** Thrown when a paid call would exceed a configured spending ceiling. The call
271
+ * is refused BEFORE any payment is signed, so no funds move. */
272
+ export class SpendingLimitError extends Error {
273
+ constructor(message, details = {}) {
274
+ super(message);
275
+ this.name = "SpendingLimitError";
276
+ Object.assign(this, details);
277
+ }
278
+ }
279
+
280
+ function numOrNull(v) { if (v == null) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : null; }
281
+ function parseUsd(price) {
282
+ if (typeof price === "number") return Number.isFinite(price) ? price : 0;
283
+ const n = Number(String(price ?? "").replace(/[^0-9.]/g, ""));
284
+ return Number.isFinite(n) ? n : 0;
199
285
  }
286
+ function hostOf(url) { try { return new URL(url).host; } catch { return String(url); } }
200
287
 
201
288
  /**
202
289
  * Restrict + order which chains an @x402 client will pay on (duck-typed — any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Buyer-side SDK for Agent402.Tools (x402): resolve a task to a tool across 1,350 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 or USDG on Robinhood Chain). Caching, idempotent retries, cross-seller Smart Order Router.",
5
5
  "type": "module",
6
6
  "main": "index.js",