agent402-openclaw 0.1.2 → 0.2.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 CHANGED
@@ -11,15 +11,24 @@ Guide: https://agent402.tools/guides/openclaw-model-provider
11
11
 
12
12
  ```bash
13
13
  openclaw plugins install agent402-openclaw
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
14
+ AGENT402_CREDITS_KEY=a402_... npx 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
 
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
18
+ (`openclaw plugins install` copies the plugin into `~/.openclaw/extensions` and
19
+ does not link its CLI, hence `npx`.) `setup` stores the key under
20
+ `~/.openclaw/agent402/credits.key` (0600) and prints the `openclaw.json` block;
21
+ add `--write` to merge it in. The primary model it writes is the cheapest
22
+ metered model that can hold OpenClaw's own prompt: OpenClaw sends roughly 70k
23
+ characters of system prompt and tool schemas before your first word, and the
24
+ routed `auto` tier caps input at 16k, so `auto` stays listed for short one-off
25
+ prompts but is never made primary (OpenClaw would refuse every turn as a
26
+ context overflow). The plugin starts a loopback proxy (`127.0.0.1:8412`) that
21
27
  pays Agent402 and forwards; OpenClaw only ever sees a local OpenAI-compatible URL.
22
28
 
29
+ Tested in CI against a real `openclaw@latest` install: plugin install, setup,
30
+ model listing, gateway boot and one agent turn (`test-real-install.js`).
31
+
23
32
  No OpenClaw? `agent402-openclaw proxy` runs the proxy alone; point any OpenAI
24
33
  client at `http://127.0.0.1:8412/v1` with model `auto`.
25
34
 
@@ -33,11 +42,16 @@ export AGENT402_WALLET_KEY=0x... # an EVM key holding USDC on Base
33
42
  With no credits key present the proxy signs an x402 payment per call from that
34
43
  wallet. The key never leaves the machine.
35
44
 
36
- ## Models
45
+ ## Models and pricing
37
46
 
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
47
+ `auto` (routed per prompt, flat $0.01/call) plus every id from
48
+ `GET https://agent402.tools/v1/models`. Explicit models are **metered by default**:
49
+ the proxy sends them to the gateway's metered route, where each request is quoted
50
+ from its body (exact-BPE input plus your `max_tokens` at the model's list price,
51
+ times 1.15, from $0.001, capped at $2 per call), so a short call costs a fraction
52
+ of a cent and a long one pays for what it asks. `--flat` (or
53
+ `AGENT402_PRICING=flat`) keeps every model on its flat per-call tier instead.
54
+ 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
41
55
  400 naming the right one; nothing is charged. A client-supplied `Idempotency-Key`
42
56
  is passed through (an x402 retry with the same key replays the paid answer);
43
57
  without one, each call is its own payment.
@@ -48,7 +62,7 @@ the key.
48
62
 
49
63
  ## Commands
50
64
 
51
- - `agent402-openclaw setup [--credits-key K | --credits-key - (stdin) | AGENT402_CREDITS_KEY env] [--write] [--port N]`
65
+ - `agent402-openclaw setup [--credits-key K | --credits-key - (stdin) | AGENT402_CREDITS_KEY env] [--write] [--port N] [--flat]`
52
66
  - `agent402-openclaw proxy [--port N] [--upstream URL]`
53
67
  - `agent402-openclaw doctor`
54
68
 
package/cli.js CHANGED
@@ -9,7 +9,7 @@ import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { STATE_DIR, CREDITS_KEY_FILE, resolveCreditsKey, resolvePayFetch } from "./index.js";
11
11
  import { startProxy, loadRoutes, DEFAULT_UPSTREAM } from "./proxy.js";
12
- import { providerModelsConfig, stripTrailingSlashes } from "./models.js";
12
+ import { providerModelsConfig, stripTrailingSlashes, defaultPrimary, AUTO_ID, OPENCLAW_MIN_INPUT_CHARS } from "./models.js";
13
13
  import { DEFAULT_PORT, PROVIDER_ID } from "./provider.js";
14
14
 
15
15
  const args = process.argv.slice(2);
@@ -20,28 +20,39 @@ const out = (s) => process.stdout.write(s + "\n");
20
20
 
21
21
  /** The provider block for openclaw.json (JSON, so it can be merged; OpenClaw reads JSON5 which is a superset). */
