agent402-client 0.8.7 → 0.8.9

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 +99 -34
  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.7";
26
+ const VERSION = "0.8.9";
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
@@ -342,26 +342,20 @@ export class Agent402 {
342
342
  // Spending policy: refuse to pay BEFORE signing if the price breaks a
343
343
  // configured ceiling (per-call / rolling-24h / per-host).
344
344
  const host = hostOf(this.baseUrl);
345
- let usd = parseUsd(tool.price);
346
- // The catalog price is seller-ADVERTISED - a hostile server could under-
347
- // state it and then quote more in the 402. When a cap is set, preflight
348
- // the 402 to learn the price the wallet will actually be asked to sign and
349
- // check the cap against the larger of the two. Fail-open: if the 402 can't
350
- // be read (FREE_MODE / non-402 / unparseable), fall back to the advertised
345
+ const listUsd = parseUsd(tool.price);
346
+ // The catalog price is the ROUTE's list price, and it is not the amount
347
+ // the wallet will be asked to sign: a seller may quote per request from
348
+ // the body (a token-metered route, or a chat route priced by the model
349
+ // named in it), and a hostile one could understate the catalog outright.
350
+ // When a cap is set, preflight the 402 and check the cap against the
351
+ // amount THIS request is quoted. Fail-open: a 402 we cannot read
352
+ // (FREE_MODE / non-402 / unparseable) falls back to the advertised
351
353
  // price - never block a legitimate payment on a parse miss.
352
- if (this._spendCapsConfigured()) {
353
- try {
354
- const pre = await send();
355
- if (pre.status === 402) {
356
- const quoted = parse402Usd(await pre.json().catch(() => null));
357
- if (quoted != null) usd = Math.max(usd, quoted);
358
- }
359
- } catch { /* fail-open to the advertised price */ }
360
- }
354
+ const usd = await this._quotedUsd(send, listUsd);
361
355
  // Reserve the amount synchronously (before the await) so concurrent calls
362
356
  // can't each observe the pre-commit total and collectively blow a rolling
363
357
  // cap; release the reservation if the call doesn't settle.
364
- const reservation = this._spendReserve(host, usd, slug);
358
+ const reservation = this._spendReserve(host, usd, slug, listUsd);
365
359
  let settled = false;
366
360
  try {
367
361
  const r = await send({}, this.payFetch);
@@ -379,10 +373,15 @@ export class Agent402 {
379
373
  if (this.creditsKey) {
380
374
  // Prepaid card credits: the server authorizes against the key's balance
381
375
  // before the handler and debits only on a 200 (X-Credits-Balance tells
382
- // what is left). The same spend caps apply as on the wallet path.
376
+ // what is left). The same spend caps apply as on the wallet path, and
377
+ // against the same number: a credits gate holds and debits the amount
378
+ // the 402 quotes for THIS body, not the route's list price. The
379
+ // preflight carries no Authorization, so it is an unpaid 402, never a
380
+ // debit.
383
381
  const host = hostOf(this.baseUrl);
384
- const usd = parseUsd(tool.price);
385
- const reservation = this._spendReserve(host, usd, slug);
382
+ const listUsd = parseUsd(tool.price);
383
+ const usd = await this._quotedUsd(send, listUsd);
384
+ const reservation = this._spendReserve(host, usd, slug, listUsd);
386
385
  let settled = false;
387
386
  try {
388
387
  const r = await send({ Authorization: `Bearer ${this.creditsKey}` });
@@ -445,15 +444,39 @@ export class Agent402 {
445
444
  _store(key, val, cache) { if (this._cache && cache) this._cache.set(key, val); return val; }
446
445
  clearCache() { this._cache?.clear(); }
447
446
 
447
+ /** The price THIS request will be asked to pay, read from its own 402.
448
+ *
449
+ * A cap enforced against the catalog price is not a cap: the seller decides
450
+ * the amount in the challenge, and a route may quote per request from the
451
+ * body. Only preflights when a ceiling is configured (it costs a round trip,
452
+ * and with no cap the number changes nothing). The preflight send carries no
453
+ * payment header and no credits key, so it can only ever draw a 402.
454
+ * @returns {Promise<number>} the quoted amount, or `listUsd` when unreadable */
455
+ async _quotedUsd(send, listUsd) {
456
+ if (!this._spendCapsConfigured()) return listUsd;
457
+ try {
458
+ const pre = await send();
459
+ if (pre?.status !== 402) return listUsd;
460
+ const quoted = await quoted402Usd(pre);
461
+ // Larger of the two: an understated catalog must not lower the ceiling
462
+ // the cap is checked against.
463
+ return quoted == null ? listUsd : Math.max(listUsd, quoted);
464
+ } catch { return listUsd; /* fail-open to the advertised price */ }
465
+ }
466
+
448
467
  /** Throw SpendingLimitError if paying `usd` to `host` now would break a cap.
449
- * Prunes the rolling 24h window first; a null cap is unlimited. */
450
- _spendCheck(host, usd, slug) {
468
+ * Prunes the rolling 24h window first; a null cap is unlimited.
469
+ * `listUsd` (when it differs) is the catalog price, named in the refusal so
470
+ * a caller can see the quote is not the number they budgeted from. */
471
+ _spendCheck(host, usd, slug, listUsd = null) {
451
472
  const s = this._spend;
452
473
  if (s.maxPerCall == null && s.daily == null && s.perHost == null) return;
474
+ const quotedNote = listUsd != null && listUsd !== usd
475
+ ? ` (this request is quoted $${usd}; the catalog lists $${listUsd})` : "";
453
476
  if (s.maxPerCall != null && usd > s.maxPerCall) {
454
477
  throw new SpendingLimitError(
455
- `refusing to pay $${usd} for "${slug}" - exceeds maxPerCallUsd $${s.maxPerCall}`,
456
- { limit: "maxPerCallUsd", slug, priceUsd: usd, cap: s.maxPerCall });
478
+ `refusing to pay $${usd} for "${slug}" - exceeds maxPerCallUsd $${s.maxPerCall}${quotedNote}`,
479
+ { limit: "maxPerCallUsd", slug, priceUsd: usd, cap: s.maxPerCall, ...(listUsd != null ? { listPriceUsd: listUsd } : {}) });
457
480
  }
458
481
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
459
482
  s.log = s.log.filter((e) => e.ts >= cutoff);
@@ -496,7 +519,10 @@ export class Agent402 {
496
519
  let detail = "";
497
520
  try {
498
521
  const body = await r.json();
499
- const msg = body?.error ?? body?.message ?? body?.detail;
522
+ // A refused payment that says what went wrong carries that sentence in
523
+ // `detail` (an RFC 9457 problem) or `hint` (an x402 refusal), with no
524
+ // `error`; a seller's plain error keeps its place first.
525
+ const msg = body?.error ?? body?.message ?? body?.detail ?? body?.hint;
500
526
  if (msg != null) detail = ` - ${typeof msg === "string" ? msg : JSON.stringify(msg)}`;
501
527
  if (body?.expected != null) detail += ` (expected: ${JSON.stringify(body.expected)})`;
502
528
  } catch { /* not JSON: the status is the whole story */ }
@@ -513,8 +539,8 @@ export class Agent402 {
513
539
  * concurrent calls account for each other's in-flight reservations instead of
514
540
  * all passing against the same pre-commit total. Returns a reservation handle
515
541
  * (or null for a $0 call). Throws SpendingLimitError before reserving if over. */
516
- _spendReserve(host, usd, slug) {
517
- this._spendCheck(host, usd, slug);
542
+ _spendReserve(host, usd, slug, listUsd = null) {
543
+ this._spendCheck(host, usd, slug, listUsd);
518
544
  if (!(usd > 0)) return null;
519
545
  const entry = { ts: Date.now(), host, usd, pending: true };
520
546
  this._spend.log.push(entry);
@@ -582,17 +608,56 @@ export class SpendingLimitError extends Error {
582
608
  // the offered rails. x402 is stablecoin-settled (USDC/USDG), so
583
609
  // atomic / 10^decimals ≈ USD. Returns null if the body isn't a parseable 402
584
610
  // challenge, so the caller fails open to the advertised catalog price.
585
- function parse402Usd(body) {
586
- const accepts = body && body.accepts;
611
+ /** The dearest amount a 402 asks for, in USD, or null when it cannot be read.
612
+ *
613
+ * x402 v2 carries the challenge base64 in the `PAYMENT-REQUIRED` HEADER and
614
+ * leaves the body `{}`, so a body-only reader learns nothing from a stock v2
615
+ * seller and silently falls back to the catalog price. Header first, then the
616
+ * body (v1 sellers, and sellers that serve both). */
617
+ export async function quoted402Usd(res) {
618
+ const header = res?.headers?.get?.("payment-required");
619
+ const fromHeader = usdFromAccepts(decodeChallengeHeader(header));
620
+ if (fromHeader != null) return fromHeader;
621
+ let body = null;
622
+ try { body = await res.json(); } catch { body = null; }
623
+ return usdFromAccepts(acceptsOf(body));
624
+ }
625
+
626
+ function decodeChallengeHeader(header) {
627
+ if (typeof header !== "string" || !header.trim()) return null;
628
+ try { return acceptsOf(JSON.parse(Buffer.from(header.trim(), "base64").toString("utf8"))); } catch { return null; }
629
+ }
630
+
631
+ /** The accepts array, at the top level or under the envelope keys sellers wrap it in. */
632
+ function acceptsOf(obj) {
633
+ if (!obj || typeof obj !== "object") return null;
634
+ if (Array.isArray(obj.accepts) && obj.accepts.length) return obj.accepts;
635
+ for (const k of ["payment", "x402", "paymentRequired", "payment_required"]) {
636
+ const nested = obj[k];
637
+ if (nested && typeof nested === "object" && Array.isArray(nested.accepts) && nested.accepts.length) return nested.accepts;
638
+ }
639
+ return null;
640
+ }
641
+
642
+ /** The MAXIMUM across accepts: the wallet picks which chain to settle on and
643
+ * this client cannot know which, so the only sound ceiling is the dearest
644
+ * offer. Any entry we cannot price returns null (unreadable, never a guess -
645
+ * the caller falls back to the advertised price). */
646
+ function usdFromAccepts(accepts) {
587
647
  if (!Array.isArray(accepts) || !accepts.length) return null;
588
- let maxUsd = 0;
648
+ let maxUsd = null;
589
649
  for (const a of accepts) {
590
- const atomic = Number(a && a.maxAmountRequired);
650
+ // v2 names the base-unit amount `amount`; x402 v1 called it `maxAmountRequired`.
651
+ const atomic = Number(a && (a.amount ?? a.maxAmountRequired));
591
652
  if (!Number.isFinite(atomic) || atomic < 0) return null;
592
- const decimals = Number((a && a.extra && a.extra.decimals) ?? (a && a.decimals) ?? 6);
593
- if (!Number.isFinite(decimals) || decimals < 0 || decimals > 30) return null;
653
+ // USDC is 6 decimals on every chain these rails settle; an accept may say
654
+ // otherwise, and a wrong exponent is a 1000x pricing error, so a
655
+ // non-integer or out-of-range hint makes the whole quote unreadable.
656
+ const declared = (a && a.extra && a.extra.decimals) ?? (a && a.decimals);
657
+ const decimals = declared == null ? 6 : Number(declared);
658
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 30) return null;
594
659
  const usd = atomic / 10 ** decimals;
595
- if (usd > maxUsd) maxUsd = usd;
660
+ if (maxUsd == null || usd > maxUsd) maxUsd = usd;
596
661
  }
597
662
  return maxUsd;
598
663
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
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",