agent402-openclaw 0.1.1 → 0.2.0

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
@@ -11,7 +11,7 @@ Guide: https://agent402.tools/guides/openclaw-model-provider
11
11
 
12
12
  ```bash
13
13
  openclaw plugins install agent402-openclaw
14
- agent402-openclaw setup --credits-key a402_... # buy a pack by card at https://agent402.tools/credits
14
+ AGENT402_CREDITS_KEY=a402_... agent402-openclaw setup --write # key by env (or `--credits-key -` on stdin); buy one by card at https://agent402.tools/credits
15
15
  openclaw gateway restart
16
16
  ```
17
17
 
@@ -33,17 +33,27 @@ export AGENT402_WALLET_KEY=0x... # an EVM key holding USDC on Base
33
33
  With no credits key present the proxy signs an x402 payment per call from that
34
34
  wallet. The key never leaves the machine.
35
35
 
36
- ## Models
36
+ ## Models and pricing
37
37
 
38
- `auto` (routed per prompt) plus every id from `GET https://agent402.tools/v1/models`,
39
- each routed to its home tier. Prices are per call, not per token, so OpenClaw's
40
- per-token cost fields are zero. A model sent to the wrong tier is answered with a
41
- 400 naming the right one; nothing is charged. Every forwarded call carries an
42
- `Idempotency-Key`, so retries replay the paid answer instead of paying twice.
38
+ `auto` (routed per prompt, flat $0.01/call) plus every id from
39
+ `GET https://agent402.tools/v1/models`. Explicit models are **metered by default**:
40
+ the proxy sends them to the gateway's metered route, where each request is quoted
41
+ from its body (exact-BPE input plus your `max_tokens` at the model's list price,
42
+ times 1.15, from $0.001, capped at $2 per call), so a short call costs a fraction
43
+ of a cent and a long one pays for what it asks. `--flat` (or
44
+ `AGENT402_PRICING=flat`) keeps every model on its flat per-call tier instead.
45
+ 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
46
+ 400 naming the right one; nothing is charged. A client-supplied `Idempotency-Key`
47
+ is passed through (an x402 retry with the same key replays the paid answer);
48
+ without one, each call is its own payment.
49
+
50
+ The proxy answers native clients on loopback only: requests carrying a browser
51
+ `Origin` header or a non-loopback `Host` are refused, so a web page cannot spend
52
+ the key.
43
53
 
44
54
  ## Commands
45
55
 
46
- - `agent402-openclaw setup [--credits-key K] [--write] [--port N]`
56
+ - `agent402-openclaw setup [--credits-key K | --credits-key - (stdin) | AGENT402_CREDITS_KEY env] [--write] [--port N]`
47
57
  - `agent402-openclaw proxy [--port N] [--upstream URL]`
48
58
  - `agent402-openclaw doctor`
49
59
 
package/cli.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // setup [--credits-key a402_...] [--write] store the key, print (or merge) the openclaw.json block
4
4
  // proxy [--port N] [--upstream URL] run the local proxy on its own (no OpenClaw needed)
5
5
  // doctor show what is configured and whether the gateway answers
6
- import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
6
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, statSync, realpathSync, renameSync } from "node:fs";
7
+ import { pathToFileURL } from "node:url";
7
8
  import { homedir } from "node:os";
8
9
  import { join } from "node:path";
9
10
  import { STATE_DIR, CREDITS_KEY_FILE, resolveCreditsKey, resolvePayFetch } from "./index.js";
