agent402-openclaw 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Havok Holdings LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # agent402-openclaw
2
+
3
+ Agent402 as an [OpenClaw](https://openclaw.ai) model provider: auto-routed and
4
+ explicit frontier models at a **flat per-call price**, paid **by card** (a prepaid
5
+ credits key, no wallet) or in **USDC over x402** from a wallet. The same key and
6
+ gateway reach Agent402's 500+ pay-per-call tools.
7
+
8
+ Guide: https://agent402.tools/guides/openclaw-model-provider
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ openclaw plugins install agent402-openclaw
14
+ agent402-openclaw setup --credits-key a402_... # buy a pack by card at https://agent402.tools/credits
15
+ openclaw gateway restart
16
+ ```
17
+
18
+ `setup` stores the key under `~/.openclaw/agent402/credits.key` (0600) and prints
19
+ the `openclaw.json` block; add `--write` to merge it in (`agent402/auto` becomes
20
+ the primary model). The plugin starts a loopback proxy (`127.0.0.1:8412`) that
21
+ pays Agent402 and forwards; OpenClaw only ever sees a local OpenAI-compatible URL.
22
+
23
+ No OpenClaw? `agent402-openclaw proxy` runs the proxy alone; point any OpenAI
24
+ client at `http://127.0.0.1:8412/v1` with model `auto`.
25
+
26
+ ## Pay from a wallet instead
27
+
28
+ ```bash
29
+ npm i @x402/fetch @x402/evm viem
30
+ export AGENT402_WALLET_KEY=0x... # an EVM key holding USDC on Base
31
+ ```
32
+
33
+ With no credits key present the proxy signs an x402 payment per call from that
34
+ wallet. The key never leaves the machine.
35
+
36
+ ## Models
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.
43
+
44
+ ## Commands
45
+
46
+ - `agent402-openclaw setup [--credits-key K] [--write] [--port N]`
47
+ - `agent402-openclaw proxy [--port N] [--upstream URL]`
48
+ - `agent402-openclaw doctor`
49
+
50
+ Zero dependencies. MIT. Maintained by Havok Holdings LLC.
package/cli.js ADDED
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // agent402-openclaw <command>
3
+ // setup [--credits-key a402_...] [--write] store the key, print (or merge) the openclaw.json block
4
+ // proxy [--port N] [--upstream URL] run the local proxy on its own (no OpenClaw needed)
5
+ // doctor show what is configured and whether the gateway answers
6
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { STATE_DIR, CREDITS_KEY_FILE, resolveCreditsKey, resolvePayFetch } from "./index.js";
10
+ import { startProxy, loadRoutes, DEFAULT_UPSTREAM } from "./proxy.js";
11
+ import { providerModelsConfig, stripTrailingSlashes } from "./models.js";
12
+ import { DEFAULT_PORT, PROVIDER_ID } from "./provider.js";
13
+
14
+ const args = process.argv.slice(2);
15
+ const cmd = args[0] || "help";
16
+ const opt = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null; };
17
+ const has = (k) => args.includes(k);
18
+ const out = (s) => process.stdout.write(s + "\n");
19
+
20
+ /** The provider block for openclaw.json (JSON, so it can be merged; OpenClaw reads JSON5 which is a superset). */
21
+ export function configBlock({ port = DEFAULT_PORT, routes }) {
22
+ return {
23
+ agents: { defaults: { model: { primary: `${PROVIDER_ID}/auto` } } },
24
+ models: { providers: { [PROVIDER_ID]: { ...providerModelsConfig(`http://127.0.0.1:${port}`, routes), timeoutSeconds: 120 } } },
25
+ };
26
+ }
27
+
28
+ function mergeInto(target, block) {
29
+ const t = target && typeof target === "object" ? target : {};
30
+ t.agents = t.agents || {}; t.agents.defaults = t.agents.defaults || {};
31
+ t.agents.defaults.model = { ...(t.agents.defaults.model || {}), primary: block.agents.defaults.model.primary };
32
+ t.models = t.models || {}; t.models.providers = t.models.providers || {};
33
+ t.models.providers[PROVIDER_ID] = block.models.providers[PROVIDER_ID];
34
+ return t;
35
+ }
36
+
37
+ async function main() {
38
+ if (cmd === "help" || has("--help")) {
39
+ out("agent402-openclaw setup [--credits-key a402_...] [--write] | proxy [--port N] [--upstream URL] | doctor");
40
+ return 0;
41
+ }
42
+ const upstream = stripTrailingSlashes(opt("--upstream") || process.env.AGENT402_UPSTREAM || DEFAULT_UPSTREAM);
43
+ const port = Number(opt("--port")) || DEFAULT_PORT;
44
+
45
+ if (cmd === "setup") {
46
+ const key = (opt("--credits-key") || "").trim();
47
+ if (key) {
48
+ 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
+ mkdirSync(STATE_DIR(), { recursive: true });
50
+ writeFileSync(CREDITS_KEY_FILE(), key + "\n", { mode: 0o600 });
51
+ try { chmodSync(CREDITS_KEY_FILE(), 0o600); } catch { /* best effort */ }
52
+ out(`stored credits key at ${CREDITS_KEY_FILE()} (0600)`);
53
+ } else if (!resolveCreditsKey()) {
54
+ out(`no credits key yet: buy a pack by card at ${upstream}/credits, then rerun with --credits-key a402_...`);
55
+ }
56
+ let routes;
57
+ try { routes = await loadRoutes(upstream); } catch (e) { console.error(`could not read ${upstream}/v1/models: ${e?.message || e}`); return 2; }
58
+ const block = configBlock({ port, routes });
59
+ const cfgPath = join(process.env.AGENT402_OPENCLAW_HOME || join(homedir(), ".openclaw"), "openclaw.json");
60
+ if (has("--write")) {
61
+ let existing = {};
62
+ if (existsSync(cfgPath)) {
63
+ try { existing = JSON.parse(readFileSync(cfgPath, "utf8")); }
64
+ 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
+ }
66
+ mkdirSync(join(cfgPath, ".."), { recursive: true });
67
+ 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);
70
+ out(`wrote provider "${PROVIDER_ID}" + primary model ${PROVIDER_ID}/auto into ${cfgPath}; run: openclaw gateway restart`);
71
+ } else {
72
+ out(`add to ${cfgPath} (or rerun with --write):`);
73
+ out(JSON.stringify(block, null, 2));
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ if (cmd === "proxy") {
79
+ const creditsKey = resolveCreditsKey();
80
+ 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})`);
83
+ await new Promise(() => {}); // run until killed
84
+ }
85
+
86
+ if (cmd === "doctor") {
87
+ const key = resolveCreditsKey();
88
+ out(`upstream: ${upstream}`);
89
+ out(`credits key: ${key ? `present (${key.slice(0, 8)}…)` : "none"}`);
90
+ 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; }
92
+ if (key) {
93
+ try {
94
+ const r = await fetch(`${upstream}/api/credits/balance`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000) });
95
+ const j = await r.json().catch(() => ({}));
96
+ out(`balance: ${r.ok ? `$${j.balanceUsd ?? j.balance ?? "?"}` : `HTTP ${r.status} ${j.reason || j.error || ""}`}`);
97
+ } catch (e) { out(`balance: unreadable (${e?.message || e})`); }
98
+ }
99
+ return 0;
100
+ }
101
+
102
+ console.error(`unknown command: ${cmd}`); return 2;
103
+ }
104
+
105
+ if (import.meta.url === `file://${process.argv[1]}`) {
106
+ main().then((c) => process.exit(c ?? 0), (e) => { console.error(e?.message || e); process.exit(1); });
107
+ }
package/index.js ADDED
@@ -0,0 +1,76 @@
1
+ // OpenClaw plugin entry (listed in package.json "openclaw.extensions").
2
+ // register() is what OpenClaw's loader calls - several times per gateway start
3
+ // (discovery, activation, per session), so registration is idempotent and the
4
+ // proxy is started once as a plugin service.
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { buildProvider, setActiveProxy, setCachedRoutes, DEFAULT_PORT, PROVIDER_ID } from "./provider.js";
9
+ import { startProxy, loadRoutes, DEFAULT_UPSTREAM } from "./proxy.js";
10
+ import { stripTrailingSlashes } from "./models.js";
11
+
12
+ export const STATE_DIR = () => join(process.env.AGENT402_OPENCLAW_HOME || join(homedir(), ".openclaw"), "agent402");
13
+ export const CREDITS_KEY_FILE = () => join(STATE_DIR(), "credits.key");
14
+
15
+ /** Credits key precedence: plugin config > env > ~/.openclaw/agent402/credits.key. */
16
+ export function resolveCreditsKey(pluginConfig = {}) {
17
+ const fromCfg = typeof pluginConfig.creditsKey === "string" ? pluginConfig.creditsKey.trim() : "";
18
+ if (fromCfg) return fromCfg;
19
+ const fromEnv = (process.env.AGENT402_CREDITS_KEY || "").trim();
20
+ if (fromEnv) return fromEnv;
21
+ try { if (existsSync(CREDITS_KEY_FILE())) return readFileSync(CREDITS_KEY_FILE(), "utf8").trim(); } catch { /* unreadable */ }
22
+ return null;
23
+ }
24
+
25
+ /** Optional x402 wallet payment: only when the peer deps are installed and a key is present. */
26
+ export async function resolvePayFetch(pluginConfig = {}, log = () => {}) {
27
+ const pk = (pluginConfig.walletKey || process.env.AGENT402_WALLET_KEY || "").trim();
28
+ if (!/^0x[0-9a-fA-F]{64}$/.test(pk)) return null;
29
+ try {
30
+ const [{ wrapFetchWithPayment }, { privateKeyToAccount }] = await Promise.all([import("@x402/fetch"), import("viem/accounts")]);
31
+ const { toClientEvmSigner } = await import("@x402/evm");
32
+ const { registerExactEvmScheme } = await import("@x402/evm/exact/client");
33
+ const { x402Client } = await import("@x402/fetch");
34
+ const client = new x402Client();
35
+ registerExactEvmScheme(client, { signer: toClientEvmSigner(privateKeyToAccount(pk)) });
36
+ return wrapFetchWithPayment(fetch, client);
37
+ } catch (e) {
38
+ log(`[agent402-openclaw] x402 wallet payment unavailable (${e?.message || e}); install @x402/fetch @x402/evm viem, or use a credits key`);
39
+ return null;
40
+ }
41
+ }
42
+
43
+ let started = null;
44
+ let registeredOnce = false;
45
+
46
+ const plugin = {
47
+ id: PROVIDER_ID,
48
+ name: "Agent402",
49
+ description: "Agent402 model provider: routed + explicit models at a flat per-call price, paid by card or USDC.",
50
+ register(api) {
51
+ const cfg = api.pluginConfig || {};
52
+ const port = Number(cfg.port) || DEFAULT_PORT;
53
+ const upstream = stripTrailingSlashes(cfg.upstream || process.env.AGENT402_UPSTREAM || DEFAULT_UPSTREAM);
54
+ const log = (m) => api.logger?.info?.(m);
55
+ api.registerProvider(buildProvider({ port }));
56
+ if (!registeredOnce) {
57
+ registeredOnce = true;
58
+ api.registerService({
59
+ id: "agent402-proxy",
60
+ start: async () => {
61
+ if (started) return;
62
+ const creditsKey = resolveCreditsKey(cfg);
63
+ 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 });
66
+ setActiveProxy(started);
67
+ 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
+ stop: async () => { if (started) { await started.close(); started = null; setActiveProxy(null); } },
70
+ });
71
+ }
72
+ },
73
+ };
74
+
75
+ export default plugin;
76
+ export { plugin, buildProvider, startProxy };
package/models.js ADDED
@@ -0,0 +1,71 @@
1
+ // Model list for OpenClaw, built from the gateway's own GET /v1/models so the
2
+ // plugin never carries a hand-typed catalog (a stale id would be a paid 502
3
+ // for the user). "auto" is always first: it is the routed tier and the
4
+ // recommended primary. Every other entry is an explicit model id with the
5
+ // tier's flat per-call price in its display name, because OpenClaw's cost
6
+ // fields are per token and Agent402 bills per call - those stay at zero so
7
+ // the UI never shows a made-up per-token figure.
8
+
9
+ export const AUTO_ID = "auto";
10
+
11
+ /** Linear trailing-slash strip (a regex like /\/+$/ is polynomial on long runs of "/"). */
12
+ export function stripTrailingSlashes(s) {
13
+ let str = String(s ?? ""); let end = str.length;
14
+ while (end > 0 && str.charCodeAt(end - 1) === 47) end--;
15
+ return str.slice(0, end);
16
+ }
17
+
18
+
19
+ /** The raw catalog entry -> { id, endpoint, tier, priceUsd, maxTokens, maxInputChars }. */
20
+ export function routesFromCatalog(catalog) {
21
+ const data = Array.isArray(catalog?.data) ? catalog.data : [];
22
+ const routes = new Map();
23
+ for (const m of data) {
24
+ const x = m?.x402;
25
+ if (!m?.id || !x?.endpoint || typeof x.priceUsd !== "number") continue;
26
+ if (String(m.id).endsWith("*")) continue; // family wildcard, not an id
27
+ routes.set(m.id, {
28
+ id: m.id,
29
+ endpoint: x.endpoint,
30
+ tier: x.tier || null,
31
+ priceUsd: x.priceUsd,
32
+ maxTokens: Number(x.maxTokens) || 1024,
33
+ maxInputChars: Number(x.maxInputChars) || 32_000,
34
+ stealth: !!x.stealth,
35
+ });
36
+ }
37
+ // The routed tier accepts a bare "auto" (model optional); expose it under
38
+ // that id whatever the catalog lists for the tier's own prefixes.
39
+ const autoRow = data.find((m) => m?.x402?.tier === "v1-chat-auto");
40
+ if (autoRow) {
41
+ routes.set(AUTO_ID, {
42
+ id: AUTO_ID, endpoint: autoRow.x402.endpoint, tier: "v1-chat-auto",
43
+ priceUsd: autoRow.x402.priceUsd, maxTokens: Number(autoRow.x402.maxTokens) || 1024,
44
+ maxInputChars: Number(autoRow.x402.maxInputChars) || 32_000, stealth: false,
45
+ });
46
+ }
47
+ return routes;
48
+ }
49
+
50
+ const priceLabel = (usd) => (usd < 0.01 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`);
51
+
52
+ /** OpenClaw `models[]` entries for a provider block. `auto` first. */
53
+ export function openclawModels(routes) {
54
+ const rows = [...routes.values()].filter((r) => !r.stealth);
55
+ rows.sort((a, b) => (a.id === AUTO_ID ? -1 : b.id === AUTO_ID ? 1 : a.priceUsd - b.priceUsd || a.id.localeCompare(b.id)));
56
+ return rows.map((r) => ({
57
+ id: r.id,
58
+ name: r.id === AUTO_ID ? `Agent402 auto (routed, ${priceLabel(r.priceUsd)}/call)` : `${r.id} (${priceLabel(r.priceUsd)}/call)`,
59
+ reasoning: false,
60
+ input: ["text"],
61
+ // Flat per-call billing: OpenClaw's per-token cost display does not apply.
62
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
63
+ contextWindow: Math.min(Math.floor(r.maxInputChars / 4), 128_000),
64
+ maxTokens: r.maxTokens,
65
+ }));
66
+ }
67
+
68
+ /** OpenClaw ModelProviderConfig pointing at the local proxy. */
69
+ export function providerModelsConfig(baseUrl, routes) {
70
+ return { baseUrl: `${stripTrailingSlashes(baseUrl)}/v1`, api: "openai-completions", models: openclawModels(routes) };
71
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "agent402",
3
+ "name": "Agent402",
4
+ "description": "Agent402 model provider: auto-routed and explicit frontier models at a flat per-call price, paid by card (prepaid credits key) or in USDC over x402. Same key reaches 500+ pay-per-call tools.",
5
+ "activation": {
6
+ "onStartup": true
7
+ },
8
+ "enabledByDefault": true,
9
+ "providers": ["agent402"]
10
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "agent402-openclaw",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./proxy": "./proxy.js",
10
+ "./models": "./models.js"
11
+ },
12
+ "bin": {
13
+ "agent402-openclaw": "cli.js"
14
+ },
15
+ "files": [
16
+ "index.js",
17
+ "provider.js",
18
+ "models.js",
19
+ "proxy.js",
20
+ "cli.js",
21
+ "openclaw.plugin.json",
22
+ "README.md"
23
+ ],
24
+ "openclaw": {
25
+ "extensions": [
26
+ "./index.js"
27
+ ]
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "scripts": {
33
+ "test": "node test.js"
34
+ },
35
+ "peerDependencies": {
36
+ "@x402/evm": ">=2.20.0",
37
+ "@x402/fetch": ">=2.20.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@x402/evm": {
41
+ "optional": true
42
+ },
43
+ "@x402/fetch": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "keywords": [
48
+ "openclaw",
49
+ "openclaw-plugin",
50
+ "llm-router",
51
+ "x402",
52
+ "mpp",
53
+ "ai-agents",
54
+ "usdc",
55
+ "agent402"
56
+ ],
57
+ "author": "Havok Holdings LLC",
58
+ "license": "MIT",
59
+ "repository": {
60
+ "type": "git",
61
+ "url": "git+https://github.com/MikeyPetrillo/Agent402.git",
62
+ "directory": "openclaw"
63
+ },
64
+ "homepage": "https://agent402.tools/guides/openclaw-model-provider"
65
+ }
package/provider.js ADDED
@@ -0,0 +1,35 @@
1
+ // The OpenClaw ProviderPlugin. Its `models` getter always points at the local
2
+ // proxy URL - the configured port before the proxy is up, the live one after -
3
+ // so OpenClaw persists a loopback baseUrl and never the remote gateway (which
4
+ // would 402 without the proxy's payment step in between).
5
+ import { providerModelsConfig, routesFromCatalog } from "./models.js";
6
+
7
+ export const PROVIDER_ID = "agent402";
8
+ export const DEFAULT_PORT = 8412;
9
+
10
+ let activeProxy = null;
11
+ let cachedRoutes = null;
12
+ export function setActiveProxy(p) { activeProxy = p; }
13
+ export function getActiveProxy() { return activeProxy; }
14
+ export function setCachedRoutes(r) { cachedRoutes = r; }
15
+
16
+ /** Minimal offline route table so the provider registers even before /v1/models is read. */
17
+ export const BOOTSTRAP_ROUTES = routesFromCatalog({ data: [
18
+ { id: "auto", x402: { tier: "v1-chat-auto", endpoint: "/v1/auto/chat/completions", priceUsd: 0.01, maxTokens: 1024, maxInputChars: 32_000 } },
19
+ ] });
20
+
21
+ export function buildProvider({ port = DEFAULT_PORT } = {}) {
22
+ return {
23
+ id: PROVIDER_ID,
24
+ label: "Agent402",
25
+ docsPath: "https://agent402.tools/guides/openclaw-model-provider",
26
+ aliases: ["a402"],
27
+ envVars: ["AGENT402_CREDITS_KEY", "AGENT402_WALLET_KEY"],
28
+ get models() {
29
+ const base = activeProxy ? activeProxy.baseUrl : `http://127.0.0.1:${port}`;
30
+ return providerModelsConfig(base, cachedRoutes || BOOTSTRAP_ROUTES);
31
+ },
32
+ // No provider auth: the proxy carries the credits key or signs the payment.
33
+ auth: [],
34
+ };
35
+ }
package/proxy.js ADDED
@@ -0,0 +1,122 @@
1
+ // Local OpenAI-compatible proxy: OpenClaw (or any client) talks to
2
+ // http://127.0.0.1:<port>/v1, the proxy pays Agent402 and forwards.
3
+ //
4
+ // Two ways to pay, chosen at start:
5
+ // creditsKey - a prepaid card-credits key (a402_...) sent as a Bearer; the
6
+ // gateway authorizes against the balance before the handler and
7
+ // debits only on a final 200. No wallet, no chain.
8
+ // payFetch - an x402-paying fetch (e.g. @x402/fetch wrapped around a
9
+ // wallet signer) used for the upstream call; the gateway's 402
10
+ // is settled per call in USDC. The proxy never sees a key.
11
+ // With neither, paid calls answer a 402-shaped JSON that says how to set up.
12
+ //
13
+ // Routing: the model id decides the upstream tier endpoint, read from the
14
+ // gateway's own GET /v1/models at start (never a hand-typed table). "auto"
15
+ // goes to the routed tier with the model field omitted.
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.
20
+ import { createServer } from "node:http";
21
+ import { randomUUID } from "node:crypto";
22
+ import { AUTO_ID, routesFromCatalog, stripTrailingSlashes } from "./models.js";
23
+
24
+ export const DEFAULT_UPSTREAM = "https://agent402.tools";
25
+ const MAX_BODY = 2 * 1024 * 1024;
26
+
27
+ export async function loadRoutes(upstream, fetchImpl = fetch) {
28
+ const r = await fetchImpl(`${upstream}/v1/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
29
+ if (!r.ok) throw new Error(`GET /v1/models -> HTTP ${r.status}`);
30
+ return routesFromCatalog(await r.json());
31
+ }
32
+
33
+ function readBody(req, limit = MAX_BODY) {
34
+ return new Promise((resolve, reject) => {
35
+ const chunks = []; let n = 0;
36
+ req.on("data", (c) => { n += c.length; if (n > limit) { reject(new Error("body too large")); req.destroy(); return; } chunks.push(c); });
37
+ req.on("end", () => resolve(Buffer.concat(chunks)));
38
+ req.on("error", reject);
39
+ });
40
+ }
41
+
42
+ const json = (res, status, obj, headers = {}) => {
43
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", ...headers });
44
+ res.end(JSON.stringify(obj));
45
+ };
46
+
47
+ /**
48
+ * @param {object} o
49
+ * @param {string} [o.upstream] gateway origin (default https://agent402.tools)
50
+ * @param {string} [o.creditsKey] prepaid credits key (a402_...)
51
+ * @param {Function} [o.payFetch] x402-paying fetch for wallet payment
52
+ * @param {Function} [o.fetch] plain fetch (tests inject one)
53
+ * @param {number} [o.port] 0 = ephemeral
54
+ * @param {string} [o.host] default 127.0.0.1 (loopback only: anyone who can reach the port spends your key)
55
+ * @param {Map} [o.routes] pre-loaded routes (tests); else loaded from upstream
56
+ * @param {(msg:string)=>void} [o.log]
57
+ */
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 = () => {} } = {}) {
59
+ upstream = stripTrailingSlashes(upstream);
60
+ const key = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{16,80}$/.test(creditsKey) ? creditsKey : null;
61
+ const paid = key ? fetchImpl : payFetch;
62
+ const mode = key ? "credits" : payFetch ? "x402" : "unpaid";
63
+ let table = routes || await loadRoutes(upstream, fetchImpl);
64
+ const stats = { requests: 0, forwarded: 0, errors: 0, startedAt: new Date().toISOString() };
65
+
66
+ const server = createServer(async (req, res) => {
67
+ stats.requests++;
68
+ const url = new URL(req.url, "http://localhost");
69
+ try {
70
+ if (req.method === "GET" && url.pathname === "/health") {
71
+ return json(res, 200, { ok: true, upstream, mode, models: table.size, stats });
72
+ }
73
+ 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 } })) });
75
+ }
76
+ if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
77
+ const raw = await readBody(req);
78
+ let body;
79
+ try { body = JSON.parse(raw.toString("utf8") || "{}"); } catch { return json(res, 400, { error: { message: "Request body must be JSON", type: "invalid_request_error" } }); }
80
+ const requested = typeof body.model === "string" && body.model.trim() ? body.model.trim().replace(/^agent402\//, "") : AUTO_ID;
81
+ const route = table.get(requested);
82
+ if (!route) {
83
+ return json(res, 400, { error: { message: `Unknown model "${requested}". Use "auto" or an id from GET /v1/models (${table.size} available).`, type: "invalid_request_error", code: "model_not_found" } });
84
+ }
85
+ if (!paid) {
86
+ return json(res, 402, { error: { message: "No payment method configured. Set AGENT402_CREDITS_KEY (buy a pack by card at agent402.tools/credits) or configure an x402 wallet (agent402-openclaw setup --wallet).", type: "payment_required", code: "agent402_unconfigured" }, topup: `${upstream}/credits`, priceUsd: route.priceUsd });
87
+ }
88
+ const outbound = { ...body };
89
+ 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.0" };
91
+ if (key) headers.authorization = `Bearer ${key}`;
92
+ const up = await paid(`${upstream}${route.endpoint}`, { method: "POST", headers, body: JSON.stringify(outbound), signal: AbortSignal.timeout(300_000) });
93
+ stats.forwarded++;
94
+ const passthrough = {};
95
+ 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; }
96
+ res.writeHead(up.status, passthrough);
97
+ if (!up.body) return res.end();
98
+ const reader = up.body.getReader();
99
+ req.on("close", () => { reader.cancel().catch(() => {}); });
100
+ for (;;) { const { done, value } = await reader.read(); if (done) break; res.write(Buffer.from(value)); }
101
+ return res.end();
102
+ }
103
+ return json(res, 404, { error: { message: `No route for ${req.method} ${url.pathname}`, type: "invalid_request_error" } });
104
+ } catch (e) {
105
+ stats.errors++;
106
+ log(`[agent402-openclaw] ${req.method} ${url.pathname}: ${e?.message || e}`);
107
+ if (!res.headersSent) return json(res, 502, { error: { message: `Upstream error: ${String(e?.message || e).slice(0, 200)}`, type: "upstream_error" } });
108
+ try { res.end(); } catch { /* ignore */ }
109
+ }
110
+ });
111
+
112
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, host, resolve); });
113
+ const actualPort = server.address().port;
114
+ const baseUrl = `http://${host}:${actualPort}`;
115
+ log(`[agent402-openclaw] proxy on ${baseUrl}/v1 -> ${upstream} (${mode}, ${table.size} models)`);
116
+ return {
117
+ baseUrl, port: actualPort, mode, upstream,
118
+ stats: () => ({ ...stats }),
119
+ refreshModels: async () => { table = await loadRoutes(upstream, fetchImpl); return table.size; },
120
+ close: () => new Promise((resolve) => server.close(() => resolve())),
121
+ };
122
+ }