22
22
  export function configBlock({ port = DEFAULT_PORT, routes }) {
23
+ const primary = defaultPrimary(routes);
23
24
  return {
24
- agents: { defaults: { model: { primary: `${PROVIDER_ID}/auto` } } },
25
+ agents: { defaults: { model: { primary: `${PROVIDER_ID}/${primary ? primary.id : AUTO_ID}` } } },
25
26
  models: { providers: { [PROVIDER_ID]: { ...providerModelsConfig(`http://127.0.0.1:${port}`, routes), timeoutSeconds: 120 } } },
26
27
  };
27
28
  }
28
29
 
29
- function mergeInto(target, block) {
30
+ function mergeInto(target, block, { port = DEFAULT_PORT } = {}) {
30
31
  const t = target && typeof target === "object" ? target : {};
31
32
  t.agents = t.agents || {}; t.agents.defaults = t.agents.defaults || {};
32
33
  t.agents.defaults.model = { ...(t.agents.defaults.model || {}), primary: block.agents.defaults.model.primary };
33
34
  t.models = t.models || {}; t.models.providers = t.models.providers || {};
34
35
  t.models.providers[PROVIDER_ID] = block.models.providers[PROVIDER_ID];
36
+ // The plugin service reads ITS port from plugins.entries.<id>.config, not from
37
+ // the provider baseUrl - a --port that only moved the baseUrl left the proxy
38
+ // on 8412 and OpenClaw dialing a port nobody listened on (real-install test).
39
+ if (port !== DEFAULT_PORT) {
40
+ t.plugins = t.plugins || {}; t.plugins.entries = t.plugins.entries || {};
41
+ t.plugins.entries[PROVIDER_ID] = { ...(t.plugins.entries[PROVIDER_ID] || {}), config: { ...(t.plugins.entries[PROVIDER_ID]?.config || {}), port } };
42
+ }
35
43
  return t;
36
44
  }
37
45
 
38
46
  async function main() {
39
47
  if (cmd === "help" || has("--help")) {
40
- out("agent402-openclaw setup [--credits-key a402_...] [--write] | proxy [--port N] [--upstream URL] | doctor");
48
+ out("agent402-openclaw setup [--credits-key a402_...|-] [--write] [--flat] | proxy [--port N] [--upstream URL] [--flat] | doctor");
41
49
  return 0;
42
50
  }
43
51
  const upstream = stripTrailingSlashes(opt("--upstream") || process.env.AGENT402_UPSTREAM || DEFAULT_UPSTREAM);
44
52
  const port = Number(opt("--port")) || DEFAULT_PORT;
53
+ // Pricing: metered by default (explicit models pay a per-request quote, from
54
+ // $0.001); --flat (or AGENT402_PRICING=flat) keeps every model on its flat tier.
55
+ const pricing = has("--flat") || String(process.env.AGENT402_PRICING || "").toLowerCase() === "flat" ? "flat" : "metered";
45
56
 
46
57
  if (cmd === "setup") {
47
58
  // The key may arrive on argv (lands in shell history), via AGENT402_CREDITS_KEY,
@@ -59,8 +70,12 @@ async function main() {
59
70
  out(`no credits key yet: buy a pack by card at ${upstream}/credits, then rerun with --credits-key a402_...`);
60
71
  }
61
72
  let routes;
62
- try { routes = await loadRoutes(upstream); } catch (e) { console.error(`could not read ${upstream}/v1/models: ${e?.message || e}`); return 2; }
73
+ try { routes = await loadRoutes(upstream, fetch, { pricing }); } catch (e) { console.error(`could not read ${upstream}/v1/models: ${e?.message || e}`); return 2; }
63
74
  const block = configBlock({ port, routes });
75
+ const primary = block.agents.defaults.model.primary;
76
+ if (primary === `${PROVIDER_ID}/${AUTO_ID}`) {
77
+ console.error(`WARNING: no model in this catalog (${pricing} pricing) accepts the ~${Math.round(OPENCLAW_MIN_INPUT_CHARS / 1000)}k chars OpenClaw sends before your first word; primary falls back to ${primary}, which will answer "Context overflow" on every turn. Rerun without --flat to use the metered route.`);
78
+ }
64
79
  const cfgPath = join(process.env.AGENT402_OPENCLAW_HOME || join(homedir(), ".openclaw"), "openclaw.json");
65
80
  if (has("--write")) {
66
81
  let existing = {};
@@ -74,10 +89,10 @@ async function main() {
74
89
  let mode = 0o600;
75
90
  try { if (existsSync(cfgPath)) mode = statSync(cfgPath).mode & 0o777; } catch { /* keep 0600 */ }
76
91
  const tmp = `${cfgPath}.${process.pid}.tmp`;
77
- writeFileSync(tmp, JSON.stringify(mergeInto(existing, block), null, 2) + "\n", { mode });
92
+ writeFileSync(tmp, JSON.stringify(mergeInto(existing, block, { port }), null, 2) + "\n", { mode });
78
93
  try { chmodSync(tmp, mode); } catch { /* best effort */ }
79
94
  renameSync(tmp, cfgPath);
80
- out(`wrote provider "${PROVIDER_ID}" + primary model ${PROVIDER_ID}/auto into ${cfgPath}; run: openclaw gateway restart`);
95
+ out(`wrote provider "${PROVIDER_ID}" + primary model ${primary} into ${cfgPath}; run: openclaw gateway restart`);
81
96
  } else {
82
97
  out(`add to ${cfgPath} (or rerun with --write):`);
83
98
  out(JSON.stringify(block, null, 2));
@@ -88,8 +103,8 @@ async function main() {
88
103
  if (cmd === "proxy") {
89
104
  const creditsKey = resolveCreditsKey();
90
105
  const payFetch = creditsKey ? null : await resolvePayFetch({}, (m) => console.error(m));
91
- const p = await startProxy({ upstream, creditsKey, payFetch, port, log: (m) => console.error(m) });
92
- out(`${p.baseUrl}/v1 (${p.mode})`);
106
+ const p = await startProxy({ upstream, creditsKey, payFetch, port, pricing, log: (m) => console.error(m) });
107
+ out(`${p.baseUrl}/v1 (${p.mode}, ${p.pricing} pricing)`);
93
108
  await new Promise(() => {}); // run until killed
94
109
  }
95
110
 
@@ -98,7 +113,7 @@ async function main() {
98
113
  out(`upstream: ${upstream}`);
99
114
  out(`credits key: ${key ? `present (${key.slice(0, 8)}…)` : "none"}`);
100
115
  out(`wallet key: ${/^0x[0-9a-fA-F]{64}$/.test(process.env.AGENT402_WALLET_KEY || "") ? "present" : "none"}`);
101
- try { const r = await loadRoutes(upstream); out(`gateway: ok, ${r.size} models`); } catch (e) { out(`gateway: unreachable (${e?.message || e})`); return 1; }
116
+ 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; }
102
117
  if (key) {
103
118
  try {
104
119
  const r = await fetch(`${upstream}/api/credits/balance`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000) });
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
@@ -8,6 +8,23 @@
8
8
 
9
9
  export const AUTO_ID = "auto";
10
10
 
11
+ /** The metered route's own caps (llm-gateway-kit TIERS["v1-chat-metered"]),
12
+ * used when the catalog entry does not carry `meteredMaxInputChars` /
13
+ * `meteredMaxTokens` (gateways older than 2026-08-26). A metered call is
14
+ * validated against the METERED tier's limits, never the model's flat home
15
+ * tier - so a nano model that takes 12k chars flat takes 200k chars metered. */
16
+ export const METERED_MAX_INPUT_CHARS = 200_000;
17
+ export const METERED_MAX_TOKENS = 8192;
18
+
19
+ /** What a model must accept to be OpenClaw's primary. Measured on OpenClaw
20
+ * 2026.7.1 with the stock agent: ~33k chars of system prompt plus ~37k chars
21
+ * of tool schemas BEFORE the user's first word, and OpenClaw's own precheck
22
+ * refuses the turn ("Context overflow") when that exceeds the model's
23
+ * contextWindow. The routed `auto` tier caps input at 16k chars, so a primary
24
+ * of `agent402/auto` never completes a single turn - found by the real-install
25
+ * test, invisible to every stub. */
26
+ export const OPENCLAW_MIN_INPUT_CHARS = 80_000;
27
+
11
28
  /** Linear trailing-slash strip (a regex like /\/+$/ is polynomial on long runs of "/"). */
12
29
  export function stripTrailingSlashes(s) {
13
30
  let str = String(s ?? ""); let end = str.length;
@@ -16,21 +33,32 @@ export function stripTrailingSlashes(s) {
16
33
  }
17
34
 
18
35
 
19
- /** The raw catalog entry -> { id, endpoint, tier, priceUsd, maxTokens, maxInputChars }. */
20
- export function routesFromCatalog(catalog) {
36
+ /** The raw catalog entry -> { id, endpoint, tier, priceUsd, maxTokens, maxInputChars }.
37
+ *
38
+ * `pricing`: "metered" (default) routes every explicit model to the gateway's
39
+ * metered endpoint when the catalog advertises one (`x402.meteredEndpoint`):
40
+ * the 402 quotes each request from its body, from `meteredFromUsd`, instead
41
+ * of the tier's flat price - a short call costs a fraction of a cent, a long
42
+ * one pays for what it asks. "flat" keeps every model on its home tier.
43
+ * `auto` always stays on the routed tier (the metered route needs an explicit
44
+ * model). */
45
+ export function routesFromCatalog(catalog, { pricing = "metered" } = {}) {
21
46
  const data = Array.isArray(catalog?.data) ? catalog.data : [];
22
47
  const routes = new Map();
48
+ const metered = pricing !== "flat";
23
49
  for (const m of data) {
24
50
  const x = m?.x402;
25
51
  if (!m?.id || !x?.endpoint || typeof x.priceUsd !== "number") continue;
26
52
  if (String(m.id).endsWith("*")) continue; // family wildcard, not an id
53
+ const useMetered = metered && typeof x.meteredEndpoint === "string" && x.meteredEndpoint.startsWith("/");
27
54
  routes.set(m.id, {
28
55
  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,
56
+ endpoint: useMetered ? x.meteredEndpoint : x.endpoint,
57
+ tier: useMetered ? "v1-chat-metered" : (x.tier || null),
58
+ priceUsd: useMetered ? (Number(x.meteredFromUsd) || x.priceUsd) : x.priceUsd,
59
+ metered: useMetered,
60
+ maxTokens: useMetered ? (Number(x.meteredMaxTokens) || METERED_MAX_TOKENS) : (Number(x.maxTokens) || 1024),
61
+ maxInputChars: useMetered ? (Number(x.meteredMaxInputChars) || METERED_MAX_INPUT_CHARS) : (Number(x.maxInputChars) || 32_000),
34
62
  stealth: !!x.stealth,
35
63
  });
36
64
  }
@@ -47,6 +75,23 @@ export function routesFromCatalog(catalog) {
47
75
  return routes;
48
76
  }
49
77
 
78
+ /** Preferred primaries, in order: capable tool-callers that are cheap when
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"];
81
+
82
+ /** The route OpenClaw should make its primary model: the first preferred id
83
+ * that fits OPENCLAW_MIN_INPUT_CHARS, else the cheapest fitting one, else
84
+ * null (nothing in this catalog can hold OpenClaw's prompt - the caller says
85
+ * so instead of writing a primary that fails every turn). `auto` is never
86
+ * chosen: its input cap is the routed tier's. */
87
+ export function defaultPrimary(routes, { minInputChars = OPENCLAW_MIN_INPUT_CHARS } = {}) {
88
+ const fits = [...routes.values()].filter((r) => r.id !== AUTO_ID && !r.stealth && r.maxInputChars >= minInputChars);
89
+ if (!fits.length) return null;
90
+ for (const id of PRIMARY_PREFERENCE) { const r = fits.find((x) => x.id === id); if (r) return r; }
91
+ fits.sort((a, b) => a.priceUsd - b.priceUsd || a.id.localeCompare(b.id));
92
+ return fits[0];
93
+ }
94
+
50
95
  const priceLabel = (usd) => (usd < 0.01 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`);
51
96
 
52
97
  /** OpenClaw `models[]` entries for a provider block. `auto` first. */
@@ -55,7 +100,7 @@ export function openclawModels(routes) {
55
100
  rows.sort((a, b) => (a.id === AUTO_ID ? -1 : b.id === AUTO_ID ? 1 : a.priceUsd - b.priceUsd || a.id.localeCompare(b.id)));
56
101
  return rows.map((r) => ({
57
102
  id: r.id,
58
- name: r.id === AUTO_ID ? `Agent402 auto (routed, ${priceLabel(r.priceUsd)}/call)` : `${r.id} (${priceLabel(r.priceUsd)}/call)`,
103
+ 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
104
  reasoning: false,
60
105
  input: ["text"],
61
106
  // Flat per-call billing: OpenClaw's per-token cost display does not apply.
@@ -6,5 +6,48 @@
6
6
  "onStartup": true
7
7
  },
8
8
  "enabledByDefault": true,
9
- "providers": ["agent402"]
9
+ "providers": [
10
+ "agent402"
11
+ ],
12
+ "configSchema": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "creditsKey": {
17
+ "type": "string",
18
+ "description": "Agent402 prepaid credits key (a402_...). Precedence: this, then AGENT402_CREDITS_KEY, then ~/.openclaw/agent402/credits.key."
19
+ },
20
+ "walletKey": {
21
+ "type": "string",
22
+ "description": "Optional EVM private key for x402 USDC payment (needs @x402/fetch + @x402/evm + viem installed)."
23
+ },
24
+ "port": {
25
+ "type": "number",
26
+ "description": "Loopback proxy port (default 8412)."
27
+ },
28
+ "upstream": {
29
+ "type": "string",
30
+ "description": "Agent402 gateway base URL (default https://agent402.tools)."
31
+ },
32
+ "pricing": {
33
+ "type": "string",
34
+ "enum": [
35
+ "metered",
36
+ "flat"
37
+ ],
38
+ "description": "metered (default): explicit models pay a per-request quote; flat: every model on its flat tier."
39
+ }
40
+ }
41
+ },
42
+ "uiHints": {
43
+ "creditsKey": {
44
+ "label": "Credits key",
45
+ "help": "Buy by card at https://agent402.tools/credits",
46
+ "sensitive": true
47
+ },
48
+ "walletKey": {
49
+ "label": "Wallet key",
50
+ "sensitive": true
51
+ }
52
+ }
10
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-openclaw",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
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
@@ -34,10 +34,10 @@ export const DEFAULT_UPSTREAM = "https://agent402.tools";
34
34
  export const PKG_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
35
35
  const MAX_BODY = 2 * 1024 * 1024;
36
36
 
37
- export async function loadRoutes(upstream, fetchImpl = fetch) {
37
+ export async function loadRoutes(upstream, fetchImpl = fetch, { pricing = "metered" } = {}) {
38
38
  const r = await fetchImpl(`${upstream}/v1/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
39
39
  if (!r.ok) throw new Error(`GET /v1/models -> HTTP ${r.status}`);
40
- return routesFromCatalog(await r.json());
40
+ return routesFromCatalog(await r.json(), { pricing });
41
41
  }
42
42
 
43
43
  function readBody(req, limit = MAX_BODY) {
@@ -63,14 +63,16 @@ const json = (res, status, obj, headers = {}) => {
63
63
  * @param {number} [o.port] 0 = ephemeral
64
64
  * @param {string} [o.host] default 127.0.0.1 (loopback only: anyone who can reach the port spends your key)
65
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
66
67
  * @param {(msg:string)=>void} [o.log]
67
68
  */
68
- 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 = () => {} } = {}) {
69
70
  upstream = stripTrailingSlashes(upstream);
71
+ pricing = pricing === "flat" ? "flat" : "metered";
70
72
  const key = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{16,80}$/.test(creditsKey) ? creditsKey : null;
71
73
  const paid = key ? fetchImpl : payFetch;
72
74
  const mode = key ? "credits" : payFetch ? "x402" : "unpaid";
73
- let table = routes || await loadRoutes(upstream, fetchImpl);
75
+ let table = routes || await loadRoutes(upstream, fetchImpl, { pricing });
74
76
  const stats = { requests: 0, forwarded: 0, errors: 0, startedAt: new Date().toISOString() };
75
77
 
76
78
  const server = createServer(async (req, res) => {
@@ -85,10 +87,10 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
85
87
  return json(res, 403, { error: { message: `Host "${hostName.slice(0, 64)}" is not loopback; refused.`, type: "forbidden" } });
86
88
  }
87
89
  if (req.method === "GET" && url.pathname === "/health") {
88
- 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 });
89
91
  }
90
92
  if (req.method === "GET" && url.pathname === "/v1/models") {
91
- 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 } })) });
92
94
  }
93
95
  if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
94
96
  const raw = await readBody(req);
@@ -130,11 +132,11 @@ export async function startProxy({ upstream = DEFAULT_UPSTREAM, creditsKey = nul
130
132
  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, host, resolve); });
131
133
  const actualPort = server.address().port;
132
134
  const baseUrl = `http://${host}:${actualPort}`;
133
- 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)`);
134
136
  return {
135
- baseUrl, port: actualPort, mode, upstream,
137
+ baseUrl, port: actualPort, mode, upstream, pricing,
136
138
  stats: () => ({ ...stats }),
137
- refreshModels: async () => { table = await loadRoutes(upstream, fetchImpl); return table.size; },
139
+ refreshModels: async () => { table = await loadRoutes(upstream, fetchImpl, { pricing }); return table.size; },
138
140
  close: () => new Promise((resolve) => server.close(() => resolve())),
139
141
  };
140
142
  }