agent402-client 0.8.11 → 0.8.13

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 (2) hide show
  1. package/index.js +67 -20
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -23,7 +23,7 @@ import { createHash, randomBytes } from "node:crypto";
23
23
  // `User-Agent: agent402-client/<version>` - a standard header, no extra
24
24
  // network calls - so a seller can attribute traffic (and settled payments)
25
25
  // to this SDK. Product token only; nothing about the caller rides along.
26
- const VERSION = "0.8.11";
26
+ const VERSION = "0.8.13";
27
27
  const USER_AGENT = `agent402-client/${VERSION}`;
28
28
  // 32MB: about a hundred times any realistic response from this catalog (the
29
29
  // largest is a base64 image at a few MB), so it cannot break a legitimate
@@ -33,6 +33,33 @@ const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
33
33
 
34
34
  const leadingZeroBits = (buf) => { let n = 0; for (const b of buf) { if (b === 0) { n += 8; continue; } n += Math.clz32(b) - 24; break; } return n; };
35
35
 
36
+ // Discovery reads (catalog, find, route, skill packs, leaderboard, a
37
+ // proof-of-work challenge) settle nothing, so a 503 the server sheds under
38
+ // load is safe to repeat. It is retried only when it names its own delay
39
+ // (a Retry-After header or a retryAfterSeconds body field), at most
40
+ // DISCOVERY_ATTEMPTS tries in all, each wait capped at DISCOVERY_MAX_WAIT_MS.
41
+ // A paid or priced call never goes through here: a repeat of one could settle
42
+ // twice.
43
+ const DISCOVERY_ATTEMPTS = 3;
44
+ const DISCOVERY_MAX_WAIT_MS = 5000;
45
+
46
+ /** The wait (ms) a shed 503 asks for, or null when `r` is not one. */
47
+ async function shedRetryWaitMs(r, maxWaitMs = DISCOVERY_MAX_WAIT_MS) {
48
+ if (!r || r.status !== 503) return null;
49
+ const header = r.headers && typeof r.headers.get === "function" ? r.headers.get("retry-after") : null;
50
+ let seconds = header != null && String(header).trim() !== "" ? Number(header) : NaN;
51
+ if (!Number.isFinite(seconds)) {
52
+ try {
53
+ const body = await (typeof r.clone === "function" ? r.clone() : r).json();
54
+ seconds = Number(body && body.retryAfterSeconds);
55
+ } catch { seconds = NaN; }
56
+ }
57
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
58
+ return Math.min(seconds * 1000, maxWaitMs);
59
+ }
60
+
61
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
62
+
36
63
  export class Agent402 {
37
64
  /**
38
65
  * @param {object} [opts]
@@ -43,8 +70,8 @@ export class Agent402 {
43
70
  * @param {number} [opts.maxPerCallUsd] hard ceiling on a single paid call (USD); over → SpendingLimitError before paying
44
71
  * @param {number} [opts.dailyLimitUsd] hard ceiling on rolling-24h paid spend (USD)
45
72
  * @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
46
- * @param {string} [opts.creditsKey] a prepaid card-credits key (a402_...) issued at agent402.tools/credits -
47
- * pays wallet-only tools by card when no payFetch is given
73
+ * @param {string} [opts.creditsKey] a prepaid card-credits key (a402_...) already issued (new credits are
74
+ * not on sale) - pays wallet-only tools when no payFetch is given
48
75
  * @param {number|null} [opts.maxResponseBytes=33554432]
49
76
  * Hard ceiling on a response body, enforced BEFORE it is parsed. null disables it.
50
77
  * @param {{id:string, validate:function}} [opts.outputValidator]
@@ -62,6 +89,8 @@ export class Agent402 {
62
89
  // above). Caller-supplied init.headers still win on a key collision.
63
90
  this.f = (url, init = {}) => fetchImpl(url, { ...init, headers: { "User-Agent": USER_AGENT, ...(init.headers || {}) } });
64
91
  this._catalog = null;
92
+ // The wait between shed-503 retries of a discovery read (tests replace it).
93
+ this._sleep = sleepMs;
65
94
  this._cache = cache ? new Map() : null;
66
95
  // Salt for the default Idempotency-Key: one per client instance, so the key
67
96
  // for (slug, params) is stable across call() invocations on THIS client
@@ -147,10 +176,35 @@ export class Agent402 {
147
176
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
148
177
  }
149
178
 
179
+ /**
180
+ * GET a discovery surface, retrying a shed 503 (see DISCOVERY_ATTEMPTS).
181
+ * Returns the response once it is 2xx; otherwise throws an Error naming the
182
+ * operation, the surface and the last status, with `.status`, `.surface`
183
+ * and `.attempts` set.
184
+ */
185
+ async _discoveryGet(label, path) {
186
+ let r;
187
+ let attempts = 0;
188
+ for (;;) {
189
+ attempts++;
190
+ r = await this.f(`${this.baseUrl}${path}`);
191
+ if (r.ok) return r;
192
+ if (attempts >= DISCOVERY_ATTEMPTS) break;
193
+ const wait = await shedRetryWaitMs(r);
194
+ if (wait == null) break;
195
+ await this._sleep(wait);
196
+ }
197
+ const surface = path.split("?")[0];
198
+ const err = new Error(`${label} failed: HTTP ${r.status} from ${surface}${attempts > 1 ? ` after ${attempts} attempts` : ""}`);
199
+ err.status = r.status;
200
+ err.surface = surface;
201
+ err.attempts = attempts;
202
+ throw err;
203
+ }
204
+
150
205
  async _loadCatalog() {
151
206
  if (this._catalog) return this._catalog;
152
- const r = await this.f(`${this.baseUrl}/api/pricing`);
153
- if (!r.ok) throw new Error(`could not load catalog: HTTP ${r.status}`);
207
+ const r = await this._discoveryGet("could not load catalog", "/api/pricing");
154
208
  const j = await r.json();
155
209
  const m = new Map();
156
210
  for (const e of j.endpoints || []) m.set(e.slug, { method: e.method, path: e.path, computePayable: e.computePayable, price: e.price });
@@ -160,8 +214,7 @@ export class Agent402 {
160
214
 
161
215
  /** Resolve a plain-language task to the best-matching tools (route, price, schema, example). */
162
216
  async find(task, { k = 5 } = {}) {
163
- const r = await this.f(`${this.baseUrl}/api/find?q=${encodeURIComponent(task)}&k=${k}`);
164
- if (!r.ok) throw new Error(`find failed: HTTP ${r.status}`);
217
+ const r = await this._discoveryGet("find", `/api/find?q=${encodeURIComponent(task)}&k=${k}`);
165
218
  return (await r.json()).results || [];
166
219
  }
167
220
 
@@ -190,8 +243,7 @@ export class Agent402 {
190
243
  const includeParam = include === "external" || include === "local" ? include : "all";
191
244
  const qs = new URLSearchParams({ q: String(task ?? ""), k: String(top), include: includeParam });
192
245
  if (network) qs.set("network", String(network));
193
- const r = await this.f(`${this.baseUrl}/api/route?${qs}`);
194
- if (!r.ok) throw new Error(`route failed: HTTP ${r.status}`);
246
+ const r = await this._discoveryGet("route", `/api/route?${qs}`);
195
247
  return r.json();
196
248
  }
197
249
 
@@ -204,8 +256,7 @@ export class Agent402 {
204
256
  * to fetch the rendered prompt messages, or hand the slug to an MCP client.
205
257
  */
206
258
  async findWorkflows(task, { k = 2 } = {}) {
207
- const r = await this.f(`${this.baseUrl}/api/find?q=${encodeURIComponent(task)}&k=${k}`);
208
- if (!r.ok) throw new Error(`findWorkflows failed: HTTP ${r.status}`);
259
+ const r = await this._discoveryGet("findWorkflows", `/api/find?q=${encodeURIComponent(task)}&k=${k}`);
209
260
  return (await r.json()).packs || [];
210
261
  }
211
262
 
@@ -216,8 +267,7 @@ export class Agent402 {
216
267
  */
217
268
  async getWorkflowPrompt(slug, args = {}) {
218
269
  const qs = new URLSearchParams(Object.entries(args).map(([k, v]) => [k, String(v)])).toString();
219
- const r = await this.f(`${this.baseUrl}/api/skill-packs/${encodeURIComponent(slug)}/prompt${qs ? `?${qs}` : ""}`);
220
- if (!r.ok) throw new Error(`getWorkflowPrompt("${slug}") failed: HTTP ${r.status}`);
270
+ const r = await this._discoveryGet(`getWorkflowPrompt("${slug}")`, `/api/skill-packs/${encodeURIComponent(slug)}/prompt${qs ? `?${qs}` : ""}`);
221
271
  return r.json();
222
272
  }
223
273
 
@@ -238,9 +288,7 @@ export class Agent402 {
238
288
  const top = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50);
239
289
  const sortParam = sort === "calls" ? "calls" : "usd";
240
290
  const includeParam = include === "all" ? "all" : "external";
241
- const url = `${this.baseUrl}/api/leaderboard?top=${top}&sort=${sortParam}&include=${includeParam}`;
242
- const r = await this.f(url);
243
- if (!r.ok) throw new Error(`topSellers failed: HTTP ${r.status}`);
291
+ const r = await this._discoveryGet("topSellers", `/api/leaderboard?top=${top}&sort=${sortParam}&include=${includeParam}`);
244
292
  const snap = await r.json();
245
293
  return {
246
294
  window: snap.windowLabel || snap.windowServed || "24h",
@@ -387,7 +435,7 @@ export class Agent402 {
387
435
  const r = await send({ Authorization: `Bearer ${this.creditsKey}` });
388
436
  if (r.status === 402) {
389
437
  const body = await r.json().catch(() => ({}));
390
- throw this._fail(slug, r, `call "${slug}" refused by credits: ${body.error || "payment required"}${body.balanceUsd != null ? ` (balance $${body.balanceUsd})` : ""} - top up at ${body.topup || `${this.baseUrl}/credits`}`);
438
+ throw this._fail(slug, r, `call "${slug}" refused by credits: ${body.error || "payment required"}${body.balanceUsd != null ? ` (balance $${body.balanceUsd})` : ""} ${body.topup ? ` - top up at ${body.topup}` : body.topupNote ? ` - ${body.topupNote}` : ""}`);
391
439
  }
392
440
  if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
393
441
  this._spendSettle(reservation);
@@ -397,7 +445,7 @@ export class Agent402 {
397
445
  }
398
446
  const r = await send(); // no wallet - succeeds only on a FREE_MODE instance
399
447
  if (r.ok) return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
400
- throw this._fail(slug, r, `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)`);
448
+ throw this._fail(slug, r, `call "${slug}" failed: HTTP ${r.status} - wallet-only tool; construct with { fetch: payFetch } (an @x402/fetch-wrapped fetch) or { creditsKey } (a prepaid credits key already issued; new credits are not on sale)`);
401
449
  }
402
450
 
403
451
  // Free (compute-payable) tool: succeeds plainly on a FREE_MODE instance,
@@ -436,8 +484,7 @@ export class Agent402 {
436
484
  }
437
485
 
438
486
  async _powChallenge(slug) {
439
- const r = await this.f(`${this.baseUrl}/api/pow/challenge?slug=${encodeURIComponent(slug)}`);
440
- if (!r.ok) throw new Error(`proof-of-work challenge for "${slug}" failed: HTTP ${r.status}`);
487
+ const r = await this._discoveryGet(`proof-of-work challenge for "${slug}"`, `/api/pow/challenge?slug=${encodeURIComponent(slug)}`);
441
488
  return r.json();
442
489
  }
443
490
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
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",