@@ -36,14 +37,21 @@ function mergeInto(target, block) {
36
37
 
37
38
  async function main() {
38
39
  if (cmd === "help" || has("--help")) {
39
- out("agent402-openclaw setup [--credits-key a402_...] [--write] | proxy [--port N] [--upstream URL] | doctor");
40
+ out("agent402-openclaw setup [--credits-key a402_...|-] [--write] [--flat] | proxy [--port N] [--upstream URL] [--flat] | doctor");
40
41
  return 0;
41
42
  }
42
43
  const upstream = stripTrailingSlashes(opt("--upstream") || process.env.AGENT402_UPSTREAM || DEFAULT_UPSTREAM);
43
44
  const port = Number(opt("--port")) || DEFAULT_PORT;
45
+ // Pricing: metered by default (explicit models pay a per-request quote, from
46
+ // $0.001); --flat (or AGENT402_PRICING=flat) keeps every model on its flat tier.
47
+ const pricing = has("--flat") || String(process.env.AGENT402_PRICING || "").toLowerCase() === "flat" ? "flat" : "metered";
44
48
 
45
49
  if (cmd === "setup") {
46
- const key = (opt("--credits-key") || "").trim();
50
+ // The key may arrive on argv (lands in shell history), via AGENT402_CREDITS_KEY,
51
+ // or on stdin with `--credits-key -` (the form the docs recommend).
52
+ let key = (opt("--credits-key") || "").trim();
53
+ if (key === "-") key = readFileSync(0, "utf8").trim();
54
+ if (!key && process.env.AGENT402_CREDITS_KEY && !has("--no-env")) key = process.env.AGENT402_CREDITS_KEY.trim();
47
55
  if (key) {
48
56
  if (!/^a402_[A-Za-z0-9_-]{16,80}$/.test(key)) { console.error("that does not look like an Agent402 credits key (a402_...)"); return 2; }
49
57
  mkdirSync(STATE_DIR(), { recursive: true });
@@ -54,7 +62,7 @@ async function main() {
54
62
  out(`no credits key yet: buy a pack by card at ${upstream}/credits, then rerun with --credits-key a402_...`);
55
63
  }
56
64
  let routes;
57
- try { routes = await loadRoutes(upstream); } catch (e) { console.error(`could not read ${upstream}/v1/models: ${e?.message || e}`); return 2; }
65
+ try { routes = await loadRoutes(upstream, fetch, { pricing }); } catch (e) { console.error(`could not read ${upstream}/v1/models: ${e?.message || e}`); return 2; }
58
66
  const block = configBlock({ port, routes });
59
67
  const cfgPath = join(process.env.AGENT402_OPENCLAW_HOME || join(homedir(), ".openclaw"), "openclaw.json");
60
68
  if (has("--write")) {
@@ -64,9 +72,14 @@ async function main() {
64
72
  catch { console.error(`${cfgPath} is not plain JSON (JSON5 comments?); not touching it. Merge this block by hand:`); out(JSON.stringify(block, null, 2)); return 2; }
65
73
  }
66
74
  mkdirSync(join(cfgPath, ".."), { recursive: true });
75
+ // Preserve the file's mode (openclaw.json holds other providers' keys and
76
+ // is often 0600); a fresh file is created 0600. Never loosen it.
77
+ let mode = 0o600;
78
+ try { if (existsSync(cfgPath)) mode = statSync(cfgPath).mode & 0o777; } catch { /* keep 0600 */ }
67
79
  const tmp = `${cfgPath}.${process.pid}.tmp`;
68
- writeFileSync(tmp, JSON.stringify(mergeInto(existing, block), null, 2) + "\n");
69
- const { renameSync } = await import("node:fs"); renameSync(tmp, cfgPath);
80
+ writeFileSync(tmp, JSON.stringify(mergeInto(existing, block), null, 2) + "\n", { mode });
81
+ try { chmodSync(tmp, mode); } catch { /* best effort */ }
82
+ renameSync(tmp, cfgPath);
70
83
  out(`wrote provider "${PROVIDER_ID}" + primary model ${PROVIDER_ID}/auto into ${cfgPath}; run: openclaw gateway restart`);
71
84
  } else {
72
85
  out(`add to ${cfgPath} (or rerun with --write):`);
@@ -78,8 +91,8 @@ async function main() {
78
91
  if (cmd === "proxy") {
79
92
  const creditsKey = resolveCreditsKey();
80
93
  const payFetch = creditsKey ? null : await resolvePayFetch({}, (m) => console.error(m));
81
- const p = await startProxy({ upstream, creditsKey, payFetch, port, log: (m) => console.error(m) });
82
- out(`${p.baseUrl}/v1 (${p.mode})`);
94
+ const p = await startProxy({ upstream, creditsKey, payFetch, port, pricing, log: (m) => console.error(m) });
95
+ out(`${p.baseUrl}/v1 (${p.mode}, ${p.pricing} pricing)`);
83
96
  await new Promise(() => {}); // run until killed
84
97
  }
85
98
 
@@ -88,7 +101,7 @@ async function main() {
88
101
  out(`upstream: ${upstream}`);
89
102
  out(`credits key: ${key ? `present (${key.slice(0, 8)}…)` : "none"}`);
90
103
  out(`wallet key: ${/^0x[0-9a-fA-F]{64}$/.test(process.env.AGENT402_WALLET_KEY || "") ? "present" : "none"}`);
91
- try { const r = await loadRoutes(upstream); out(`gateway: ok, ${r.size} models`); } catch (e) { out(`gateway: unreachable (${e?.message || e})`); return 1; }
104
+ try { const r = await loadRoutes(upstream, fetch, { pricing }); out(`gateway: ok, ${r.size} models (${pricing} pricing: ${[...r.values()].filter((x) => x.metered).length} metered)`); } catch (e) { out(`gateway: unreachable (${e?.message || e})`); return 1; }
92
105
  if (key) {
93
106
  try {
94
107
  const r = await fetch(`${upstream}/api/credits/balance`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000) });
@@ -102,6 +115,13 @@ async function main() {
102
115
  console.error(`unknown command: ${cmd}`); return 2;
103
116
  }
104
117
 
105
- if (import.meta.url === `file://${process.argv[1]}`) {
118
+ // Entry guard that survives npm's bin SYMLINK and paths with spaces: Node
119
+ // resolves import.meta.url to the real path but leaves process.argv[1] as the
120
+ // symlink, so a plain string compare is false for every `npx`/global install
121
+ // and the CLI silently did nothing (0.1.0 and 0.1.1 shipped that way).
122
+ function invokedDirectly() {
123
+ try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; }
124
+ }
125
+ if (invokedDirectly()) {
106
126
  main().then((c) => process.exit(c ?? 0), (e) => { console.error(e?.message || e); process.exit(1); });
107
127
  }
package/index.js CHANGED
@@ -51,6 +51,7 @@ const plugin = {
51
51
  const cfg = api.pluginConfig || {};
52
52
  const port = Number(cfg.port) || DEFAULT_PORT;
53
53
  const upstream = stripTrailingSlashes(cfg.upstream || process.env.AGENT402_UPSTREAM || DEFAULT_UPSTREAM);
54
+ const pricing = String(cfg.pricing || process.env.AGENT402_PRICING || "metered").toLowerCase() === "flat" ? "flat" : "metered";
54
55
  const log = (m) => api.logger?.info?.(m);
55
56
  api.registerProvider(buildProvider({ port }));
56
57
  if (!registeredOnce) {
@@ -61,8 +62,8 @@ const plugin = {
61
62
  if (started) return;
62
63
  const creditsKey = resolveCreditsKey(cfg);
63
64
  const payFetch = creditsKey ? null : await resolvePayFetch(cfg, log);
64
- try { setCachedRoutes(await loadRoutes(upstream)); } catch (e) { log(`[agent402-openclaw] could not read ${upstream}/v1/models yet: ${e?.message || e}`); }
65
- started = await startProxy({ upstream, creditsKey, payFetch, port, log });
65
+ try { setCachedRoutes(await loadRoutes(upstream, fetch, { pricing })); } catch (e) { log(`[agent402-openclaw] could not read ${upstream}/v1/models yet: ${e?.message || e}`); }
66
+ started = await startProxy({ upstream, creditsKey, payFetch, port, pricing, log });
66
67
  setActiveProxy(started);
67
68
  if (started.mode === "unpaid") log("[agent402-openclaw] no payment configured: run `agent402-openclaw setup` (card credits key) - paid calls will answer 402 until then");
68
69
  },
package/models.js CHANGED
@@ -16,19 +16,30 @@ export function stripTrailingSlashes(s) {
16
16
  }
17
17
 
18
18
 
19
- /** The raw catalog entry -> { id, endpoint, tier, priceUsd, maxTokens, maxInputChars }. */
20
- export function routesFromCatalog(catalog) {
19
+ /** The raw catalog entry -> { id, endpoint, tier, priceUsd, maxTokens, maxInputChars }.
20
+ *
21
+ * `pricing`: "metered" (default) routes every explicit model to the gateway's
22
+ * metered endpoint when the catalog advertises one (`x402.meteredEndpoint`):
23
+ * the 402 quotes each request from its body, from `meteredFromUsd`, instead
24
+ * of the tier's flat price - a short call costs a fraction of a cent, a long
25
+ * one pays for what it asks. "flat" keeps every model on its home tier.
26
+ * `auto` always stays on the routed tier (the metered route needs an explicit
27
+ * model). */
28
+ export function routesFromCatalog(catalog, { pricing = "metered" } = {}) {
21
29
  const data = Array.isArray(catalog?.data) ? catalog.data : [];
22
30
  const routes = new Map();
31
+ const metered = pricing !== "flat";
23
32
  for (const m of data) {
24
33
  const x = m?.x402;
25
34
  if (!m?.id || !x?.endpoint || typeof x.priceUsd !== "number") continue;
26
35
  if (String(m.id).endsWith("*")) continue; // family wildcard, not an id
36
+ const useMetered = metered && typeof x.meteredEndpoint === "string" && x.meteredEndpoint.startsWith("/");
27
37
  routes.set(m.id, {
28
38
  id: m.id,
29
- endpoint: x.endpoint,
30
- tier: x.tier || null,
31
- priceUsd: x.priceUsd,
39
+ endpoint: useMetered ? x.meteredEndpoint : x.endpoint,
40
+ tier: useMetered ? "v1-chat-metered" : (x.tier || null),
41
+ priceUsd: useMetered ? (Number(x.meteredFromUsd) || x.priceUsd) : x.priceUsd,
42
+ metered: useMetered,
32
43
  maxTokens: Number(x.maxTokens) || 1024,
33
44
  maxInputChars: Number(x.maxInputChars) || 32_000,
34
45
  stealth: !!x.stealth,
@@ -55,7 +66,7 @@ export function openclawModels(routes) {
55
66
  rows.sort((a, b) => (a.id === AUTO_ID ? -1 : b.id === AUTO_ID ? 1 : a.priceUsd - b.priceUsd || a.id.localeCompare(b.id)));
56
67
  return rows.map((r) => ({
57
68
  id: r.id,
58
- name: r.id === AUTO_ID ? `Agent402 auto (routed, ${priceLabel(r.priceUsd)}/call)` : `${r.id} (${priceLabel(r.priceUsd)}/call)`,
69
+ name: r.id === AUTO_ID ? `Agent402 auto (routed, ${priceLabel(r.priceUsd)}/call)` : r.metered ? `${r.id} (metered, from ${priceLabel(r.priceUsd)}/call)` : `${r.id} (${priceLabel(r.priceUsd)}/call)`,
59
70
  reasoning: false,
60
71
  input: ["text"],
61
72
  // Flat per-call billing: OpenClaw's per-token cost display does not apply.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-openclaw",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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
@@ -14,20 +14,30 @@
14
14
  // gateway's own GET /v1/models at start (never a hand-typed table). "auto"
15
15
  // goes to the routed tier with the model field omitted.
16
16
  //
17
- // Retries are safe: every forwarded request carries a fresh Idempotency-Key,
18
- // so a client retry of the same request replays the paid response instead of
19
- // paying twice. Streams pass through byte for byte.
17
+ // Idempotency: a client-supplied Idempotency-Key is passed through so an
18
+ // x402 retry with the same key replays the paid answer server-side; without
19
+ // one each forwarded call gets a fresh key (a safety for the fetch layer's
20
+ // own 402->pay retry), which does NOT make two client calls one payment.
21
+ // Streams pass through byte for byte.
22
+ //
23
+ // Loopback only, and browser-hostile on purpose: any web page can POST to
24
+ // 127.0.0.1 with a "simple" no-cors request, and this proxy spends the
25
+ // user's key, so a request carrying an Origin header (browsers always send
26
+ // one on cross-origin POSTs; native clients send none) or a Host that is
27
+ // not loopback is refused before anything is forwarded.
20
28
  import { createServer } from "node:http";
29
+ import { readFileSync } from "node:fs";
21
30
  import { randomUUID } from "node:crypto";
22
31
  import { AUTO_ID, routesFromCatalog, stripTrailingSlashes } from "./models.js";
23
32
 
24
33
  export const DEFAULT_UPSTREAM = "https://agent402.tools";
34
+ export const PKG_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
25
35
  const MAX_BODY = 2 * 1024 * 1024;
26
36
 
27
- export async function loadRoutes(upstream, fetchImpl = fetch) {
37
+ export async function loadRoutes(upstream, fetchImpl = fetch, { pricing = "metered" } = {}) {
28
38
  const r = await fetchImpl(`${upstream}/v1/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
29
39
  if (!r.ok) throw new Error(`GET /v1/models -> HTTP ${r.status}`);
30
- return routesFromCatalog(await r.json());
40
+ return routesFromCatalog(await r.json(), { pricing });
31
41
  }
32
42
 
33
43
  function readBody(req, limit = MAX_BODY) {
@@ -53,25 +63,34 @@ const json = (res, status, obj, headers = {}) => {
53
63
  * @param {number} [o.port] 0 = ephemeral
54
64
  * @param {string} [o.host] default 127.0.0.1 (loopback only: anyone who can reach the port spends your key)
55
65
  * @param {Map} [o.routes] pre-loaded routes (tests); else loaded from upstream
66
+ * @param {"metered"|"flat"} [o.pricing] metered (default): explicit models pay per-request quotes; flat: home tiers
56
67
  * @param {(msg:string)=>void} [o.log]
57
68
  */
58
- export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = null, payFetch = null, fetch: fetchImpl = fetch, port = 0, host = "127.0.0.1", routes = null, log = () => {} } = {}) {
69
+ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = null, payFetch = null, fetch: fetchImpl = fetch, port = 0, host = "127.0.0.1", routes = null, pricing = "metered", log = () => {} } = {}) {
59
70
  upstream = stripTrailingSlashes(upstream);
71
+ pricing = pricing === "flat" ? "flat" : "metered";
60
72
  const key = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{16,80}$/.test(creditsKey) ? creditsKey : null;
61
73
  const paid = key ? fetchImpl : payFetch;
62
74
  const mode = key ? "credits" : payFetch ? "x402" : "unpaid";
63
- let table = routes || await loadRoutes(upstream, fetchImpl);
75
+ let table = routes || await loadRoutes(upstream, fetchImpl, { pricing });
64
76
  const stats = { requests: 0, forwarded: 0, errors: 0, startedAt: new Date().toISOString() };
65
77
 
66
78
  const server = createServer(async (req, res) => {
67
79
  stats.requests++;
68
80
  const url = new URL(req.url, "http://localhost");
69
81
  try {
82
+ if (req.headers.origin !== undefined) {
83
+ return json(res, 403, { error: { message: "Browser-origin requests are refused: this proxy spends a payment key and answers native clients only.", type: "forbidden" } });
84
+ }
85
+ const hostName = String(req.headers.host || "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
86
+ if (hostName && !["127.0.0.1", "localhost", "::1"].includes(hostName)) {
87
+ return json(res, 403, { error: { message: `Host "${hostName.slice(0, 64)}" is not loopback; refused.`, type: "forbidden" } });
88
+ }
70
89
  if (req.method === "GET" && url.pathname === "/health") {
71
- return json(res, 200, { ok: true, upstream, mode, models: table.size, stats });
90
+ return json(res, 200, { ok: true, upstream, mode, pricing, models: table.size, stats });
72
91
  }
73
92
  if (req.method === "GET" && url.pathname === "/v1/models") {
74
- return json(res, 200, { object: "list", data: [...table.values()].filter((r) => !r.stealth).map((r) => ({ id: r.id, object: "model", owned_by: "agent402", agent402: { endpoint: r.endpoint, priceUsd: r.priceUsd, tier: r.tier } })) });
93
+ return json(res, 200, { object: "list", data: [...table.values()].filter((r) => !r.stealth).map((r) => ({ id: r.id, object: "model", owned_by: "agent402", agent402: { endpoint: r.endpoint, priceUsd: r.priceUsd, tier: r.tier, metered: !!r.metered } })) });
75
94
  }
76
95
  if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
77
96
  const raw = await readBody(req);
@@ -87,7 +106,8 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
87
106
  }
88
107
  const outbound = { ...body };
89
108
  if (requested === AUTO_ID) delete outbound.model; else outbound.model = route.id;
90
- const headers = { "content-type": "application/json", accept: req.headers.accept || "application/json", "idempotency-key": randomUUID(), "user-agent": "agent402-openclaw/0.1.1" };
109
+ const clientIdem = typeof req.headers["idempotency-key"] === "string" && /^[\w.:-]{8,128}$/.test(req.headers["idempotency-key"]) ? req.headers["idempotency-key"] : null;
110
+ const headers = { "content-type": "application/json", accept: req.headers.accept || "application/json", "idempotency-key": clientIdem || randomUUID(), "user-agent": `agent402-openclaw/${PKG_VERSION}` };
91
111
  if (key) headers.authorization = `Bearer ${key}`;
92
112
  const up = await paid(`${upstream}${route.endpoint}`, { method: "POST", headers, body: JSON.stringify(outbound), signal: AbortSignal.timeout(300_000) });
93
113
  stats.forwarded++;
@@ -112,11 +132,11 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
112
132
  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, host, resolve); });
113
133
  const actualPort = server.address().port;
114
134
  const baseUrl = `http://${host}:${actualPort}`;
115
- log(`[agent402-openclaw] proxy on ${baseUrl}/v1 -> ${upstream} (${mode}, ${table.size} models)`);
135
+ log(`[agent402-openclaw] proxy on ${baseUrl}/v1 -> ${upstream} (${mode}, ${pricing} pricing, ${table.size} models)`);
116
136
  return {
117
- baseUrl, port: actualPort, mode, upstream,
137
+ baseUrl, port: actualPort, mode, upstream, pricing,
118
138
  stats: () => ({ ...stats }),
119
- refreshModels: async () => { table = await loadRoutes(upstream, fetchImpl); return table.size; },
139
+ refreshModels: async () => { table = await loadRoutes(upstream, fetchImpl, { pricing }); return table.size; },
120
140
  close: () => new Promise((resolve) => server.close(() => resolve())),
121
141
  };
122
142
  }