agent402-client 0.7.0 → 0.8.1
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/README.md +59 -6
- package/index.js +142 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ const a = new Agent402(); // → https://agent402.tools
|
|
|
23
23
|
|
|
24
24
|
// Don't know the slug? Resolve a task in one call.
|
|
25
25
|
const matches = await a.find("extract the article from a url");
|
|
26
|
-
// → [{ slug: "extract", route, price, inputSchema, example,
|
|
26
|
+
// → [{ slug: "extract", route, price, inputSchema, example, ... }]
|
|
27
27
|
|
|
28
28
|
// Call it - proof-of-work is solved automatically for free tools.
|
|
29
29
|
const out = await a.call("hash", { text: "hello world", algo: "sha256" });
|
|
@@ -101,7 +101,7 @@ same id is a new payment).
|
|
|
101
101
|
## Workflows (skill packs)
|
|
102
102
|
|
|
103
103
|
For jobs that no single tool covers - e.g. "audit a domain", "build a stock
|
|
104
|
-
brief" - Agent402 ships curated multi-tool **skill packs**: 5
|
|
104
|
+
brief" - Agent402 ships curated multi-tool **skill packs**: 5-7 catalog tools
|
|
105
105
|
composed into a Claude-ready task template. Discover them the same way you'd
|
|
106
106
|
discover a tool:
|
|
107
107
|
|
|
@@ -114,6 +114,38 @@ const { messages } = await a.getWorkflowPrompt("security-audit", { domain: "stri
|
|
|
114
114
|
// → feed messages straight to any LLM
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
+
## Cross-seller routing (neutral Smart Order Router)
|
|
118
|
+
|
|
119
|
+
`find()` searches **this host's catalog only**. To rank tools across eligible
|
|
120
|
+
routable seller rows in the host's **current index** (not complete ecosystem
|
|
121
|
+
coverage), use `route()` - free, read-only, no wallet:
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
const task = "screenshot webpage";
|
|
125
|
+
const { query, include, results } = await a.route(task, {
|
|
126
|
+
k: 5,
|
|
127
|
+
include: "external", // all | external | local
|
|
128
|
+
network: "robinhood", // optional chain filter (short name or CAIP-2)
|
|
129
|
+
});
|
|
130
|
+
// results[]: opaque server rows - { seller, slug, url, priceUsd, executeVia?, ... }
|
|
131
|
+
|
|
132
|
+
// executeVia quotes which route-execute* tier fits a row's underlying price.
|
|
133
|
+
// It does NOT bind that row: the tier re-resolves an eligible match under its
|
|
134
|
+
// cap at execution time (url, route, seller, and identity can differ).
|
|
135
|
+
const hint = results[0]?.executeVia;
|
|
136
|
+
if (hint?.tool) {
|
|
137
|
+
const { result, receipt } = await a.call(hint.tool, {
|
|
138
|
+
task: query,
|
|
139
|
+
include,
|
|
140
|
+
params: { url: "https://example.com" }, // inputs the chosen operation needs
|
|
141
|
+
}, { cache: false }); // routed externals can be dynamic; client cache is on by default
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`executeVia` is server-reported metadata only. The SDK passes discovery rows
|
|
146
|
+
through unchanged, does not validate seller output, and does not guarantee
|
|
147
|
+
which seller or tool the execution tier will pick.
|
|
148
|
+
|
|
117
149
|
## Discover the live x402 economy
|
|
118
150
|
|
|
119
151
|
Want to see who's actually getting paid on x402 right now - not just what tools
|
|
@@ -133,16 +165,37 @@ await a.topSellers({ sort: "calls", include: "all" });
|
|
|
133
165
|
|
|
134
166
|
| Method | What |
|
|
135
167
|
|---|---|
|
|
136
|
-
| `new Agent402({ baseUrl?, fetch?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd? })` | `fetch` is your x402- or MPP-wrapped fetch for paid tools (optional); `creditsKey` is a prepaid card-credits key (`a402_...`) used for paid tools when no `fetch` is given; `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below) |
|
|
137
|
-
| `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools (route, price, schema, example) |
|
|
168
|
+
| `new Agent402({ baseUrl?, fetch?, creditsKey?, cache?, fetchImpl?, maxPerCallUsd?, dailyLimitUsd?, maxPerHostUsd?, maxResponseBytes? })` | `fetch` is your x402- or MPP-wrapped fetch for paid tools (optional); `creditsKey` is a prepaid card-credits key (`a402_...`) used for paid tools when no `fetch` is given; `cache` (default `true`) memoizes deterministic results; the three USD caps set optional spending limits (see below); `maxResponseBytes` (default 32MB, `null` to disable) refuses an oversized response body before it is parsed |
|
|
169
|
+
| `await a.find(task, { k = 5 })` | Resolve a plain-language task to the best-matching tools on **this host** (route, price, schema, example) |
|
|
170
|
+
| `await a.route(task, { k?, include?, network? })` | Cross-seller Smart Order Router: rank eligible/routable seller rows from the host's current index (free, read-only; opaque results may include server-reported `executeVia`) |
|
|
138
171
|
| `await a.findWorkflows(task, { k = 2 })` | Resolve a task to matching multi-tool workflow templates (skill packs) |
|
|
139
172
|
| `await a.getWorkflowPrompt(slug, args)` | Fetch the rendered prompt messages for a skill pack with arguments substituted in |
|
|
140
173
|
| `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) |
|
|
141
|
-
| `await a.call(slug, params, { idempotencyKey?, cache? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); returns the JSON result |
|
|
174
|
+
| `await a.call(slug, params, { idempotencyKey?, cache?, maxResponseBytes? })` | Call a tool; auto-pays (PoW for free tools; your payment `fetch` or the credits key for wallet-only); returns the JSON result |
|
|
142
175
|
| `Agent402.solvePow(pow)` | Solve a proof-of-work challenge object → an `X-Pow-Solution` value |
|
|
143
176
|
| `a.spendingSummary()` | Rolling-24h paid spend so far: `{ dailyUsd, calls, byHost, limits }` |
|
|
144
177
|
| `a.clearCache()` | Drop the in-memory result cache |
|
|
145
178
|
|
|
179
|
+
## Response size ceiling
|
|
180
|
+
|
|
181
|
+
You are calling strangers and paying them, and the seller chooses the response.
|
|
182
|
+
By the time `r.json()` resolves, a multi-gigabyte body is already in your
|
|
183
|
+
agent's memory, so this is one of the few checks that cannot be done after the
|
|
184
|
+
fact in your own code.
|
|
185
|
+
|
|
186
|
+
Every call is capped at 32MB by default. The declared `content-length` is
|
|
187
|
+
refused before a byte is read, and the stream is counted as it arrives, because
|
|
188
|
+
`content-length` is the seller's claim about their own body.
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
const a = new Agent402({ maxResponseBytes: 1_000_000 }); // 1MB everywhere
|
|
192
|
+
await a.call("hash", { text: "x" }, { maxResponseBytes: null }); // or per call
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
An oversized body throws `ResponseTooLargeError` carrying `size`, `cap`,
|
|
196
|
+
`source` and `paid`. Check `paid`: on a wallet-only tool the money moved before
|
|
197
|
+
the body arrived, so a refused response is still a spend you made.
|
|
198
|
+
|
|
146
199
|
## Spending caps (never overpay)
|
|
147
200
|
|
|
148
201
|
By default the client pays whatever a tool costs. Set optional hard ceilings and a
|
|
@@ -160,7 +213,7 @@ const a = new Agent402({
|
|
|
160
213
|
});
|
|
161
214
|
|
|
162
215
|
try {
|
|
163
|
-
await a.call("some-expensive-tool", {
|
|
216
|
+
await a.call("some-expensive-tool", { ... });
|
|
164
217
|
} catch (e) {
|
|
165
218
|
if (e instanceof SpendingLimitError) console.log(e.limit, e.priceUsd, e.cap);
|
|
166
219
|
}
|
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// agent402-client
|
|
1
|
+
// agent402-client - a tiny buyer-side client for agent402.tools (or any Agent402
|
|
2
2
|
// instance). Resolve a task to a tool, then call it with payment handled for you:
|
|
3
3
|
// - free pure-CPU tools settle with a built-in proof-of-work (no wallet, zero deps),
|
|
4
4
|
// - wallet-only tools settle via an x402-wrapped fetch you provide (@x402/fetch),
|
|
@@ -15,11 +15,16 @@
|
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
|
|
17
17
|
// Keep in lockstep with package.json. Every request the SDK issues carries
|
|
18
|
-
// `User-Agent: agent402-client/<version>`
|
|
19
|
-
// network calls
|
|
18
|
+
// `User-Agent: agent402-client/<version>` - a standard header, no extra
|
|
19
|
+
// network calls - so a seller can attribute traffic (and settled payments)
|
|
20
20
|
// to this SDK. Product token only; nothing about the caller rides along.
|
|
21
21
|
const VERSION = "0.7.0";
|
|
22
22
|
const USER_AGENT = `agent402-client/${VERSION}`;
|
|
23
|
+
// 32MB: about a hundred times any realistic response from this catalog (the
|
|
24
|
+
// largest is a base64 image at a few MB), so it cannot break a legitimate
|
|
25
|
+
// caller, while still stopping the class it exists for. A ceiling that is off
|
|
26
|
+
// by default protects nobody, so this one is on; pass null to disable it.
|
|
27
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
23
28
|
|
|
24
29
|
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; };
|
|
25
30
|
|
|
@@ -35,11 +40,14 @@ export class Agent402 {
|
|
|
35
40
|
* @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
|
|
36
41
|
* @param {string} [opts.creditsKey] a prepaid card-credits key (a402_...) from agent402.tools/credits -
|
|
37
42
|
* pays wallet-only tools by card when no payFetch is given
|
|
43
|
+
* @param {number|null} [opts.maxResponseBytes=33554432]
|
|
44
|
+
* Hard ceiling on a response body, enforced BEFORE it is parsed. null disables it.
|
|
38
45
|
*/
|
|
39
46
|
constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = globalThis.fetch,
|
|
40
|
-
maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null
|
|
47
|
+
maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null,
|
|
48
|
+
maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES } = {}) {
|
|
41
49
|
this.creditsKey = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{32,64}$/.test(creditsKey) ? creditsKey : null;
|
|
42
|
-
if (typeof fetchImpl !== "function") throw new Error("No fetch available
|
|
50
|
+
if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
|
|
43
51
|
this.baseUrl = String(baseUrl).replace(/\/$/, "");
|
|
44
52
|
this.payFetch = payFetch || null;
|
|
45
53
|
// Wrap the plain fetch so every request identifies the SDK (see USER_AGENT
|
|
@@ -50,15 +58,74 @@ export class Agent402 {
|
|
|
50
58
|
// Spending policy (defends the x402 "wallet drain via uncapped spending"
|
|
51
59
|
// failure mode): optional hard ceilings enforced BEFORE any payment is
|
|
52
60
|
// signed. A malicious or misconfigured 402 that quotes an inflated price is
|
|
53
|
-
// refused instead of paid. null = no limit (default
|
|
61
|
+
// refused instead of paid. null = no limit (default - behavior unchanged).
|
|
54
62
|
// Amounts commit to the rolling window only on a settled paid call, so a
|
|
55
63
|
// failed/blocked call never counts against the budget.
|
|
56
64
|
this._spend = {
|
|
57
65
|
maxPerCall: numOrNull(maxPerCallUsd),
|
|
58
66
|
daily: numOrNull(dailyLimitUsd),
|
|
59
67
|
perHost: numOrNull(maxPerHostUsd),
|
|
60
|
-
log: [], // [{ ts, host, usd }]
|
|
68
|
+
log: [], // [{ ts, host, usd }] - settled paid calls in the last 24h
|
|
61
69
|
};
|
|
70
|
+
// A response-size ceiling, enforced before parsing. See _readJson.
|
|
71
|
+
this.maxResponseBytes = maxResponseBytes === null ? null : (numOrNull(maxResponseBytes) ?? DEFAULT_MAX_RESPONSE_BYTES);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read a JSON body with a hard byte ceiling.
|
|
76
|
+
*
|
|
77
|
+
* WHY THIS LIVES IN THE SDK. Almost everything a buyer might want to check
|
|
78
|
+
* about a response, they can check themselves in their own code once they
|
|
79
|
+
* hold it. Not this one: by the time `r.json()` has resolved, a hostile or
|
|
80
|
+
* broken seller's multi-gigabyte body is already in the agent's memory. The
|
|
81
|
+
* SDK owns the fetch, so it is the only place the check can happen in time.
|
|
82
|
+
*
|
|
83
|
+
* That matters here more than in an ordinary HTTP client, because this SDK
|
|
84
|
+
* calls STRANGERS and pays them. The seller chooses the response.
|
|
85
|
+
*
|
|
86
|
+
* Two gates: the declared content-length is refused before a byte is read,
|
|
87
|
+
* and the actual stream is counted as it arrives, because content-length is
|
|
88
|
+
* the seller's claim about their own body and a missing or lying header must
|
|
89
|
+
* not be the thing standing between an agent and an OOM.
|
|
90
|
+
*/
|
|
91
|
+
async _readJson(r, { maxBytes, slug, paid = false } = {}) {
|
|
92
|
+
const cap = maxBytes === undefined ? this.maxResponseBytes : (maxBytes === null ? null : numOrNull(maxBytes));
|
|
93
|
+
if (cap == null) return r.json();
|
|
94
|
+
|
|
95
|
+
const declared = Number(r.headers?.get?.("content-length"));
|
|
96
|
+
if (Number.isFinite(declared) && declared > cap) {
|
|
97
|
+
throw new ResponseTooLargeError(declared, cap, { slug, paid, source: "content-length" });
|
|
98
|
+
}
|
|
99
|
+
// A response object we cannot measure at all - no stream and no text() -
|
|
100
|
+
// is a caller-supplied stub or an exotic runtime, never a hostile seller
|
|
101
|
+
// over a real fetch (a real Response has both). Degrade to an unbounded
|
|
102
|
+
// read rather than turning "I could not measure this" into a failure of a
|
|
103
|
+
// call the caller may have already paid for.
|
|
104
|
+
if ((!r.body || typeof r.body.getReader !== "function") && typeof r.text !== "function") return r.json();
|
|
105
|
+
// No readable stream but text() is available: bound the PARSE, even though
|
|
106
|
+
// the bytes have already arrived.
|
|
107
|
+
if (!r.body || typeof r.body.getReader !== "function") {
|
|
108
|
+
const text = await r.text();
|
|
109
|
+
const size = Buffer.byteLength(text, "utf8");
|
|
110
|
+
if (size > cap) throw new ResponseTooLargeError(size, cap, { slug, paid, source: "body" });
|
|
111
|
+
return JSON.parse(text);
|
|
112
|
+
}
|
|
113
|
+
const reader = r.body.getReader();
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let received = 0;
|
|
116
|
+
for (;;) {
|
|
117
|
+
const { done, value } = await reader.read();
|
|
118
|
+
if (done) break;
|
|
119
|
+
received += value.length;
|
|
120
|
+
if (received > cap) {
|
|
121
|
+
// Stop pulling immediately. Reading the rest to "see how big it is" is
|
|
122
|
+
// the failure this exists to prevent.
|
|
123
|
+
try { await reader.cancel(); } catch { /* already gone */ }
|
|
124
|
+
throw new ResponseTooLargeError(received, cap, { slug, paid, source: "stream" });
|
|
125
|
+
}
|
|
126
|
+
chunks.push(value);
|
|
127
|
+
}
|
|
128
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
62
129
|
}
|
|
63
130
|
|
|
64
131
|
async _loadCatalog() {
|
|
@@ -79,9 +146,39 @@ export class Agent402 {
|
|
|
79
146
|
return (await r.json()).results || [];
|
|
80
147
|
}
|
|
81
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Cross-seller Smart Order Router - rank tools across eligible/routable seller
|
|
151
|
+
* rows in the host's current index (local catalog plus crawled x402/MPP sellers
|
|
152
|
+
* the router considers healthy enough to route). Free; no payment, no wallet, no
|
|
153
|
+
* proof-of-work. Coverage is whatever the host's index holds at call time, not
|
|
154
|
+
* every seller on the internet.
|
|
155
|
+
*
|
|
156
|
+
* Returns the server JSON as-is (reporting only - no validation or authorization).
|
|
157
|
+
* Each result may carry server-reported `executeVia` naming a `route-execute*`
|
|
158
|
+
* tier whose underlying cap covers that row's price. `call(executeVia.tool, ...)`
|
|
159
|
+
* re-resolves an eligible match under that tier at execution time; it does not
|
|
160
|
+
* bind the discovery row's url, route, seller, or identity.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} task
|
|
163
|
+
* @param {object} [opts]
|
|
164
|
+
* @param {number} [opts.k=5] max results (1-25, server-bounded)
|
|
165
|
+
* @param {"all"|"external"|"local"} [opts.include="all"]
|
|
166
|
+
* @param {string} [opts.network] chain filter (e.g. "robinhood", "eip155:4663")
|
|
167
|
+
* @returns {Promise<{query:string, include:string, count:number, sellers:number, results:Array<object>}>}
|
|
168
|
+
*/
|
|
169
|
+
async route(task, { k = 5, include = "all", network } = {}) {
|
|
170
|
+
const top = Math.min(Math.max(parseInt(k, 10) || 5, 1), 25);
|
|
171
|
+
const includeParam = include === "external" || include === "local" ? include : "all";
|
|
172
|
+
const qs = new URLSearchParams({ q: String(task ?? ""), k: String(top), include: includeParam });
|
|
173
|
+
if (network) qs.set("network", String(network));
|
|
174
|
+
const r = await this.f(`${this.baseUrl}/api/route?${qs}`);
|
|
175
|
+
if (!r.ok) throw new Error(`route failed: HTTP ${r.status}`);
|
|
176
|
+
return r.json();
|
|
177
|
+
}
|
|
178
|
+
|
|
82
179
|
/**
|
|
83
180
|
* Resolve a task to matching multi-tool workflow templates (skill packs).
|
|
84
|
-
* Each pack composes 5
|
|
181
|
+
* Each pack composes 5-7 catalog tools into a Claude-ready task template
|
|
85
182
|
* for jobs that no single tool covers (e.g. audit a domain). Returns
|
|
86
183
|
* `[{slug, title, tagline, toolSlugs, score, url, promptName}]` (possibly
|
|
87
184
|
* empty when the lexical signal is weak). Use `getWorkflowPrompt(slug, args)`
|
|
@@ -95,7 +192,7 @@ export class Agent402 {
|
|
|
95
192
|
|
|
96
193
|
/**
|
|
97
194
|
* Fetch the rendered prompt messages for a skill pack with arguments
|
|
98
|
-
* substituted in. Same output as MCP `prompts/get`
|
|
195
|
+
* substituted in. Same output as MCP `prompts/get` - usable directly with
|
|
99
196
|
* any LLM. `args` are passed by promptArg name (see /api/skill-packs.json).
|
|
100
197
|
*/
|
|
101
198
|
async getWorkflowPrompt(slug, args = {}) {
|
|
@@ -106,11 +203,11 @@ export class Agent402 {
|
|
|
106
203
|
}
|
|
107
204
|
|
|
108
205
|
/**
|
|
109
|
-
* Live x402 leaderboard
|
|
206
|
+
* Live x402 leaderboard - the sellers earning the most USDC (or serving the
|
|
110
207
|
* most calls) on Base in the last ~24h, derived from on-chain USDC
|
|
111
208
|
* transfers. Free; no payment, no wallet, no proof-of-work. Useful when
|
|
112
209
|
* building agents that want to discover the live x402 economy beyond a
|
|
113
|
-
* single service's catalog. Hourly snapshot
|
|
210
|
+
* single service's catalog. Hourly snapshot - safe to call freely.
|
|
114
211
|
*
|
|
115
212
|
* @param {object} [opts]
|
|
116
213
|
* @param {number} [opts.limit=10] max rows (1-50)
|
|
@@ -142,7 +239,7 @@ export class Agent402 {
|
|
|
142
239
|
* Register a wallet address for Base builder code attribution. Idempotent:
|
|
143
240
|
* the same wallet always returns the same code. No authentication required.
|
|
144
241
|
*
|
|
145
|
-
* @param {string} walletAddress the caller's wallet address (e.g. "0x
|
|
242
|
+
* @param {string} walletAddress the caller's wallet address (e.g. "0x...")
|
|
146
243
|
* @param {object} [opts]
|
|
147
244
|
* @param {typeof fetch} [opts.fetchImpl] plain fetch (defaults to global fetch)
|
|
148
245
|
* @returns {Promise<{builderCode:string, walletAddress:string}>}
|
|
@@ -169,10 +266,10 @@ export class Agent402 {
|
|
|
169
266
|
* Call a tool by slug; pays automatically (PoW for free tools, x402 for
|
|
170
267
|
* wallet-only) and returns the parsed JSON result.
|
|
171
268
|
*/
|
|
172
|
-
async call(slug, params = {}, { idempotencyKey, cache = true } = {}) {
|
|
269
|
+
async call(slug, params = {}, { idempotencyKey, cache = true, maxResponseBytes } = {}) {
|
|
173
270
|
const cat = await this._loadCatalog();
|
|
174
271
|
const tool = cat.get(slug);
|
|
175
|
-
if (!tool) throw new Error(`unknown tool "${slug}"
|
|
272
|
+
if (!tool) throw new Error(`unknown tool "${slug}" - use client.find(task) to discover one`);
|
|
176
273
|
|
|
177
274
|
const cacheKey = `${slug}:${JSON.stringify(params)}`;
|
|
178
275
|
if (this._cache && cache && this._cache.has(cacheKey)) return this._cache.get(cacheKey);
|
|
@@ -180,7 +277,7 @@ export class Agent402 {
|
|
|
180
277
|
const idem = idempotencyKey || `a402-${createHash("sha256").update(`${cacheKey}:${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24)}`;
|
|
181
278
|
const send = (extraHeaders = {}, useFetch = this.f) => {
|
|
182
279
|
// UA set here too (not only in the this.f wrapper) so the x402 payFetch
|
|
183
|
-
// path
|
|
280
|
+
// path - the one that settles real payments - always carries it.
|
|
184
281
|
const headers = { "User-Agent": USER_AGENT, "Idempotency-Key": idem, ...extraHeaders };
|
|
185
282
|
let url = `${this.baseUrl}${tool.path}`;
|
|
186
283
|
const init = { method: tool.method, headers };
|
|
@@ -201,12 +298,12 @@ export class Agent402 {
|
|
|
201
298
|
// configured ceiling (per-call / rolling-24h / per-host).
|
|
202
299
|
const host = hostOf(this.baseUrl);
|
|
203
300
|
let usd = parseUsd(tool.price);
|
|
204
|
-
// The catalog price is seller-ADVERTISED
|
|
301
|
+
// The catalog price is seller-ADVERTISED - a hostile server could under-
|
|
205
302
|
// state it and then quote more in the 402. When a cap is set, preflight
|
|
206
303
|
// the 402 to learn the price the wallet will actually be asked to sign and
|
|
207
304
|
// check the cap against the larger of the two. Fail-open: if the 402 can't
|
|
208
305
|
// be read (FREE_MODE / non-402 / unparseable), fall back to the advertised
|
|
209
|
-
// price
|
|
306
|
+
// price - never block a legitimate payment on a parse miss.
|
|
210
307
|
if (this._spendCapsConfigured()) {
|
|
211
308
|
try {
|
|
212
309
|
const pre = await send();
|
|
@@ -224,9 +321,9 @@ export class Agent402 {
|
|
|
224
321
|
const r = await send({}, this.payFetch);
|
|
225
322
|
if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
|
|
226
323
|
this._spendSettle(reservation); // confirm the reservation as settled spend
|
|
227
|
-
return this._store(cacheKey, await
|
|
324
|
+
return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
|
|
228
325
|
} catch (e) {
|
|
229
|
-
this._spendRelease(reservation); // roll back
|
|
326
|
+
this._spendRelease(reservation); // roll back - nothing settled
|
|
230
327
|
throw e;
|
|
231
328
|
}
|
|
232
329
|
}
|
|
@@ -245,16 +342,16 @@ export class Agent402 {
|
|
|
245
342
|
}
|
|
246
343
|
if (!r.ok) throw new Error(`call "${slug}" failed: HTTP ${r.status}`);
|
|
247
344
|
this._spendSettle(reservation);
|
|
248
|
-
return this._store(cacheKey, await
|
|
345
|
+
return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: true }), cache);
|
|
249
346
|
} catch (e) { this._spendRelease(reservation); throw e; }
|
|
250
347
|
}
|
|
251
|
-
const r = await send(); // no wallet
|
|
252
|
-
if (r.ok) return this._store(cacheKey, await
|
|
253
|
-
throw new Error(`call "${slug}" failed: HTTP ${r.status}
|
|
348
|
+
const r = await send(); // no wallet - succeeds only on a FREE_MODE instance
|
|
349
|
+
if (r.ok) return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: false }), cache);
|
|
350
|
+
throw new Error(`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)`);
|
|
254
351
|
}
|
|
255
352
|
|
|
256
353
|
// Free (compute-payable) tool: succeeds plainly on a FREE_MODE instance,
|
|
257
|
-
// otherwise pay with a proof-of-work (fetched from /api/pow/challenge
|
|
354
|
+
// otherwise pay with a proof-of-work (fetched from /api/pow/challenge - the
|
|
258
355
|
// Agent402 server signals it via the X-Pow-Challenge header, not the 402 body).
|
|
259
356
|
let r = await send();
|
|
260
357
|
if (!r.ok) {
|
|
@@ -262,7 +359,7 @@ export class Agent402 {
|
|
|
262
359
|
r = await send({ "X-Pow-Solution": Agent402.solvePow(chal) });
|
|
263
360
|
}
|
|
264
361
|
if (!r.ok) throw new Error(`call "${slug}" failed after proof-of-work: HTTP ${r.status}`);
|
|
265
|
-
return this._store(cacheKey, await
|
|
362
|
+
return this._store(cacheKey, await this._readJson(r, { maxBytes: maxResponseBytes, slug, paid: false }), cache);
|
|
266
363
|
}
|
|
267
364
|
|
|
268
365
|
async _powChallenge(slug) {
|
|
@@ -281,7 +378,7 @@ export class Agent402 {
|
|
|
281
378
|
if (s.maxPerCall == null && s.daily == null && s.perHost == null) return;
|
|
282
379
|
if (s.maxPerCall != null && usd > s.maxPerCall) {
|
|
283
380
|
throw new SpendingLimitError(
|
|
284
|
-
`refusing to pay $${usd} for "${slug}"
|
|
381
|
+
`refusing to pay $${usd} for "${slug}" - exceeds maxPerCallUsd $${s.maxPerCall}`,
|
|
285
382
|
{ limit: "maxPerCallUsd", slug, priceUsd: usd, cap: s.maxPerCall });
|
|
286
383
|
}
|
|
287
384
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
@@ -290,7 +387,7 @@ export class Agent402 {
|
|
|
290
387
|
const spent = s.log.reduce((a, e) => a + e.usd, 0);
|
|
291
388
|
if (spent + usd > s.daily) {
|
|
292
389
|
throw new SpendingLimitError(
|
|
293
|
-
`refusing to pay $${usd} for "${slug}"
|
|
390
|
+
`refusing to pay $${usd} for "${slug}" - would bring 24h spend to $${(spent + usd).toFixed(6)}, over dailyLimitUsd $${s.daily}`,
|
|
294
391
|
{ limit: "dailyLimitUsd", slug, priceUsd: usd, spent, cap: s.daily });
|
|
295
392
|
}
|
|
296
393
|
}
|
|
@@ -298,7 +395,7 @@ export class Agent402 {
|
|
|
298
395
|
const spentHost = s.log.filter((e) => e.host === host).reduce((a, e) => a + e.usd, 0);
|
|
299
396
|
if (spentHost + usd > s.perHost) {
|
|
300
397
|
throw new SpendingLimitError(
|
|
301
|
-
`refusing to pay $${usd} for "${slug}"
|
|
398
|
+
`refusing to pay $${usd} for "${slug}" - would bring 24h spend to ${host} to $${(spentHost + usd).toFixed(6)}, over maxPerHostUsd $${s.perHost}`,
|
|
302
399
|
{ limit: "maxPerHostUsd", slug, host, priceUsd: usd, spent: spentHost, cap: s.perHost });
|
|
303
400
|
}
|
|
304
401
|
}
|
|
@@ -330,7 +427,7 @@ export class Agent402 {
|
|
|
330
427
|
if (i >= 0) this._spend.log.splice(i, 1);
|
|
331
428
|
}
|
|
332
429
|
|
|
333
|
-
/** Rolling-24h spend summary (settled paid calls only)
|
|
430
|
+
/** Rolling-24h spend summary (settled paid calls only) - for observability. */
|
|
334
431
|
spendingSummary() {
|
|
335
432
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
336
433
|
const log = this._spend.log.filter((e) => e.ts >= cutoff && !e.pending);
|
|
@@ -347,6 +444,20 @@ export class Agent402 {
|
|
|
347
444
|
|
|
348
445
|
/** Thrown when a paid call would exceed a configured spending ceiling. The call
|
|
349
446
|
* is refused BEFORE any payment is signed, so no funds move. */
|
|
447
|
+
/** A response body over the ceiling, refused BEFORE it was parsed.
|
|
448
|
+
*
|
|
449
|
+
* `paid` matters: on a wallet-only tool the money has already moved by the
|
|
450
|
+
* time the body arrives, so a caller must be able to tell "I was charged and
|
|
451
|
+
* the seller sent something unusable" from "nothing happened". Losing that
|
|
452
|
+
* distinction would turn a refused response into a silently forgotten spend. */
|
|
453
|
+
export class ResponseTooLargeError extends Error {
|
|
454
|
+
constructor(size, cap, { slug, paid, source } = {}) {
|
|
455
|
+
super(`response for "${slug}" is ${size} bytes, over the ${cap}-byte ceiling (${source}) - refused before parsing${paid ? "; this call WAS paid" : ""}`);
|
|
456
|
+
this.name = "ResponseTooLargeError";
|
|
457
|
+
Object.assign(this, { size, cap, slug, paid: Boolean(paid), source });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
350
461
|
export class SpendingLimitError extends Error {
|
|
351
462
|
constructor(message, details = {}) {
|
|
352
463
|
super(message);
|
|
@@ -355,7 +466,7 @@ export class SpendingLimitError extends Error {
|
|
|
355
466
|
}
|
|
356
467
|
}
|
|
357
468
|
|
|
358
|
-
// Parse the USD amount an x402 `402` challenge actually requires
|
|
469
|
+
// Parse the USD amount an x402 `402` challenge actually requires - the max across
|
|
359
470
|
// the offered rails. x402 is stablecoin-settled (USDC/USDG), so
|
|
360
471
|
// atomic / 10^decimals ≈ USD. Returns null if the body isn't a parseable 402
|
|
361
472
|
// challenge, so the caller fails open to the advertised catalog price.
|
|
@@ -383,7 +494,7 @@ function parseUsd(price) {
|
|
|
383
494
|
function hostOf(url) { try { return new URL(url).host; } catch { return String(url); } }
|
|
384
495
|
|
|
385
496
|
/**
|
|
386
|
-
* Restrict + order which chains an @x402 client will pay on (duck-typed
|
|
497
|
+
* Restrict + order which chains an @x402 client will pay on (duck-typed - any
|
|
387
498
|
* client version with createPaymentPayload works, zero new dependencies).
|
|
388
499
|
* Multi-chain sellers list Base first, so an unmodified client effectively
|
|
389
500
|
* always settles there; this makes rails like USDG on Robinhood Chain
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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",
|