agent402-openclaw 0.4.2 → 0.4.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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # agent402-openclaw
2
2
 
3
+ Part of [Agent402](https://agent402.tools), the open-source paid door for AI agents.
4
+
3
5
  Agent402 as an [OpenClaw](https://openclaw.ai) model provider: auto-routed and
4
6
  explicit frontier models at a **flat per-call price**, paid **by card** (a prepaid
5
7
  credits key, no wallet) or in **USDC over x402** from a wallet. The same key and
@@ -46,6 +48,7 @@ client at `http://127.0.0.1:8412/v1` with model `auto`.
46
48
  ```bash
47
49
  npm i @x402/fetch @x402/evm viem
48
50
  export AGENT402_WALLET_KEY=0x... # an EVM key holding USDC on Base
51
+ export AGENT402_MAX_PER_CALL_USD=2 # per-call ceiling checked before signing (default 2, the gateway's metered cap; 0 disables)
49
52
  ```
50
53
 
51
54
  With no credits key present the proxy signs an x402 payment per call from that
@@ -54,11 +57,11 @@ wallet. The key never leaves the machine.
54
57
  Two ways the wallet can pay a metered call:
55
58
 
56
59
  - **exact** (default): the 402 quotes the call from its body (input + your
57
- `max_tokens` at the model's list price, x1.15) and the wallet pays that quote.
60
+ `max_tokens`) and the wallet pays that quote.
58
61
  - **upto, actual usage**: run `agent402-openclaw permit2-approve` once (one USDC
59
62
  approval transaction on Base; the wallet needs a little ETH for gas). From
60
63
  then on the proxy authorizes the quote as a CEILING and the gateway settles
61
- what the call actually cost x1.15, so a short answer costs a fraction of the
64
+ what the call actually used, so a short answer costs a fraction of the
62
65
  quote. `agent402-openclaw doctor` says which mode the wallet is in.
63
66
 
64
67
  Card / credits buyers already pay actual usage on metered calls.
@@ -68,12 +71,14 @@ Card / credits buyers already pay actual usage on metered calls.
68
71
  `auto` (routed per prompt, flat $0.01/call) plus every id from
69
72
  `GET https://agent402.tools/v1/models`. Explicit models are **metered by default**:
70
73
  the proxy sends them to the gateway's metered route, where each request is quoted
71
- from its body (exact-BPE input plus your `max_tokens` at the model's list price,
72
- times 1.15, from $0.001, capped at $2 per call), so a short call costs a fraction
74
+ from its body (input plus your `max_tokens`, from $0.001, capped at $2 per
75
+ call), so a short call costs a fraction
73
76
  of a cent and a long one pays for what it asks. `--flat` (or
74
77
  `AGENT402_PRICING=flat`) keeps every model on its flat per-call tier instead.
75
- Either way OpenClaw's per-token cost fields stay zero; the price is per call. A model sent to the wrong tier is answered with a
76
- 400 naming the right one; nothing is charged. A client-supplied `Idempotency-Key`
78
+ Either way OpenClaw's per-token cost fields stay zero; the price is per call. A model sent to another flat
79
+ tier's route is priced at its home tier: the 402 quotes that tier's price, and the paid call is served under
80
+ that tier's caps (the answer says so in `agent402_tier`). The router, grounded and stealth tiers still answer a
81
+ 400 naming the right route; nothing is charged. A client-supplied `Idempotency-Key`
77
82
  is passed through (an x402 retry with the same key replays the paid answer);
78
83
  without one, each call is its own payment.
79
84
 
package/index.js CHANGED
@@ -47,10 +47,44 @@ export const PERMIT2_READY_MIN = 10n ** 12n; // 1,000,000 USDC (6 decimals): a r
47
47
 
48
48
  /** Which 402 accept to pay: upto on Base when the client can (settle-actual),
49
49
  * else exact on Base, else the first the client supports. Pure. */
50
- export function selectAccept(accepts, { preferUpto = true } = {}) {
50
+ /** Per-call ceiling in USD, enforced in the accept selector so an over-cap quote
51
+ * is refused BEFORE anything is signed. @x402/core 2.23+ ships a $1
52
+ * pegged-assets default on every client; this package turns that off (it would
53
+ * refuse USDG and gave an opaque vendor error on any metered turn over $1) and
54
+ * carries its own ceiling under its own name. The default is $2, the gateway's
55
+ * own metered quote cap (GATEWAY_METERED_MAX_QUOTE_USD), so no call the gateway
56
+ * would serve is refused here while a runaway quote still stops at the wallet.
57
+ * `maxPerCallUsd` in the plugin config or AGENT402_MAX_PER_CALL_USD; `0`/`off`
58
+ * disables; a malformed value reads as the default, never as off. */
59
+ export const DEFAULT_MAX_PER_CALL_USD = 2;
60
+ export function maxPerCallUsd(pluginConfig = {}) {
61
+ const raw = String(pluginConfig.maxPerCallUsd ?? process.env.AGENT402_MAX_PER_CALL_USD ?? "").trim().toLowerCase();
62
+ if (raw === "0" || raw === "off") return Infinity;
63
+ const n = Number(raw);
64
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PER_CALL_USD;
65
+ }
66
+ /** Dollar value of an accept's quote: `amount` (v2) or `maxAmountRequired` (v1)
67
+ * in base units over the asset's `extra.decimals` (USDC on Base: 6). Unknown
68
+ * shape reads as null, and null is REFUSED under a finite ceiling: a quote we
69
+ * cannot read is not one we sign. */
70
+ export function acceptUsd(accept) {
71
+ const raw = accept?.amount ?? accept?.maxAmountRequired;
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
+ }
77
+ export function selectAccept(accepts, { preferUpto = true, maxUsd = Infinity } = {}) {
51
78
  const list = Array.isArray(accepts) ? accepts : [];
52
- if (preferUpto) { const u = list.find((a) => a?.scheme === "upto" && a?.network === BASE_CAIP2); if (u) return u; }
53
- return list.find((a) => a?.scheme === "exact" && a?.network === BASE_CAIP2) || list[0];
79
+ const pick = (preferUpto && list.find((a) => a?.scheme === "upto" && a?.network === BASE_CAIP2))
80
+ || list.find((a) => a?.scheme === "exact" && a?.network === BASE_CAIP2) || list[0];
81
+ if (pick && Number.isFinite(maxUsd)) {
82
+ const usd = acceptUsd(pick);
83
+ if (usd == null || usd > maxUsd) {
84
+ throw new Error(`agent402-openclaw: quote ${usd == null ? "unreadable" : `$${usd}`} exceeds the per-call ceiling $${maxUsd} (maxPerCallUsd / AGENT402_MAX_PER_CALL_USD; 0 disables)`);
85
+ }
86
+ }
87
+ return pick;
54
88
  }
55
89
  export const uptoReady = (allowance) => { try { return BigInt(allowance ?? 0) >= PERMIT2_READY_MIN; } catch { return false; } };
56
90
 
@@ -70,7 +104,9 @@ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
70
104
  const pub = createPublicClient({ chain: base, transport: http((pluginConfig.baseRpc || process.env.AGENT402_BASE_RPC || DEFAULT_BASE_RPC).trim()) });
71
105
  const allowance = await pub.readContract(getPermit2AllowanceReadParams({ tokenAddress: USDC_BASE, ownerAddress: account.address }));
72
106
  upto = uptoReady(allowance);
73
- const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: upto }));
107
+ const maxUsd = maxPerCallUsd(pluginConfig);
108
+ const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: upto, maxUsd }));
109
+ client.setSpendControls?.(false); // the vendor default ($1, pegged assets only) would refuse USDG and any quote over $1; selectAccept enforces our own maxPerCallUsd (same $1 default) instead
74
110
  registerExactEvmScheme(client, { signer });
