agent402-client 0.8.6 → 0.8.8
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 +107 -38
- 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.8";
|
|
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,30 +342,24 @@ 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);
|
|
368
|
-
if (!r.ok) throw
|
|
362
|
+
if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
|
|
369
363
|
this._spendSettle(reservation); // confirm the reservation as settled spend
|
|
370
364
|
settled = true;
|
|
371
365
|
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
@@ -379,18 +373,23 @@ 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}` });
|
|
389
388
|
if (r.status === 402) {
|
|
390
389
|
const body = await r.json().catch(() => ({}));
|
|
391
|
-
throw
|
|
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`}`);
|
|
392
391
|
}
|
|
393
|
-
if (!r.ok) throw
|
|
392
|
+
if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r));
|
|
394
393
|
this._spendSettle(reservation);
|
|
395
394
|
settled = true;
|
|
396
395
|
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: true });
|
|
@@ -398,7 +397,7 @@ export class Agent402 {
|
|
|
398
397
|
}
|
|
399
398
|
const r = await send(); // no wallet - succeeds only on a FREE_MODE instance
|
|
400
399
|
if (r.ok) return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
|
|
401
|
-
throw
|
|
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)`);
|
|
402
401
|
}
|
|
403
402
|
|
|
404
403
|
// Free (compute-payable) tool: succeeds plainly on a FREE_MODE instance,
|
|
@@ -409,7 +408,7 @@ export class Agent402 {
|
|
|
409
408
|
const chal = await this._powChallenge(slug);
|
|
410
409
|
r = await send({ "X-Pow-Solution": Agent402.solvePow(chal) });
|
|
411
410
|
}
|
|
412
|
-
if (!r.ok) throw
|
|
411
|
+
if (!r.ok) throw this._fail(slug, r, await this._failureDetail(slug, r, " after proof-of-work"));
|
|
413
412
|
return this._deliverResponse(slug, r, cacheKey, cache, validator, { maxResponseBytes, paid: false });
|
|
414
413
|
}
|
|
415
414
|
|
|
@@ -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);
|
|
@@ -485,6 +508,13 @@ export class Agent402 {
|
|
|
485
508
|
* it is parsed (maxResponseBytes, 32 MB default), so a second cut here
|
|
486
509
|
* protected nothing and lost the part of the message an agent needs to
|
|
487
510
|
* act on. A body that is not JSON contributes nothing but its status. */
|
|
511
|
+
/** A refusal a caller can branch on: `status` (the HTTP status), `slug` and
|
|
512
|
+
* `paid: false` ride on the Error, so a 402 is not something to regex out of
|
|
513
|
+
* the message. Nothing was spent on any path that reaches this. */
|
|
514
|
+
_fail(slug, r, message) {
|
|
515
|
+
return Object.assign(new Error(message), { status: r?.status, slug, paid: false });
|
|
516
|
+
}
|
|
517
|
+
|
|
488
518
|
async _failureDetail(slug, r, when = "") {
|
|
489
519
|
let detail = "";
|
|
490
520
|
try {
|
|
@@ -506,8 +536,8 @@ export class Agent402 {
|
|
|
506
536
|
* concurrent calls account for each other's in-flight reservations instead of
|
|
507
537
|
* all passing against the same pre-commit total. Returns a reservation handle
|
|
508
538
|
* (or null for a $0 call). Throws SpendingLimitError before reserving if over. */
|
|
509
|
-
_spendReserve(host, usd, slug) {
|
|
510
|
-
this._spendCheck(host, usd, slug);
|
|
539
|
+
_spendReserve(host, usd, slug, listUsd = null) {
|
|
540
|
+
this._spendCheck(host, usd, slug, listUsd);
|
|
511
541
|
if (!(usd > 0)) return null;
|
|
512
542
|
const entry = { ts: Date.now(), host, usd, pending: true };
|
|
513
543
|
this._spend.log.push(entry);
|
|
@@ -575,17 +605,56 @@ export class SpendingLimitError extends Error {
|
|
|
575
605
|
// the offered rails. x402 is stablecoin-settled (USDC/USDG), so
|
|
576
606
|
// atomic / 10^decimals ≈ USD. Returns null if the body isn't a parseable 402
|
|
577
607
|
// challenge, so the caller fails open to the advertised catalog price.
|
|
578
|
-
|
|
579
|
-
|
|
608
|
+
/** The dearest amount a 402 asks for, in USD, or null when it cannot be read.
|
|
609
|
+
*
|
|
610
|
+
* x402 v2 carries the challenge base64 in the `PAYMENT-REQUIRED` HEADER and
|
|
611
|
+
* leaves the body `{}`, so a body-only reader learns nothing from a stock v2
|
|
612
|
+
* seller and silently falls back to the catalog price. Header first, then the
|
|
613
|
+
* body (v1 sellers, and sellers that serve both). */
|
|
614
|
+
export async function quoted402Usd(res) {
|
|
615
|
+
const header = res?.headers?.get?.("payment-required");
|
|
616
|
+
const fromHeader = usdFromAccepts(decodeChallengeHeader(header));
|
|
617
|
+
if (fromHeader != null) return fromHeader;
|
|
618
|
+
let body = null;
|
|
619
|
+
try { body = await res.json(); } catch { body = null; }
|
|
620
|
+
return usdFromAccepts(acceptsOf(body));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function decodeChallengeHeader(header) {
|
|
624
|
+
if (typeof header !== "string" || !header.trim()) return null;
|
|
625
|
+
try { return acceptsOf(JSON.parse(Buffer.from(header.trim(), "base64").toString("utf8"))); } catch { return null; }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The accepts array, at the top level or under the envelope keys sellers wrap it in. */
|
|
629
|
+
function acceptsOf(obj) {
|
|
630
|
+
if (!obj || typeof obj !== "object") return null;
|
|
631
|
+
if (Array.isArray(obj.accepts) && obj.accepts.length) return obj.accepts;
|
|
632
|
+
for (const k of ["payment", "x402", "paymentRequired", "payment_required"]) {
|
|
633
|
+
const nested = obj[k];
|
|
634
|
+
if (nested && typeof nested === "object" && Array.isArray(nested.accepts) && nested.accepts.length) return nested.accepts;
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** The MAXIMUM across accepts: the wallet picks which chain to settle on and
|
|
640
|
+
* this client cannot know which, so the only sound ceiling is the dearest
|
|
641
|
+
* offer. Any entry we cannot price returns null (unreadable, never a guess -
|
|
642
|
+
* the caller falls back to the advertised price). */
|
|
643
|
+
function usdFromAccepts(accepts) {
|
|
580
644
|
if (!Array.isArray(accepts) || !accepts.length) return null;
|
|
581
|
-
let maxUsd =
|
|
645
|
+
let maxUsd = null;
|
|
582
646
|
for (const a of accepts) {
|
|
583
|
-
|
|
647
|
+
// v2 names the base-unit amount `amount`; x402 v1 called it `maxAmountRequired`.
|
|
648
|
+
const atomic = Number(a && (a.amount ?? a.maxAmountRequired));
|
|
584
649
|
if (!Number.isFinite(atomic) || atomic < 0) return null;
|
|
585
|
-
|
|
586
|
-
|
|
650
|
+
// USDC is 6 decimals on every chain these rails settle; an accept may say
|
|
651
|
+
// otherwise, and a wrong exponent is a 1000x pricing error, so a
|
|
652
|
+
// non-integer or out-of-range hint makes the whole quote unreadable.
|
|
653
|
+
const declared = (a && a.extra && a.extra.decimals) ?? (a && a.decimals);
|
|
654
|
+
const decimals = declared == null ? 6 : Number(declared);
|
|
655
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 30) return null;
|
|
587
656
|
const usd = atomic / 10 ** decimals;
|
|
588
|
-
if (usd > maxUsd) maxUsd = usd;
|
|
657
|
+
if (maxUsd == null || usd > maxUsd) maxUsd = usd;
|
|
589
658
|
}
|
|
590
659
|
return maxUsd;
|
|
591
660
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-client",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.8",
|
|
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",
|