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.
- package/index.js +99 -34
- 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.
|
|
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
|
-
|
|
346
|
-
// The catalog price is
|
|
347
|
-
//
|
|
348
|
-
// the
|
|
349
|
-
//
|
|
350
|
-
//
|
|
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
|
-
|
|
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
|
|
385
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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 =
|
|
648
|
+
let maxUsd = null;
|
|
589
649
|
for (const a of accepts) {
|
|
590
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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.
|
|
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",
|