agent402-openclaw 0.5.2 → 0.5.4
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 +4 -1
- package/index.js +10 -13
- package/known-assets.js +120 -0
- package/package.json +3 -2
- package/proxy.js +54 -3
package/README.md
CHANGED
|
@@ -76,7 +76,10 @@ export AGENT402_MAX_PER_CALL_USD=2 # per-call ceiling checked before signing (
|
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
With no credits key present the proxy signs an x402 payment per call from that
|
|
79
|
-
wallet. The key never leaves the machine.
|
|
79
|
+
wallet. The key never leaves the machine. The ceiling is checked on the payment
|
|
80
|
+
option that is signed, priced from the known decimals of its asset (USDC or
|
|
81
|
+
USDG on a known network); an option in any other asset is not signed while a
|
|
82
|
+
ceiling is set.
|
|
80
83
|
|
|
81
84
|
Two ways the wallet can pay a metered call:
|
|
82
85
|
|
package/index.js
CHANGED
|
@@ -8,6 +8,8 @@ import { join } from "node:path";
|
|
|
8
8
|
import { buildProvider, setActiveProxy, setCachedRoutes, DEFAULT_PORT, PLUGIN_ID } from "./provider.js";
|
|
9
9
|
import { startProxy, loadRoutes, DEFAULT_UPSTREAM } from "./proxy.js";
|
|
10
10
|
import { stripTrailingSlashes } from "./models.js";
|
|
11
|
+
import { acceptUsd } from "./known-assets.js";
|
|
12
|
+
export { acceptUsd, KNOWN_ASSETS } from "./known-assets.js";
|
|
11
13
|
|
|
12
14
|
export const STATE_DIR = () => join(process.env.AGENT402_OPENCLAW_HOME || join(homedir(), ".openclaw"), "agent402");
|
|
13
15
|
export const CREDITS_KEY_FILE = () => join(STATE_DIR(), "credits.key");
|
|
@@ -63,17 +65,12 @@ export function maxPerCallUsd(pluginConfig = {}) {
|
|
|
63
65
|
const n = Number(raw);
|
|
64
66
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PER_CALL_USD;
|
|
65
67
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (raw == null || !/^\d+$/.test(String(raw))) return null;
|
|
73
|
-
const dec = Number(accept?.extra?.decimals ?? 6);
|
|
74
|
-
if (!Number.isInteger(dec) || dec < 0 || dec > 36) return null;
|
|
75
|
-
return Number(raw) / 10 ** dec;
|
|
76
|
-
}
|
|
68
|
+
// The dollar value of an accept's quote is acceptUsd (./known-assets.js):
|
|
69
|
+
// `amount` (v2) or `maxAmountRequired` (v1) in base units over the KNOWN
|
|
70
|
+
// decimals of its asset, never a decimals value the seller sends, since the
|
|
71
|
+
// signer signs the raw amount. An accept in an asset outside the table, or
|
|
72
|
+
// with no whole-number amount, reads as null, and null is REFUSED under a
|
|
73
|
+
// finite ceiling: a quote we cannot price is not one we sign.
|
|
77
74
|
export function selectAccept(accepts, { preferUpto = true, maxUsd = Infinity } = {}) {
|
|
78
75
|
const list = Array.isArray(accepts) ? accepts : [];
|
|
79
76
|
const pick = (preferUpto && list.find((a) => a?.scheme === "upto" && a?.network === BASE_CAIP2))
|
|
@@ -106,7 +103,7 @@ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
|
|
|
106
103
|
upto = uptoReady(allowance);
|
|
107
104
|
const maxUsd = maxPerCallUsd(pluginConfig);
|
|
108
105
|
const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: upto, maxUsd }));
|
|
109
|
-
client.setSpendControls?.(false); // the vendor default ($1,
|
|
106
|
+
client.setSpendControls?.(false); // the vendor default ($1, its own default assets only) would refuse USDG and any quote over $1; selectAccept holds the accept that is signed to our own maxPerCallUsd instead
|
|
110
107
|
registerExactEvmScheme(client, { signer });
|
|
111
108
|
if (upto) client.register(BASE_CAIP2, new UptoEvmScheme(signer));
|
|
112
109
|
log(upto
|
|
@@ -117,7 +114,7 @@ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
|
|
|
117
114
|
// The allowance read or the upto scheme failed: exact still works.
|
|
118
115
|
log(`[agent402-openclaw] upto unavailable (${String(e?.message || e).slice(0, 120)}) - paying exact`);
|
|
119
116
|
const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: false, maxUsd: maxPerCallUsd(pluginConfig) }));
|
|
120
|
-
client.setSpendControls?.(false); // the vendor default ($1,
|
|
117
|
+
client.setSpendControls?.(false); // the vendor default ($1, its own default assets only) would refuse USDG and any quote over $1; selectAccept holds the accept that is signed to our own maxPerCallUsd instead
|
|
121
118
|
registerExactEvmScheme(client, { signer });
|
|
122
119
|
return wrapFetchWithPayment(fetch, client);
|
|
123
120
|
}
|
package/known-assets.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Known payment assets, and the USD value of an x402 accept priced from them.
|
|
2
|
+
//
|
|
3
|
+
// An x402 "exact" signer signs the accept's raw base-unit `amount` for the
|
|
4
|
+
// accept's `asset`. A spending cap therefore has to price that amount with the
|
|
5
|
+
// asset's real decimals, and a decimals hint the seller sends beside it (for
|
|
6
|
+
// example `extra.decimals`) is not evidence of anything. So the price of an
|
|
7
|
+
// accept comes from this table alone: the stablecoins each network settles on
|
|
8
|
+
// the Agent402 rails, with their on-chain decimals. An accept whose
|
|
9
|
+
// (network, asset) pair is not in the table cannot be priced, and a buyer with
|
|
10
|
+
// a cap does not sign it.
|
|
11
|
+
//
|
|
12
|
+
// The same file ships in agent402-client, agent402-mcp and agent402-openclaw;
|
|
13
|
+
// the parent repo's scripts/test-known-assets.js keeps the copies identical and
|
|
14
|
+
// checks the table against the assets the server offers.
|
|
15
|
+
|
|
16
|
+
/** network (CAIP-2) -> the assets a buyer may price there. */
|
|
17
|
+
export const KNOWN_ASSETS = Object.freeze({
|
|
18
|
+
// EVM: USDC (native Circle) unless named otherwise.
|
|
19
|
+
"eip155:8453": [{ asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, symbol: "USDC" }], // Base
|
|
20
|
+
"eip155:84532": [{ asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", decimals: 6, symbol: "USDC" }], // Base Sepolia
|
|
21
|
+
"eip155:137": [{ asset: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", decimals: 6, symbol: "USDC" }], // Polygon
|
|
22
|
+
"eip155:42161": [{ asset: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6, symbol: "USDC" }], // Arbitrum
|
|
23
|
+
"eip155:143": [{ asset: "0x754704Bc059F8C67012fEd69BC8A327a5aafb603", decimals: 6, symbol: "USDC" }], // Monad
|
|
24
|
+
"eip155:42220": [{ asset: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", decimals: 6, symbol: "USDC" }], // Celo
|
|
25
|
+
"eip155:43114": [{ asset: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", decimals: 6, symbol: "USDC" }], // Avalanche
|
|
26
|
+
"eip155:1329": [{ asset: "0xe15fC38F6D8c56aF07bbCBe3BAf5708A2Bf42392", decimals: 6, symbol: "USDC" }], // Sei
|
|
27
|
+
"eip155:10": [{ asset: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", decimals: 6, symbol: "USDC" }], // Optimism
|
|
28
|
+
"eip155:5042": [{ asset: "0x3600000000000000000000000000000000000000", decimals: 6, symbol: "USDC" }], // Arc
|
|
29
|
+
"eip155:4663": [{ asset: "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168", decimals: 6, symbol: "USDG" }], // Robinhood Chain
|
|
30
|
+
// Solana: the USDC mint.
|
|
31
|
+
"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": [{ asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", decimals: 6, symbol: "USDC" }],
|
|
32
|
+
"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": [{ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", decimals: 6, symbol: "USDC" }], // devnet
|
|
33
|
+
// Stellar: the USDC Stellar Asset Contract (7 decimals on Stellar).
|
|
34
|
+
"stellar:pubnet": [{ asset: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", decimals: 7, symbol: "USDC" }],
|
|
35
|
+
// Algorand: USDC is ASA 31566704.
|
|
36
|
+
"algorand:wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=": [{ asset: "31566704", decimals: 6, symbol: "USDC" }],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const EVM_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
40
|
+
// EVM addresses compare without case (checksum casing is presentation);
|
|
41
|
+
// base58, Stellar and Algorand identifiers compare exactly.
|
|
42
|
+
const sameAsset = (a, b) => (EVM_ADDRESS.test(a) && EVM_ADDRESS.test(b) ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
43
|
+
|
|
44
|
+
/** The table entry for (network, asset), or null when it is not a known asset. */
|
|
45
|
+
export function knownAsset(network, asset) {
|
|
46
|
+
if (typeof network !== "string" || typeof asset !== "string" || !asset) return null;
|
|
47
|
+
const list = KNOWN_ASSETS[network];
|
|
48
|
+
if (!list) return null;
|
|
49
|
+
return list.find((e) => sameAsset(e.asset, asset)) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The USD value of one accept: its base-unit amount (`amount` in x402 v2,
|
|
53
|
+
* `maxAmountRequired` in v1) over the KNOWN decimals of its asset. null when
|
|
54
|
+
* the asset is not known or the amount is not a whole number of base units. */
|
|
55
|
+
export function acceptUsd(accept) {
|
|
56
|
+
if (!accept || typeof accept !== "object") return null;
|
|
57
|
+
const raw = accept.amount ?? accept.maxAmountRequired;
|
|
58
|
+
if (raw == null || !/^\d+$/.test(String(raw))) return null;
|
|
59
|
+
const entry = knownAsset(accept.network, accept.asset);
|
|
60
|
+
if (!entry) return null;
|
|
61
|
+
return Number(raw) / 10 ** entry.decimals;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The USD values of a 402's accepts: `max` and `min` over the accepts that can
|
|
65
|
+
* be priced, and `unknown`, the count that cannot. null when there are none. */
|
|
66
|
+
export function quoteOf(accepts) {
|
|
67
|
+
if (!Array.isArray(accepts) || !accepts.length) return null;
|
|
68
|
+
let max = null, min = null, unknown = 0;
|
|
69
|
+
for (const a of accepts) {
|
|
70
|
+
const usd = acceptUsd(a);
|
|
71
|
+
if (usd == null) { unknown++; continue; }
|
|
72
|
+
if (max == null || usd > max) max = usd;
|
|
73
|
+
if (min == null || usd < min) min = usd;
|
|
74
|
+
}
|
|
75
|
+
return { max, min, unknown, total: accepts.length };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Enforce a USD allowance at signing on an @x402/core x402Client.
|
|
79
|
+
*
|
|
80
|
+
* `allowance()` is the most this buyer may sign for the payment being built
|
|
81
|
+
* (a per-call cap, a remaining budget, or the smaller of the two; Infinity
|
|
82
|
+
* for none). A registered policy drops every accept that cannot be priced or
|
|
83
|
+
* is over the allowance, so the client selects among the ones that fit, and a
|
|
84
|
+
* hook before payload creation checks the selected accept again and passes
|
|
85
|
+
* its USD value to `onSign`. A refusal throws (from the policy) or aborts
|
|
86
|
+
* (from the hook) before any signature exists; `onRefuse` hears why first. */
|
|
87
|
+
export function guardSigning(client, { allowance, onSign = null, onRefuse = null }) {
|
|
88
|
+
if (!client || typeof client.registerPolicy !== "function" || typeof client.onBeforePaymentCreation !== "function") {
|
|
89
|
+
throw new TypeError("guardSigning needs an @x402/core x402Client (registerPolicy and onBeforePaymentCreation)");
|
|
90
|
+
}
|
|
91
|
+
if (typeof allowance !== "function") throw new TypeError("guardSigning needs an allowance() function");
|
|
92
|
+
const refuse = (detail) => { try { onRefuse?.(detail); } catch { /* the refusal stands */ } return detail; };
|
|
93
|
+
client.registerPolicy((version, accepts) => {
|
|
94
|
+
const cap = allowance();
|
|
95
|
+
const kept = accepts.filter((a) => { const usd = acceptUsd(a); return usd != null && usd <= cap; });
|
|
96
|
+
if (!kept.length) {
|
|
97
|
+
const q = quoteOf(accepts);
|
|
98
|
+
const d = refuse({ reason: q && q.min != null ? "over-allowance" : "unknown-asset", priceUsd: q ? q.min : null, allowanceUsd: cap });
|
|
99
|
+
throw new Error(d.reason === "unknown-asset"
|
|
100
|
+
? "spending guard: no payment option is in a known asset, so none can be priced; nothing was signed"
|
|
101
|
+
: `spending guard: the cheapest payment option asks $${d.priceUsd}, above the $${cap} this buyer may sign; nothing was signed`);
|
|
102
|
+
}
|
|
103
|
+
return kept;
|
|
104
|
+
});
|
|
105
|
+
client.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
|
|
106
|
+
const usd = acceptUsd(r);
|
|
107
|
+
const cap = allowance();
|
|
108
|
+
if (usd == null) {
|
|
109
|
+
refuse({ reason: "unknown-asset", priceUsd: null, allowanceUsd: cap });
|
|
110
|
+
return { abort: true, reason: "spending guard: the selected payment option is not in a known asset; nothing was signed" };
|
|
111
|
+
}
|
|
112
|
+
if (usd > cap) {
|
|
113
|
+
refuse({ reason: "over-allowance", priceUsd: usd, allowanceUsd: cap });
|
|
114
|
+
return { abort: true, reason: `spending guard: the selected payment option asks $${usd}, above the $${cap} this buyer may sign; nothing was signed` };
|
|
115
|
+
}
|
|
116
|
+
onSign?.(usd, r);
|
|
117
|
+
return undefined;
|
|
118
|
+
});
|
|
119
|
+
return client;
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent402-openclaw",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "OpenClaw model provider for Agent402: an auto-routed tier at a flat per-call price and explicit LLM models priced per request, paid in USDC over x402 or with a prepaid credits key through a local proxy. Zero dependencies; x402 wallet payment is an optional peer.",
|
|
3
|
+
"version": "0.5.4",
|
|
4
|
+
"description": "OpenClaw model provider for Agent402: an auto-routed tier at a flat per-call price and explicit LLM models priced per request, paid in USDC over x402, or with a prepaid credits key already issued, through a local proxy. Zero dependencies; x402 wallet payment is an optional peer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"index.js",
|
|
17
|
+
"known-assets.js",
|
|
17
18
|
"provider.js",
|
|
18
19
|
"models.js",
|
|
19
20
|
"proxy.js",
|
package/proxy.js
CHANGED
|
@@ -47,9 +47,52 @@ export const DEFAULT_UPSTREAM = "https://agent402.tools";
|
|
|
47
47
|
export const PKG_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
|
|
48
48
|
const MAX_BODY = 2 * 1024 * 1024;
|
|
49
49
|
|
|
50
|
+
// The model catalog read (GET /v1/models) settles nothing, so a 503 the
|
|
51
|
+
// server sheds under load is safe to repeat. It is retried only when it names
|
|
52
|
+
// its own delay (a Retry-After header or a retryAfterSeconds body field), at
|
|
53
|
+
// most DISCOVERY_ATTEMPTS tries in all, each wait capped at
|
|
54
|
+
// DISCOVERY_MAX_WAIT_MS. A paid call never goes through here: a repeat of
|
|
55
|
+
// one could settle twice.
|
|
56
|
+
const DISCOVERY_ATTEMPTS = 3;
|
|
57
|
+
const DISCOVERY_MAX_WAIT_MS = 5000;
|
|
58
|
+
|
|
59
|
+
async function shedRetryWaitMs(r) {
|
|
60
|
+
if (!r || r.status !== 503) return null;
|
|
61
|
+
const header = r.headers && typeof r.headers.get === "function" ? r.headers.get("retry-after") : null;
|
|
62
|
+
let seconds = header != null && String(header).trim() !== "" ? Number(header) : NaN;
|
|
63
|
+
if (!Number.isFinite(seconds)) {
|
|
64
|
+
try {
|
|
65
|
+
const body = await (typeof r.clone === "function" ? r.clone() : r).json();
|
|
66
|
+
seconds = Number(body && body.retryAfterSeconds);
|
|
67
|
+
} catch { seconds = NaN; }
|
|
68
|
+
}
|
|
69
|
+
if (!Number.isFinite(seconds) || seconds < 0) return null;
|
|
70
|
+
return Math.min(seconds * 1000, DISCOVERY_MAX_WAIT_MS);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Resolves with the 2xx response, or throws `${label}: HTTP <status>` (plus
|
|
74
|
+
// "after N attempts" when it retried) with `.status` and `.attempts` set.
|
|
75
|
+
// `init` may be a function, so each attempt gets its own timeout signal.
|
|
76
|
+
async function discoveryFetch(fetchImpl, label, url, init) {
|
|
77
|
+
let r;
|
|
78
|
+
let attempts = 0;
|
|
79
|
+
for (;;) {
|
|
80
|
+
attempts++;
|
|
81
|
+
r = await fetchImpl(url, typeof init === "function" ? init() : init);
|
|
82
|
+
if (r.ok) return r;
|
|
83
|
+
if (attempts >= DISCOVERY_ATTEMPTS) break;
|
|
84
|
+
const wait = await shedRetryWaitMs(r);
|
|
85
|
+
if (wait == null) break;
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
87
|
+
}
|
|
88
|
+
const err = new Error(`${label}: HTTP ${r.status}${attempts > 1 ? ` after ${attempts} attempts` : ""}`);
|
|
89
|
+
err.status = r.status;
|
|
90
|
+
err.attempts = attempts;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
|
|
50
94
|
export async function loadRoutes(upstream, fetchImpl = fetch, { pricing = "metered" } = {}) {
|
|
51
|
-
const r = await fetchImpl
|
|
52
|
-
if (!r.ok) throw new Error(`GET /v1/models -> HTTP ${r.status}`);
|
|
95
|
+
const r = await discoveryFetch(fetchImpl, "GET /v1/models", `${upstream}/v1/models`, () => ({ headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) }));
|
|
53
96
|
return routesFromCatalog(await r.json(), { pricing });
|
|
54
97
|
}
|
|
55
98
|
|
|
@@ -130,7 +173,15 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
|
|
|
130
173
|
const clientIdem = typeof req.headers["idempotency-key"] === "string" && /^[\w.:-]{8,128}$/.test(req.headers["idempotency-key"]) ? req.headers["idempotency-key"] : null;
|
|
131
174
|
const headers = { "content-type": "application/json", accept: req.headers.accept || "application/json", "idempotency-key": clientIdem || randomUUID(), "user-agent": `agent402-openclaw/${PKG_VERSION}`, ...extraHeaders };
|
|
132
175
|
if (key) headers.authorization = `Bearer ${key}`;
|
|
133
|
-
|
|
176
|
+
// A paid request never follows a redirect: followed, it would pay another
|
|
177
|
+
// origin's 402 or carry a signature (or the credits key) to another host.
|
|
178
|
+
const up = await paid(`${upstream}${endpoint}`, { method: "POST", headers, body: JSON.stringify(outbound), redirect: "manual", signal: AbortSignal.timeout(300_000) });
|
|
179
|
+
if (up.type === "opaqueredirect" || (up.status >= 300 && up.status < 400)) {
|
|
180
|
+
stats.errors++;
|
|
181
|
+
const loc = up.headers.get("location");
|
|
182
|
+
const to = loc ? new URL(loc, `${upstream}${endpoint}`).href : null;
|
|
183
|
+
return json(res, 502, { error: { message: `refused to follow a redirect on a paid call: ${upstream}${endpoint} answered HTTP ${up.status || "redirect"}${to ? ` pointing to ${to}` : ""}. Paid requests never follow redirects${to ? `; if that is the address you meant, set the upstream to ${new URL(to).origin}` : ""}.`, type: "upstream_redirect_refused" } });
|
|
184
|
+
}
|
|
134
185
|
stats.forwarded++;
|
|
135
186
|
const passthrough = {};
|
|
136
187
|
// payment-response / x-payment-response carry the x402 settlement receipt
|