75
111
  if (upto) client.register(BASE_CAIP2, new UptoEvmScheme(signer));
76
112
  log(upto
@@ -80,7 +116,8 @@ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
80
116
  } catch (e) {
81
117
  // The allowance read or the upto scheme failed: exact still works.
82
118
  log(`[agent402-openclaw] upto unavailable (${String(e?.message || e).slice(0, 120)}) - paying exact`);
83
- const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: false }));
119
+ const client = new x402Client((v, accepts) => selectAccept(accepts, { preferUpto: false, maxUsd: maxPerCallUsd(pluginConfig) }));
120
+ client.setSpendControls?.(false); // the vendor default ($1, pegged assets only) would refuse USDG and any quote over $1; selectAccept enforces our own maxPerCallUsd (same $1 default) instead
84
121
  registerExactEvmScheme(client, { signer });
85
122
  return wrapFetchWithPayment(fetch, client);
86
123
  }
package/models.js CHANGED
@@ -77,7 +77,13 @@ export function routesFromCatalog(catalog, { pricing = "metered" } = {}) {
77
77
 
78
78
  /** Preferred primaries, in order: capable tool-callers that are cheap when
79
79
  * metered. Only consulted among routes that can hold OpenClaw's prompt. */
80
- const PRIMARY_PREFERENCE = ["anthropic/claude-haiku-4.5", "openai/gpt-4.1-mini", "google/gemini-2.5-flash", "openai/gpt-4o-mini"];
80
+ // Exported so the gateway's live-catalog guard (scripts/test-gateway-model-ids.js)
81
+ // fails the day any of these ids carries an expiration date upstream: the first
82
+ // pick is haiku-4.5, whose retirement floor is 2026-10-15 with no notice yet.
83
+ // google/gemini-2.5-flash left this list 2026-09-18: the live catalog stamps it
84
+ // expiration_date 2026-10-20, which is exactly what that guard was written to
85
+ // catch on its first run.
86
+ export const PRIMARY_PREFERENCE = ["anthropic/claude-haiku-4.5", "openai/gpt-4.1-mini", "openai/gpt-4o-mini"];
81
87
 
82
88
  /** The route OpenClaw should make its primary model: the first preferred id
83
89
  * that fits OPENCLAW_MIN_INPUT_CHARS, else the cheapest fitting one, else
@@ -36,6 +36,13 @@
36
36
  "flat"
37
37
  ],
38
38
  "description": "metered (default): explicit models pay a per-request quote; flat: every model on its flat tier."
39
+ },
40
+ "maxPerCallUsd": {
41
+ "type": [
42
+ "number",
43
+ "string"
44
+ ],
45
+ "description": "Per-call ceiling in USD for wallet (x402) payments, checked before signing (default 2, the gateway's metered quote cap; 0 or \"off\" disables). Also AGENT402_MAX_PER_CALL_USD."
39
46
  }
40
47
  }
41
48
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-openclaw",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "OpenClaw model provider for Agent402: auto-routed and explicit LLM models at a flat per-call price, paid by card (prepaid credits key) or in USDC over x402 through a local proxy. Zero dependencies; x402 wallet payment is an optional peer.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/proxy.js CHANGED
@@ -20,6 +20,15 @@
20
20
  // own 402->pay retry), which does NOT make two client calls one payment.
21
21
  // Streams pass through byte for byte.
22
22
  //
23
+ // Refusals: a refused paid call's 402 from an x402 v2 seller carries the whole
24
+ // payment offer in its JSON body (Agent402 mirrors its PAYMENT-REQUIRED header
25
+ // there) beside the refusal's own reason, hint and retry. OpenClaw's model
26
+ // client builds its error message from `error.message`, else from the whole
27
+ // body, so relaying that body put kilobytes of accepts and schemas into the
28
+ // agent's error text in place of the one sentence that says what to fix. Such
29
+ // a body is answered in the OpenAI error shape instead (refusalAsOpenAIError);
30
+ // every other upstream body passes through byte for byte.
31
+ //
23
32
  // Loopback only, and browser-hostile on purpose: any web page can POST to
24
33
  // 127.0.0.1 with a "simple" no-cors request, and this proxy spends the
25
34
  // user's key, so a request carrying an Origin header (browsers always send
@@ -49,6 +58,27 @@ function readBody(req, limit = MAX_BODY) {
49
58
  });
50
59
  }
51
60
 
61
+ // The x402 offer keys a refusal body may carry beside its own fields.
62
+ const OFFER_KEYS = ["x402Version", "resource", "accepts", "extensions"];
63
+ const text = (v) => (typeof v === "string" && v.trim() ? v : null);
64
+
65
+ /** An upstream error body that carries an x402 offer (a numeric x402Version),
66
+ * as an OpenAI-shaped error: the explanation (a problem's detail, else the
67
+ * hint, else the error sentence) as error.message, the retry class or reason as
68
+ * error.code, the refusal's own fields beside it, and no offer. Null for any
69
+ * other body, which the caller relays unchanged. */
70
+ export function refusalAsOpenAIError(status, doc) {
71
+ if (!doc || typeof doc !== "object" || Array.isArray(doc) || typeof doc.x402Version !== "number") return null;
72
+ const ours = { ...doc };
73
+ for (const k of OFFER_KEYS) delete ours[k];
74
+ const { error, ...fields } = ours;
75
+ const message = text(fields.detail) || text(fields.hint) || text(error) || text(fields.title) || `Agent402 answered HTTP ${status}`;
76
+ return {
77
+ error: { message, type: status === 402 ? "payment_required" : "upstream_error", code: text(fields.retry) || text(fields.reason) || null },
78
+ ...fields,
79
+ };
80
+ }
81
+
52
82
  const json = (res, status, obj, headers = {}) => {
53
83
  res.writeHead(status, { "content-type": "application/json; charset=utf-8", ...headers });
54
84
  res.end(JSON.stringify(obj));
@@ -113,6 +143,18 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
113
143
  stats.forwarded++;
114
144
  const passthrough = {};
115
145
  for (const h of ["content-type", "x-credits-balance", "payment-receipt", "x-cache", "cache-control"]) { const v = up.headers.get(h); if (v) passthrough[h] = v; }
146
+ if (!up.ok && /json/i.test(passthrough["content-type"] || "")) {
147
+ const raw = await up.text();
148
+ let doc = null;
149
+ try { doc = JSON.parse(raw); } catch { /* not JSON after all: relayed as sent */ }
150
+ const mapped = refusalAsOpenAIError(up.status, doc);
151
+ if (mapped) {
152
+ const { "content-type": _ct, ...rest } = passthrough;
153
+ return json(res, up.status, mapped, rest);
154
+ }
155
+ res.writeHead(up.status, passthrough);
156
+ return res.end(raw);
157
+ }
116
158
  res.writeHead(up.status, passthrough);
117
159
  if (!up.body) return res.end();
118
160
  const reader = up.body.getReader();