openzoo 0.48.23 → 0.48.25

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/bin/openzoo.js CHANGED
@@ -53,6 +53,35 @@ if (Number(process.versions.node.split('.')[0]) < MIN_NODE && !process.env.OPENZ
53
53
  process.exit(1);
54
54
  }
55
55
 
56
+ // A DEAD IPv6 ROUTE MUST NOT EAT THE CONNECT BUDGET.
57
+ //
58
+ // x402-tokens.fly.dev publishes both AAAA (2a09:8280:1::16a:5795) and A
59
+ // (66.241.124.74). Node resolves `verbatim` by default, so on a network whose
60
+ // IPv6 is advertised but blackholed it opens the v6 socket first and waits —
61
+ // and the aggregate error names BOTH addresses, which reads as "the whole host
62
+ // is down" when v4 was never given a fair chance:
63
+ //
64
+ // Connect Timeout Error (attempted addresses: 2a09:8280:1::16a:5795:443,
65
+ // 66.241.124.74:443, timeout: 10000ms)
66
+ //
67
+ // Reported from the wild on macOS: the proxy binds, every upstream call times
68
+ // out, the health poll never passes, and it exits after ~1 minute looking like
69
+ // a startup bug. Same shape as the cloudflared IPv6+QUIC note in the wiki.
70
+ //
71
+ // Happy Eyeballs (RFC 8305) races the families instead of guessing, with a
72
+ // short per-attempt timeout so a dead route costs 500ms rather than ten
73
+ // seconds. Both calls are version-gated and best-effort — an older Node just
74
+ // keeps its default behaviour, and the ipv4first fallback covers it.
75
+ try {
76
+ const net = await import('node:net');
77
+ net.setDefaultAutoSelectFamily?.(true);
78
+ net.setDefaultAutoSelectFamilyAttemptTimeout?.(500);
79
+ } catch { /* older node: fall through to the DNS order below */ }
80
+ try {
81
+ const dns = await import('node:dns');
82
+ if (!process.env.OPENZOO_DNS_VERBATIM) dns.setDefaultResultOrder?.('ipv4first');
83
+ } catch { /* nothing to do */ }
84
+
56
85
  const cmd = process.argv[2] || 'proxy';
57
86
 
58
87
  const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
package/lib/info.js CHANGED
@@ -24,8 +24,53 @@ function fmtUi(raw, decimals) {
24
24
  * price is known ($1 stables); everything else gets an honest `?`.
25
25
  * A chain whose RPC does not answer prints as unreachable — never as zero.
26
26
  */
27
+ /**
28
+ * USD per whole token, by SYMBOL, straight from the 402 quote.
29
+ *
30
+ * The quote already carries `extra.tokenUsd` for every asset it settles in —
31
+ * the very "priced at the 402" this file kept promising in a footnote while
32
+ * printing `$?`. So a wallet holding 845,486 TOKEN summed to "≈ $0.11 known".
33
+ * Symbols come back wrapped (wTOKENx, wLEOSx, wUSDGx); strip the wrapper so a
34
+ * row for the plain token the user actually holds finds its price.
35
+ */
36
+ async function quotedPrices() {
37
+ const out = {};
38
+ try {
39
+ // Imported here, not at module scope, matching affordableUsd below — this
40
+ // file is loaded by `openzoo address`, which must work with no network.
41
+ const { withNamespace } = await import('./namespace.js');
42
+ const r = await fetch(`${config.apiBase}/v1/credits/topup`, {
43
+ method: 'POST',
44
+ headers: withNamespace({ 'content-type': 'application/json' }),
45
+ body: JSON.stringify({ usd: 1 }),
46
+ });
47
+ if (r.status !== 402) return out;
48
+ const ch = await r.json().catch(() => ({}));
49
+ for (const row of ch.accepts || []) {
50
+ const usd = Number(row?.extra?.tokenUsd);
51
+ const sym = String(row?.extra?.symbol || '');
52
+ if (!sym || !Number.isFinite(usd)) continue;
53
+ // wTOKENx -> TOKEN, wUSDGx -> USDG, yUSDCx -> USDC
54
+ const plain = sym.replace(/^[wy]/, '').replace(/x$/, '').toUpperCase();
55
+ out[sym.toUpperCase()] = usd;
56
+ if (!(plain in out)) out[plain] = usd;
57
+ }
58
+ } catch { /* offline: fall back to printing what we know */ }
59
+ return out;
60
+ }
61
+
27
62
  export async function printBalance() {
28
63
  const { keypair, evmPrivateKey } = loadOrCreateWallet();
64
+ const px = await quotedPrices();
65
+ const priceOf = (sym) => px[String(sym).toUpperCase()];
66
+ const show = (sym, ui) => {
67
+ const p = priceOf(sym);
68
+ if (p == null) return ' ($?)';
69
+ const v = ui * p;
70
+ // Sub-cent holdings read as "$0.00", which is indistinguishable from
71
+ // worthless; show enough digits that a real balance is visible.
72
+ return ` ($${v >= 0.01 || v === 0 ? v.toFixed(2) : v.toFixed(6)})`;
73
+ };
29
74
  const evmAddress = privateKeyToAccount(evmPrivateKey).address;
30
75
  const connection = new Connection(config.rpcUrl, 'confirmed');
31
76
 
@@ -34,14 +79,17 @@ export async function printBalance() {
34
79
  Promise.all(FUNDING_ASSETS.map((a) => tokenBalance(connection, keypair.publicKey, a.mint))),
35
80
  connection.getBalance(keypair.publicKey),
36
81
  ]);
37
- const usdcUi = balances[0].ui ?? 0;
38
- let knownUsd = usdcUi;
82
+ // Seeded at zero: every asset is priced from the quote below, so seeding
83
+ // with USDC would count it twice.
84
+ let knownUsd = 0;
39
85
  let anyFunds = balances.some((b) => b.raw);
40
86
 
41
87
  console.log(`Solana — ${keypair.publicKey.toBase58()}`);
42
88
  FUNDING_ASSETS.forEach((a, i) => {
43
- const usd = a.symbol === 'USDC' ? ` ($${(balances[i].ui ?? 0).toFixed(2)})` : ' ($?, valued at the 402)';
44
- console.log(` ${a.symbol.padEnd(11)}: ${balances[i].ui ?? 0}${usd}`);
89
+ const ui = balances[i].ui ?? 0;
90
+ const p = priceOf(a.symbol);
91
+ if (p != null) knownUsd += ui * p;
92
+ console.log(` ${a.symbol.padEnd(11)}: ${ui}${show(a.symbol, ui)}`);
45
93
  });
46
94
  console.log(` ${'SOL'.padEnd(11)}: ${lamports / 1e9} (gas — optional, payments are sponsored)`);
47
95
 
@@ -57,9 +105,9 @@ export async function printBalance() {
57
105
  assets.forEach((a, i) => {
58
106
  const ui = fmtUi(raws[i], a.decimals);
59
107
  if (raws[i] > 0n) anyFunds = true;
60
- if (a.usd != null) knownUsd += ui * a.usd;
61
- const usd = a.usd != null ? ` ($${(ui * a.usd).toFixed(2)})` : ' ($?)';
62
- console.log(` ${a.symbol.padEnd(11)}: ${ui}${usd}`);
108
+ const p = priceOf(a.symbol) ?? a.usd;
109
+ if (p != null) knownUsd += ui * p;
110
+ console.log(` ${a.symbol.padEnd(11)}: ${ui}${p != null ? show(a.symbol, ui) : ' ($?)'}`);
63
111
  });
64
112
  console.log(` ${'ETH'.padEnd(11)}: ${fmtUi(native, 18)} (gas — optional, payments are sponsored)`);
65
113
  } catch {
@@ -67,7 +115,7 @@ export async function printBalance() {
67
115
  }
68
116
  }
69
117
 
70
- console.log(`value : ≈ $${knownUsd.toFixed(2)} known (stable legs; TOKEN and the RH memecoins are priced at the 402, shown as $?)`);
118
+ console.log(`value : ≈ $${knownUsd.toFixed(2)}${Object.keys(px).length ? ' (every asset priced at the 402)' : ' known (402 unreachable unpriced assets shown as $?)'}`);
71
119
  if (!anyFunds) {
72
120
  console.log(`fund : ${fundingLine(keypair.publicKey.toBase58())}`);
73
121
  console.log(` or USDC on Base to ${evmAddress}`);
@@ -110,10 +158,20 @@ export async function affordableUsd() {
110
158
  const perUsd = BigInt(row.maxAmountRequired || '0');
111
159
  if (perUsd <= 0n) continue;
112
160
  try {
113
- const bal = await client.balanceForAccept?.(row);
114
- const raw = typeof bal === 'bigint' ? bal : BigInt(bal?.raw ?? 0);
115
- const usd = Number(raw * 1000n / perUsd) / 1000;
116
- if (usd > best) best = usd;
161
+ // SPENDABLE, not merely wrapped. Every quote names the WRAPPER, and the
162
+ // plain token is converted at payment time (topUpQuotedAsset /
163
+ // acquireWrappedIfNeeded) so counting only pre-wrapped balance told a
164
+ // user holding 14,847 TOKEN that their wallet "covers only $1.0230" and
165
+ // sent them off to fund an account that was already funded.
166
+ let usd;
167
+ if (client.spendableUsdForAccept) {
168
+ usd = await client.spendableUsdForAccept(row);
169
+ } else {
170
+ const bal = await client.balanceForAccept?.(row);
171
+ const raw = typeof bal === 'bigint' ? bal : BigInt(bal?.raw ?? 0);
172
+ usd = Number(raw * 1000n / perUsd) / 1000;
173
+ }
174
+ if (Number.isFinite(usd) && usd > best) best = usd;
117
175
  } catch { /* a rail we cannot read is simply not a candidate */ }
118
176
  }
119
177
  return best;
package/lib/launch.js CHANGED
@@ -91,7 +91,26 @@ export async function launchClaude(argv) {
91
91
  try { up = (await fetch(`${base}/models`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* keep waiting */ }
92
92
  }
93
93
  if (!up) {
94
- done(`proxy did not answer on ${base}`);
94
+ // NAME THE REAL FAILURE. /v1/models is proxied upstream, so an unreachable
95
+ // gateway looks identical to a broken local proxy — and the message sent
96
+ // one user hunting their own machine for an hour while the actual fault
97
+ // was a blackholed IPv6 route to fly.dev. Probe the gateway directly and
98
+ // say which of the two is actually down.
99
+ let upstream = null;
100
+ try {
101
+ const r = await fetch(`${config.apiBase}/v1/models`, { signal: AbortSignal.timeout(8000) });
102
+ upstream = r.ok;
103
+ } catch (e) { upstream = e?.message || false; }
104
+ if (upstream !== true) {
105
+ done(`cannot reach the gateway at ${config.apiBase}`);
106
+ console.error(` the local proxy started fine; ${config.apiBase} did not answer.`);
107
+ console.error(` reason: ${typeof upstream === 'string' ? upstream : 'no response'}`);
108
+ console.error(' if that mentions two addresses (one starting 2a09:), your network');
109
+ console.error(' advertises IPv6 but drops it — this build already prefers IPv4;');
110
+ console.error(' force it explicitly with: NODE_OPTIONS=--dns-result-order=ipv4first');
111
+ } else {
112
+ done(`proxy did not answer on ${base} (the gateway is up)`);
113
+ }
95
114
  console.error(' full log: ~/.openzoo/proxy.log');
96
115
  console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
97
116
  process.exit(1);
package/lib/pay.js CHANGED
@@ -150,6 +150,66 @@ export class PayClient {
150
150
  return BigInt(raw || 0);
151
151
  }
152
152
 
153
+ /**
154
+ * What this wallet can actually PAY with on this rail, in settlement-asset
155
+ * units — wrapped holdings PLUS the plain token it can convert on the fly.
156
+ *
157
+ * balanceForAccept answers a different question: how much of the WRAPPED
158
+ * asset is sitting there right now. Every quote names the wrapper (wTOKENx,
159
+ * the RH vault twins), so a wallet holding 845,486 plain TOKEN read as
160
+ * "$3.14 covered" — only the sliver already wrapped — while `openzoo balance`
161
+ * showed the full holding priced as `$?`. Two different wrong answers about
162
+ * the same wallet, and a user was told to fund an account with $177 in it.
163
+ *
164
+ * buildPaymentFor already wraps what it needs at payment time (topUpQuotedAsset
165
+ * on Solana, acquireWrappedIfNeeded on EVM), so the convertible balance IS
166
+ * spendable and the estimate was simply lying by omission. Advisory only:
167
+ * anything unreadable degrades to the wrapped figure rather than throwing,
168
+ * because this feeds a display and a top-up hint, never a settlement.
169
+ */
170
+ async spendableUsdForAccept(accept) {
171
+ const perUsd = BigInt(accept.maxAmountRequired || '0');
172
+ if (perUsd <= 0n) return 0;
173
+ const wrappedRaw = await this.balanceForAccept(accept).catch(() => 0n);
174
+ let usd = Number(wrappedRaw * 1000n / perUsd) / 1000;
175
+
176
+ // Price the PLAIN token with the quote's own tokenUsd, NOT with the pool's
177
+ // supply/reserves ratio. That ratio is share accounting, not a price: the
178
+ // live wTOKENx pool reads supply/reserves ≈ 5659, so treating it as a
179
+ // conversion rate valued 845,486 TOKEN at $1,004,132. The 402 already
180
+ // publishes what a whole token is worth, and the wrapper is a claim on the
181
+ // same asset — so underlying and wrapped are priced identically, which is
182
+ // exactly what `openzoo balance` now shows.
183
+ const px = Number(accept?.extra?.tokenUsd);
184
+ if (!Number.isFinite(px) || px <= 0) return usd;
185
+ const rail = railOf(accept);
186
+ try {
187
+ if (rail === 'solana') {
188
+ const pool = await resolvePool(this.connection, accept.asset);
189
+ if (!pool) return usd;
190
+ const under = await tokenBalance(this.connection, this.keypair.publicKey,
191
+ pool.underlying.toBase58());
192
+ usd += Number(under?.ui || 0) * px;
193
+ return usd;
194
+ }
195
+ // EVM wrappers are ERC-4626; asset() names the plain token behind them.
196
+ const { createPublicClient, http } = await import('viem');
197
+ const pc = createPublicClient({ transport: http(evmRpcFor(rail)) });
198
+ const ABI = [
199
+ { name: 'asset', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
200
+ { name: 'decimals', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
201
+ ];
202
+ const under = await pc.readContract({ address: accept.asset, abi: ABI, functionName: 'asset', args: [] });
203
+ const raw = await evmTokenBalance({ rpcUrl: evmRpcFor(rail), token: under, owner: this.evmAddress });
204
+ if (!raw || raw <= 0n) return usd;
205
+ const dec = Number(accept?.extra?.decimals ?? 18);
206
+ usd += (Number(raw) / 10 ** dec) * px;
207
+ return usd;
208
+ } catch {
209
+ return usd; // not a wrapper, or a rail we cannot read
210
+ }
211
+ }
212
+
153
213
  async buildPaymentFor(accept, onStage) {
154
214
  const rail = railOf(accept);
155
215
  if (rail === 'solana') {
package/lib/wrap.js CHANGED
@@ -180,6 +180,19 @@ export async function poolState(connection, pool) {
180
180
  export function buildWrapInstructions({ pool, owner, depositRaw, rentPayer = owner }) {
181
181
  const userWrapped = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
182
182
  const userUnderlying = getAssociatedTokenAddressSync(pool.underlying, owner, false, pool.underlyingProgram);
183
+ // NINE ACCOUNTS, AND THE PROGRAM PULLS THE DEPOSIT ITSELF.
184
+ //
185
+ // This used to emit three instructions — ensure ATA, Wrap, then a separate
186
+ // TransferChecked moving the underlying into escrow — because the program
187
+ // only minted shares and trusted that the caller's own transfer would follow.
188
+ // Nothing enforced it. On 2026-08-18 a caller sent the Wrap instruction ALONE
189
+ // and minted shares backed by nothing, then unwrapped them: 829,559 TOKEN out
190
+ // of the vault, NAV 1 -> 0.000177.
191
+ //
192
+ // The deployed program (slot 440219442) now CPIs the transfer itself, so the
193
+ // Wrap instruction carries the depositor's source account and signature and
194
+ // the separate transfer is GONE. A 5-account call is rejected outright with
195
+ // NotEnoughAccounts (0x6a) — verified against mainnet by simulation.
183
196
  const wrapIx = new TransactionInstruction({
184
197
  programId: pool.programId || WRAP_PROGRAM_ID,
185
198
  keys: [
@@ -188,16 +201,17 @@ export function buildWrapInstructions({ pool, owner, depositRaw, rentPayer = own
188
201
  { pubkey: userWrapped, isSigner: false, isWritable: true },
189
202
  { pubkey: pool.authority, isSigner: false, isWritable: false },
190
203
  { pubkey: pool.wrappedProgram, isSigner: false, isWritable: false },
204
+ // the deposit the program will pull, and who authorises it
205
+ { pubkey: userUnderlying, isSigner: false, isWritable: true },
206
+ { pubkey: owner, isSigner: true, isWritable: false },
207
+ { pubkey: pool.underlying, isSigner: false, isWritable: false },
208
+ { pubkey: pool.underlyingProgram, isSigner: false, isWritable: false },
191
209
  ],
192
210
  data: Buffer.concat([Buffer.from([1]), u64le(depositRaw), Buffer.from([pool.bump])]),
193
211
  });
194
212
  return [
195
213
  createAssociatedTokenAccountIdempotentInstruction(rentPayer, userWrapped, owner, pool.wrapped, pool.wrappedProgram),
196
214
  wrapIx,
197
- createTransferCheckedInstruction(
198
- userUnderlying, pool.underlying, pool.escrow, owner,
199
- depositRaw, pool.underlyingDecimals, [], pool.underlyingProgram,
200
- ),
201
215
  ];
202
216
  }
203
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.23",
3
+ "version": "0.48.25",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/lib/proxy.js.bak DELETED
@@ -1,980 +0,0 @@
1
- import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import http from 'node:http';
5
- import crypto from 'node:crypto';
6
- import { Readable } from 'node:stream';
7
- import {
8
- config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
9
- } from './config.js';
10
- import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
11
- import { tokenBalance } from './x402.js';
12
- import { evmTokenBalance } from './evm.js';
13
- import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
14
- import { maybeRewriteModel, rewritablePath, augmentModelList, ALIAS_IDS } from './models.js';
15
- import { forgetContext } from './contexts.js';
16
- import { injectBrief } from './brief.js';
17
- import { withNamespace } from './namespace.js';
18
- import { anthropicToOpenAI, openAIToAnthropic, writeAnthropicSse } from './anthropic.js';
19
-
20
- const HOP_BY_HOP = new Set([
21
- 'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
22
- 'proxy-authorization', 'proxy-connection', 'te', 'trailer', 'content-length',
23
- // `Expect: 100-continue` is sent by curl and most HTTP libraries once a body
24
- // passes ~1KB. undici REFUSES it outright ("expect header not supported"),
25
- // so forwarding it made every LARGE-body request fail while small ones
26
- // worked — i.e. it broke exactly the corpus calls this proxy exists for.
27
- 'expect',
28
- // The harness's api key (sk-openzoo or anything) is accepted and dropped:
29
- // the zoo takes payment, not keys.
30
- 'authorization',
31
- ]);
32
-
33
- /**
34
- * Be forgiving about the path, the same way we are about model ids.
35
- * Harnesses are configured with a base_url that ALREADY ends in /v1, so an
36
- * agent building "{base}/v1/hrr/bind" sends /v1/v1/hrr/bind and gets a 404 it
37
- * cannot diagnose (observed: agent concluded the bind endpoint "is not
38
- * functioning as advertised" and fell back to stuffing the corpus inline).
39
- * Collapse repeated /v1 and add a missing one.
40
- */
41
- function normalizePath(url) {
42
- const [path, query] = (url || '/').split(/(?=\?)/);
43
- let p = path.replace(/^(?:\/v1)+(?=\/v1\/)/, ''); // /v1/v1/x -> /v1/x
44
- if (!/^\/v1(\/|$)/.test(p) && /^\/(hrr|chat|models|completions|embeddings|usage)/.test(p)) p = `/v1${p}`;
45
- return p === path ? url : `${p}${query || ''}`;
46
- }
47
-
48
- function upstreamHeaders(req) {
49
- const out = {};
50
- for (const [k, v] of Object.entries(req.headers)) {
51
- if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
52
- }
53
- // EVERY forwarded request carries this wallet's context namespace, not just
54
- // the ones PayClient builds itself. Without it a bind sent THROUGH the proxy
55
- // (an agent posting to /v1/hrr/bind) landed in the shared tenant while the
56
- // chat that referenced it looked in the wallet's tenant — the context was
57
- // unreachable and every spill bind came back 400, silently forwarding the
58
- // whole body at full price.
59
- return withNamespace(out);
60
- }
61
-
62
- async function readBody(req) {
63
- const chunks = [];
64
- for await (const c of req) chunks.push(c);
65
- return Buffer.concat(chunks);
66
- }
67
-
68
- /** Pipe an upstream fetch Response to the client, unbuffered (SSE-safe). */
69
- function relay(res, upstream) {
70
- const headers = {};
71
- upstream.headers.forEach((v, k) => {
72
- if (!['transfer-encoding', 'connection', 'content-encoding', 'content-length'].includes(k)) headers[k] = v;
73
- });
74
- res.writeHead(upstream.status, headers);
75
- if (!upstream.body) { res.end(); return Promise.resolve(); }
76
- return new Promise((resolve) => {
77
- const body = Readable.fromWeb(upstream.body);
78
- body.on('error', () => res.destroy());
79
- res.on('close', () => body.destroy());
80
- body.on('end', resolve);
81
- body.pipe(res);
82
- });
83
- }
84
-
85
- function jsonErr(res, status, message, extraFields = {}) {
86
- res.writeHead(status, { 'content-type': 'application/json' });
87
- res.end(JSON.stringify({ error: { message }, ...extraFields }));
88
- }
89
-
90
- const mb = (n) => (n / 1048576).toFixed(1);
91
-
92
- /**
93
- * Every fundable balance across all three chains, for the startup line and
94
- * the live refresh. Each read is independent and advisory — one lagging RPC
95
- * drops its entry rather than blanking the whole line.
96
- */
97
- async function snapshotBalances(client) {
98
- const out = [];
99
- try {
100
- const bals = await Promise.all(
101
- FUNDING_ASSETS.map((a) => tokenBalance(client.connection, client.keypair.publicKey, a.mint)),
102
- );
103
- FUNDING_ASSETS.forEach((a, i) => out.push({ symbol: a.symbol, ui: Number(bals[i].ui ?? 0), chain: 'solana' }));
104
- } catch { /* Solana RPC hiccup — EVM entries still report */ }
105
- const owner = client.evmAddress;
106
- if (owner) {
107
- await Promise.all(Object.entries(EVM_FUNDING_ASSETS).flatMap(([rail, assets]) => assets.map(async (a) => {
108
- try {
109
- const raw = await evmTokenBalance({ rpcUrl: evmRpcFor(rail), token: a.address, owner });
110
- out.push({ symbol: a.symbol, ui: Number(raw) / 10 ** a.decimals, chain: rail });
111
- } catch { /* advisory */ }
112
- })));
113
- }
114
- // Parallel reads land in racy order; sort so the printed line is stable
115
- // and diffs against the previous snapshot read cleanly.
116
- const rank = { solana: 0, base: 1, robinhood: 2 };
117
- return out.sort((a, b) => (rank[a.chain] ?? 9) - (rank[b.chain] ?? 9) || a.symbol.localeCompare(b.symbol));
118
- }
119
-
120
- /** Solana entries always show; EVM entries only once they hold something. */
121
- function balanceLine(snap) {
122
- return snap
123
- .filter((b) => b.chain === 'solana' || b.ui > 0)
124
- .map((b) => `${b.ui} ${b.symbol}${b.chain !== 'solana' ? ` (${b.chain})` : ''}`)
125
- .join(' · ');
126
- }
127
-
128
- /**
129
- * The zoo answers chat completions as ONE JSON object (it settles payment
130
- * before serving — there is nothing to stream until generation is done).
131
- * Harnesses that sent `stream: true` expect SSE and treat a JSON body as a
132
- * dead connection: Cursor shows "Reconnecting…", RETRIES, and every retry is
133
- * a fresh payment. So the proxy honours the contract itself — the finished
134
- * completion is re-emitted as spec-shaped chat.completion.chunk events.
135
- */
136
- function serveAsSse(res, data, upstream) {
137
- const headers = {
138
- 'content-type': 'text/event-stream; charset=utf-8',
139
- 'cache-control': 'no-cache',
140
- // Tell any proxy in front of us (cloudflare quick tunnel, nginx) NOT to
141
- // buffer the stream. Without this the tunnel accumulates the whole SSE
142
- // body and releases it at once, which reorders/merges the tool_call frames
143
- // an agent parses incrementally — observed as "provider-side tool-call
144
- // protocol error" over the tunnel while localhost (no proxy) is fine.
145
- 'x-accel-buffering': 'no',
146
- connection: 'keep-alive',
147
- };
148
- const settle = upstream?.headers?.get?.('x-payment-response');
149
- if (settle) headers['x-payment-response'] = settle;
150
- res.writeHead(200, headers);
151
- const base = {
152
- id: data.id, object: 'chat.completion.chunk', created: data.created, model: data.model,
153
- };
154
- const ev = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`);
155
- for (const c of data.choices || []) {
156
- ev({ ...base, choices: [{ index: c.index ?? 0, delta: { role: 'assistant' }, finish_reason: null }] });
157
- if (c.message?.content) {
158
- ev({ ...base, choices: [{ index: c.index ?? 0, delta: { content: c.message.content }, finish_reason: null }] });
159
- }
160
- // Agent mode lives or dies here: a finish_reason of "tool_calls" with the
161
- // calls themselves dropped strands the harness mid-turn (observed: Cursor
162
- // agent hangs). Streaming spec: tool_calls ride the delta with an index,
163
- // arguments as a string chunk — one full chunk per call is valid SSE.
164
- if (Array.isArray(c.message?.tool_calls) && c.message.tool_calls.length) {
165
- ev({
166
- ...base,
167
- choices: [{
168
- index: c.index ?? 0,
169
- delta: {
170
- tool_calls: c.message.tool_calls.map((t, i) => ({
171
- index: i,
172
- id: t.id,
173
- type: t.type || 'function',
174
- function: { name: t.function?.name, arguments: t.function?.arguments ?? '' },
175
- })),
176
- },
177
- finish_reason: null,
178
- }],
179
- });
180
- }
181
- ev({ ...base, choices: [{ index: c.index ?? 0, delta: {}, finish_reason: c.finish_reason ?? 'stop' }], ...(data.usage ? { usage: data.usage } : {}) });
182
- }
183
- res.write('data: [DONE]\n\n');
184
- res.end();
185
- }
186
-
187
- /**
188
- * Replay guard — a harness that cannot consume a response retries the SAME
189
- * body within seconds, and each retry used to be a fresh payment (observed:
190
- * six identical $0.06 settles for one Cursor message). An identical POST body
191
- * arriving within the window is served the cached completion, not re-paid.
192
- * Window is deliberately short: a genuinely new turn always differs (harnesses
193
- * resend the whole conversation), so only true retries can hit.
194
- */
195
- const REPLAY_TTL_MS = 30_000;
196
- const replayCache = new Map(); // sha256(body) -> { at, data, settle }
197
- /**
198
- * The key MUST include the routing headers, not just the body.
199
- *
200
- * Keying on the body alone was a correctness bug, not merely a caching one:
201
- * N shards asking the SAME question of N DIFFERENT bound corpora send
202
- * byte-identical bodies and differ only in X-HRR-Context. They collided on one
203
- * key, so shards 2..N were served shard 1's answer — REPRODUCED in the field:
204
- * 10 shards, 7 byte-identical replies across corpora known to differ, and the
205
- * batch finished in 12s where a single uncached call took ~7s.
206
- *
207
- * Wrong answers attributed to the wrong corpus is a far worse failure than the
208
- * double-billing this cache exists to prevent, so every header that can change
209
- * the ANSWER joins the key.
210
- */
211
- const REPLAY_KEY_HEADERS = ['x-hrr-context', 'x-hrr-top-k', 'x-hrr-gate', 'x-openzoo-namespace'];
212
-
213
- function replayKey(bodyBuf, headers = {}) {
214
- const h = crypto.createHash('sha256').update(bodyBuf);
215
- for (const name of REPLAY_KEY_HEADERS) {
216
- const v = headers[name];
217
- if (v) h.update(`\n${name}:${v}`);
218
- }
219
- return h.digest('hex');
220
- }
221
- function replayGet(key) {
222
- const hit = replayCache.get(key);
223
- if (!hit) return null;
224
- if (Date.now() - hit.at > REPLAY_TTL_MS) { replayCache.delete(key); return null; }
225
- return hit;
226
- }
227
- function replayPut(key, data, settle) {
228
- replayCache.set(key, { at: Date.now(), data, settle });
229
- if (replayCache.size > 50) {
230
- const oldest = [...replayCache.entries()].sort((a, b) => a[1].at - b[1].at)[0];
231
- if (oldest) replayCache.delete(oldest[0]);
232
- }
233
- }
234
-
235
- /**
236
- * "The body never ships twice" at the proxy. A chat body whose LAST message
237
- * carries a huge pasted corpus gets split at its last blank line — corpus vs
238
- * ask — so the corpus can be bound ONCE on the zoo and every later call ships
239
- * only the ask plus X-HRR-Context. The split point is deterministic, which is
240
- * what makes the sha256 manifest hit on run 2 even when the question changed.
241
- *
242
- * Conservative on purpose: only a single big STRING content on the final
243
- * message, only when a blank-line boundary exists, and any failure falls back
244
- * to sending the original body untouched — caching must never break a call.
245
- * Returns null (send as-is) or { body, contextId, hash, corpus, reused, savedBytes }.
246
- */
247
- async function maybeCacheCorpus(req, bodyBuf, log) {
248
- if (contextCacheDisabled()) return null;
249
- if (req.method !== 'POST' || !(req.url || '').includes('/chat/completions')) return null;
250
- if (req.headers['x-hrr-context']) return null; // harness manages its own context
251
- if (bodyBuf.length <= BIND_MIN_CHARS) return null;
252
- let body;
253
- try { body = JSON.parse(bodyBuf.toString('utf8')); } catch { return null; }
254
- const msgs = Array.isArray(body?.messages) ? body.messages : null;
255
- if (!msgs?.length) return null;
256
- const last = msgs[msgs.length - 1];
257
- if (typeof last?.content !== 'string' || last.content.length <= BIND_MIN_CHARS) return null;
258
- const cut = last.content.lastIndexOf('\n\n');
259
- if (cut < BIND_MIN_CHARS) return null;
260
- const corpus = last.content.slice(0, cut);
261
- const ask = last.content.slice(cut + 2).trim();
262
- if (!ask || ask.length > 8000) return null;
263
-
264
- const bind = await bindCorpus(corpus, {
265
- onStage: (stage, info) => {
266
- if (stage === 'binding') log(`binding ${mb(info.bytes)}MB corpus to holographic memory (one-time)...`);
267
- },
268
- });
269
- if (bind.reused) {
270
- log(`corpus already bound (${bind.hash.slice(0, 12)}… → ${bind.contextId}) — skipped ${mb(bind.bytes)}MB upload`);
271
- } else {
272
- log(`corpus bound once (${mb(bind.bytes)}MB → ${bind.contextId}) — repeats of this body are near-free`);
273
- }
274
- const rewritten = { ...body, messages: [...msgs.slice(0, -1), { ...last, content: ask }] };
275
- return {
276
- body: Buffer.from(JSON.stringify(rewritten)),
277
- contextId: bind.contextId,
278
- hash: bind.hash,
279
- corpus,
280
- reused: bind.reused,
281
- savedBytes: bind.bytes,
282
- };
283
- }
284
-
285
- /**
286
- * `requireToken` / `sessionMaxUsd` are TUNNEL MODE (see lib/tunnel.js): once the
287
- * proxy is reachable from the internet, the api key stops being decorative and
288
- * becomes the only thing between a stranger and your wallet. Both are off by
289
- * default, so localhost behaviour is unchanged.
290
- */
291
- export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null, autoTunnel = false } = {}) {
292
- const client = new PayClient();
293
- const log = silent ? () => {} : (...a) => console.log(...a);
294
- // ALWAYS-ON. `silent: true` is used by the editor path to keep startup tidy,
295
- // but it also swallowed the per-request lines and the payment receipts — so the
296
- // terminal sat blank and there was no way to tell a working setup from an editor
297
- // quietly answering from its own backend. Traffic and receipts are the whole
298
- // point of watching this window; they are never silenced.
299
- // ALWAYS-ON, BUT NEVER INTO A HARNESS'S TERMINAL. `silent` means another
300
- // process (openzoo claude, the editor launcher) owns stdio — printing request
301
- // lines / payment receipts there corrupts that program's output (observed: the
302
- // Solana receipt leaking into the Claude Code CLI). When silent, route this
303
- // channel to a log file instead; only print to the console when we own it.
304
- let paidCalls = 0;
305
- let sayFile = null;
306
- if (silent) {
307
- try {
308
- sayFile = path.join(os.homedir(), '.openzoo', 'proxy.log');
309
- mkdirSync(path.dirname(sayFile), { recursive: true });
310
- } catch { sayFile = null; }
311
- }
312
- const say = (...a) => {
313
- const line = a.join(' ');
314
- if (sayFile) { try { appendFileSync(sayFile, line + '\n'); return; } catch { /* fall through */ } }
315
- console.log(line);
316
- };
317
- let sessionSpent = 0;
318
- let sessionCogs = 0;
319
- let sessionDirect = 0;
320
- const MARKUP = 3; // confirmed constant, see .claude/wiki.md "Margin needs a like-for-like denominator"
321
- let tunnelSpent = 0;
322
- // Live balance refresh state — the real implementation is assigned in the
323
- // banner section below; the handler only ever calls scheduleRefresh().
324
- let lastSnap = null;
325
- let refreshBalances = async () => {};
326
- let refreshPending = false;
327
- const scheduleRefresh = (ms) => {
328
- if (silent || refreshPending) return;
329
- refreshPending = true;
330
- const t = setTimeout(async () => { refreshPending = false; await refreshBalances(); }, ms);
331
- t.unref?.();
332
- };
333
- // Set once cloudflared is up (see below). Gating keys off the REQUEST's
334
- // origin, not off whether the URL exists yet, so there is no startup window
335
- // where public traffic slips through ungated.
336
- let tunnelGate = null;
337
- // How many chat requests actually ARRIVED. The single number that answers
338
- // "is the editor really routing through us?" — an editor that silently keeps
339
- // using its own backend leaves this at 0 while looking perfectly healthy.
340
- let servedRequests = 0;
341
- let tunnelError = null;
342
-
343
- const server = http.createServer(async (req, res) => {
344
- // MCP on the SAME port as the proxy. One `npx openzoo` gives a harness
345
- // both surfaces: point base_url at /v1 for transparent context spilling,
346
- // or add /mcp for tools (zoo_bind, zoo_ask...). Running two commands to
347
- // get both was friction nobody should pay.
348
- // This wallet's own running total for THIS proxy process — every paid
349
- // call through this port counts (GUI, MCP, CLI, any harness), not just
350
- // whichever surface happens to be asking. Local-only, no auth needed:
351
- // it's a number, not a capability.
352
- if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
353
- res.writeHead(200, { 'content-type': 'application/json' });
354
- res.end(JSON.stringify({ spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls }));
355
- return;
356
- }
357
-
358
- // Public addresses only — never the private key. Exists so a caller (the
359
- // grokui error path, in particular) can print REAL funding instructions
360
- // inline instead of telling the user to go look somewhere else.
361
- if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/wallet') {
362
- res.writeHead(200, { 'content-type': 'application/json' });
363
- res.end(JSON.stringify({
364
- solana: client.address,
365
- evm: client.evmAddress,
366
- funding: fundingLine(client.address),
367
- // from the same background poll the startup/refresh lines use, so a
368
- // caller can tell a genuinely empty wallet from a transient 402
369
- balances: balanceLine(lastSnap || []) || null,
370
- funded: (lastSnap || []).some((b) => b.ui > 0),
371
- }));
372
- return;
373
- }
374
-
375
- if ((req.url || '').split('?')[0] === '/mcp') {
376
- try {
377
- const { handleMcpRequest } = await import('./mcphttp.js');
378
- await handleMcpRequest(req, res);
379
- } catch (err) {
380
- jsonErr(res, 500, `openzoo mcp error: ${err.message}`);
381
- }
382
- return;
383
- }
384
-
385
- // THE CUTESY GUI. Local browsers hitting GET / get a little chat app —
386
- // model zoo, bind-a-corpus drawer, live spent/saved ticker off the x402
387
- // receipts. Tunnel traffic (cf headers) keeps the JSON discovery below:
388
- // the GUI is the operator's, not the public's.
389
- {
390
- const p0 = (req.url || '').split('?')[0];
391
- const local = !(req.headers['cf-connecting-ip'] || req.headers['cf-ray']);
392
- if (local && req.method === 'GET' && (p0 === '/' || p0 === '/gui')) {
393
- try {
394
- const html = readFileSync(new URL('./gui.html', import.meta.url), 'utf8');
395
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
396
- res.end(html);
397
- return;
398
- } catch { /* fall through to proxy behavior if the file is missing */ }
399
- }
400
- }
401
-
402
- const normalized = normalizePath(req.url);
403
- if (normalized !== req.url) {
404
- log(`path ${req.url} -> ${normalized} (base_url already ends in /v1)`);
405
- req.url = normalized;
406
- }
407
- let url = `${config.apiBase}${req.url}`;
408
- // Requests that arrived over the public quick-tunnel URL carry cloudflared's
409
- // headers; nothing dialing 127.0.0.1 directly does. That distinction is what
410
- // lets localhost stay keyless while the SAME port is safely public.
411
- const viaTunnel = !requireToken && tunnelGate
412
- && Boolean(req.headers['cf-connecting-ip'] || req.headers['cf-ray']);
413
- // Auth first: refuse before reading a body, forwarding, quoting or paying.
414
- if (requireToken) {
415
- const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
416
- if (got !== requireToken) {
417
- log(`tunnel: 401 ${req.method} ${req.url} from ${req.socket.remoteAddress}`);
418
- jsonErr(res, 401, 'unauthorized: this openzoo tunnel requires the api key printed at startup');
419
- return;
420
- }
421
- if (sessionMaxUsd != null && sessionSpent >= sessionMaxUsd) {
422
- jsonErr(res, 402, `openzoo tunnel session cap reached ($${sessionMaxUsd}) — restart the tunnel or raise OPENZOO_TUNNEL_MAX_USD`);
423
- return;
424
- }
425
- }
426
- // ROUTING TRUTH, SERVED FROM WHATEVER URL YOU REACHED US ON. A cloud agent
427
- // only ever touches the tunnel, so asking a local MCP process "what is my
428
- // routing" is the wrong question — the answer has to come from the tunnel
429
- // itself, and name the tunnel. Free and unauthenticated: discovery must
430
- // never be the thing that is gated.
431
- {
432
- const p0 = (req.url || '').split('?')[0];
433
- if (req.method === 'GET' && (p0 === '/v1/info' || p0 === '/info')) {
434
- const self = viaTunnel && tunnelGate?.publicUrl
435
- ? `${tunnelGate.publicUrl}/v1`
436
- : `http://localhost:${config.port}/v1`;
437
- res.writeHead(200, { 'content-type': 'application/json' });
438
- res.end(JSON.stringify({
439
- youAreTalkingTo: 'openzoo proxy',
440
- yourEndpoint: self,
441
- reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
442
- publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
443
- servedRequests,
444
- spendUsd: sessionSpent,
445
- paidCalls,
446
- mcp: `${self.replace(/\/v1$/, '')}/mcp`,
447
- upstream: config.apiBase,
448
- payment: 'x402 per request from the operator\'s local burner wallet — no API key, no account',
449
- auth: viaTunnel
450
- ? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
451
- : 'localhost is keyless',
452
- context: {
453
- yourAttentionWindow: 'unchanged — openzoo does not enlarge it',
454
- boundCeiling: '~128M tokens client-usable via bind + retrieval',
455
- singleRequestLimit: '~8MB per request; larger corpora bind in parts',
456
- retrieval: 'lossy top-k retrieval, NOT lossless compression',
457
- },
458
- tools: ['zoo_bind', 'zoo_ask', 'zoo_status', 'zoo_models', 'zoo_wallet', 'zoo_contexts'],
459
- docs: 'https://openzoo.fun',
460
- }, null, 2));
461
- return;
462
- }
463
- }
464
-
465
- if (viaTunnel) {
466
- const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
467
- // TRUST ON FIRST USE, so a stale key still works without weakening the
468
- // tunnel to "any key forever".
469
- //
470
- // Tunnel tokens are minted per session, so a client holding a key from an
471
- // earlier run silently failed — and the only fixes were re-pasting by
472
- // hand or writing into the editor's OS-encrypted credential store, which
473
- // would mean prompting for keychain access to install a value the user
474
- // never chose. Instead: the printed token always works, and the FIRST
475
- // other key to present itself claims the tunnel for the rest of the
476
- // session. Your editor (which reaches the URL first, from this machine)
477
- // adopts it; anyone who finds the URL afterwards is refused because the
478
- // slot is taken. The URL is unguessable and ephemeral, the spend ceiling
479
- // still applies, and OPENZOO_TUNNEL_STRICT=1 restores exact-match only.
480
- const strict = process.env.OPENZOO_TUNNEL_STRICT === '1';
481
- let authed = got === tunnelGate.token;
482
- if (!authed && !strict && got.length >= 8) {
483
- if (!tunnelGate.adopted) {
484
- tunnelGate.adopted = got;
485
- log(`public url: adopted the caller's key (first-use) — later keys must match it`);
486
- authed = true;
487
- } else {
488
- authed = got === tunnelGate.adopted;
489
- }
490
- }
491
- const p = (req.url || '').split('?')[0];
492
- // DISCOVERY IS FREE. An agent probing this URL cold should be able to
493
- // work out what it is and what to ask its operator for — a bare 401 on
494
- // every path just sends it spelunking through the operator's machine.
495
- // Reads that cost nothing and leak nothing (the catalog is public on
496
- // the zoo anyway) go through without the key; money paths stay gated.
497
- if (!authed) {
498
- if (req.method === 'GET' && (p === '/' || p === '/v1' || p === '/v1/info')) {
499
- res.writeHead(200, { 'content-type': 'application/json' });
500
- res.end(JSON.stringify({
501
- name: 'openzoo proxy (public tunnel)',
502
- what: 'OpenAI-compatible pay-per-call proxy — the operator\'s wallet pays per request via x402; no account, no signup.',
503
- authentication: 'All POST endpoints require `Authorization: Bearer <api key>`. The key is printed in the operator\'s terminal at proxy startup — ask them for it. It is NOT guessable.',
504
- endpoints: {
505
- 'GET /v1/models': 'model catalog with pricing + context_length — no key needed',
506
- 'GET /v1/models/{id}': 'single-model probe — no key needed',
507
- 'POST /v1/chat/completions': 'chat (streaming supported, any model id — unknown ids are matched to the nearest served model) — key required',
508
- },
509
- docs: 'https://openzoo.fun · https://www.npmjs.com/package/openzoo',
510
- }, null, 2));
511
- return;
512
- }
513
- // Binding COSTS NOTHING — no 402, no wallet, no settlement. Gating it
514
- // behind the key only stopped agents from using the one endpoint that
515
- // makes a big corpus workable: observed in the wild, an agent wrote a
516
- // correct multi-part bind script, got 401 on the final append, and
517
- // fell back to stuffing the corpus inline. The money paths below stay
518
- // gated; the worst a stranger can do here is spend our sidecar's disk.
519
- const freeRead = req.method === 'GET' && (p === '/v1/models' || p.startsWith('/v1/models/'));
520
- const freeBind = req.method === 'POST' && p === '/v1/hrr/bind';
521
- if (freeBind) log(`public url: unauthenticated bind allowed (free endpoint) from ${req.socket.remoteAddress}`);
522
- if (!freeRead && !freeBind) {
523
- log(`public url: 401 ${req.method} ${req.url}`);
524
- jsonErr(res, 401, 'unauthorized: this openzoo public URL requires the api key printed at the operator\'s proxy startup — GET / for discovery, GET /v1/models is open');
525
- return;
526
- }
527
- }
528
- if (authed && tunnelSpent >= tunnelGate.sessionMaxUsd) {
529
- jsonErr(res, 402, `openzoo public-URL session cap reached ($${tunnelGate.sessionMaxUsd}) — restart the proxy or raise OPENZOO_TUNNEL_MAX_USD`);
530
- return;
531
- }
532
- }
533
- let bodyBuf;
534
- try {
535
- bodyBuf = await readBody(req);
536
- } catch {
537
- jsonErr(res, 400, 'bad request body');
538
- return;
539
- }
540
-
541
- // ANTHROPIC MESSAGES SHAPE. A harness pointed here via ANTHROPIC_BASE_URL
542
- // (Claude Code, the Anthropic SDKs) speaks POST /v1/messages, not chat
543
- // completions — this is how such a harness routes its inference through
544
- // x402 without any DNS or TLS trickery. Translate the body to OpenAI shape
545
- // and rewrite the path so EVERYTHING downstream (model rewrite, brief,
546
- // corpus cache, payment, replay, streaming) runs unchanged; translate the
547
- // answer back on the way out. See lib/anthropic.js.
548
- let anthropicMode = false;
549
- let anthropicModel = null;
550
- const rawPath = (req.url || '').split('?')[0];
551
- if (req.method === 'POST' && (rawPath === '/v1/messages' || rawPath === '/messages')) {
552
- try {
553
- const inbound = JSON.parse(bodyBuf.toString('utf8'));
554
- anthropicModel = inbound.model;
555
- bodyBuf = Buffer.from(JSON.stringify(anthropicToOpenAI(inbound)));
556
- anthropicMode = true;
557
- req.url = '/v1/chat/completions';
558
- url = `${config.apiBase}${req.url}`;
559
- } catch {
560
- jsonErr(res, 400, 'invalid anthropic messages body');
561
- return;
562
- }
563
- }
564
-
565
- // Harness model ids ("gpt-5.6-sol", "claude-…") are rewritten onto the
566
- // NEAREST zoo model BEFORE anything else sees the body — any POST that
567
- // carries a model field, not just chat/completions, so /completions,
568
- // /responses and future shapes all work. Never silent.
569
- let wantsStream = false;
570
- if ((req.url || '').includes('/chat/completions') && req.method === 'POST') {
571
- servedRequests += 1;
572
- say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
573
- }
574
- if (rewritablePath(req.method, req.url)) {
575
- const rw = await maybeRewriteModel(bodyBuf);
576
- if (rw) {
577
- log(`model "${rw.from}" is not on the zoo — nearest match ${rw.to} (OPENZOO_DEFAULT_MODEL overrides)`);
578
- bodyBuf = rw.body;
579
- }
580
- try {
581
- const parsed = JSON.parse(bodyBuf.toString('utf8'));
582
- wantsStream = parsed?.stream === true;
583
- // Tell the agent what it is actually connected to — in band, where it
584
- // will read it, instead of leaving it to guess (and to chunk corpora
585
- // it could bind whole). See lib/brief.js.
586
- if ((req.url || '').includes('/chat/completions')) {
587
- // Tell it the URL it actually reached us on — the public tunnel for
588
- // a remote harness, localhost for a local one. An agent that has to
589
- // guess its own endpoint guesses a website.
590
- const selfUrl = viaTunnel && tunnelGate?.publicUrl
591
- ? `${tunnelGate.publicUrl}/v1`
592
- : `http://localhost:${config.port}/v1`;
593
- const briefed = injectBrief(parsed, selfUrl);
594
- if (briefed) bodyBuf = Buffer.from(JSON.stringify(briefed));
595
- }
596
- } catch { /* not JSON */ }
597
- }
598
-
599
- // Retry of a body we answered seconds ago? Serve the cached completion —
600
- // never pay twice for a harness's reconnect loop.
601
- const isChat = req.method === 'POST' && (req.url || '').includes('/chat/completions');
602
- const rKey = isChat ? replayKey(bodyBuf, req.headers) : null;
603
- if (rKey) {
604
- const hit = replayGet(rKey);
605
- if (hit) {
606
- log('identical request within 30s — served the cached completion, NOT re-paid');
607
- if (wantsStream) { serveAsSse(res, hit.data, null); return; }
608
- const h = { 'content-type': 'application/json' };
609
- if (hit.settle) h['x-payment-response'] = hit.settle;
610
- res.writeHead(200, h);
611
- res.end(JSON.stringify(hit.data));
612
- return;
613
- }
614
- }
615
- const init = { method: req.method, headers: upstreamHeaders(req) };
616
- if (req.method !== 'GET' && req.method !== 'HEAD') init.body = bodyBuf;
617
-
618
- // Harnesses validate their configured model BEFORE ever POSTing — some
619
- // list /v1/models, some probe /v1/models/<id>. Both must succeed for the
620
- // ids we know how to rewrite, or the harness refuses upfront and the
621
- // rewrite never gets its chance.
622
- const path = (req.url || '').split('?')[0];
623
- if (req.method === 'GET' && path === '/v1/models') {
624
- try {
625
- const { response } = await client.fetch(url, init);
626
- const payload = await response.json();
627
- res.writeHead(response.status, { 'content-type': 'application/json' });
628
- res.end(JSON.stringify(response.ok ? augmentModelList(payload) : payload));
629
- return;
630
- } catch { /* fall through to the plain relay below */ }
631
- }
632
- const probe = req.method === 'GET' && /^\/v1\/models\/(.+)$/.exec(path);
633
- if (probe && ALIAS_IDS.includes(decodeURIComponent(probe[1]))) {
634
- res.writeHead(200, { 'content-type': 'application/json' });
635
- res.end(JSON.stringify({ id: decodeURIComponent(probe[1]), object: 'model', owned_by: 'openzoo-alias' }));
636
- return;
637
- }
638
-
639
- try {
640
- let cached = null;
641
- try {
642
- cached = await maybeCacheCorpus(req, bodyBuf, log);
643
- } catch (err) {
644
- log(`context cache skipped for this call: ${err.message}`);
645
- }
646
- const send = (buf, ctxId) => client.fetch(url, {
647
- ...init,
648
- body: buf,
649
- headers: ctxId ? { ...init.headers, 'x-hrr-context': ctxId } : init.headers,
650
- });
651
- let result;
652
- if (cached) {
653
- result = await send(cached.body, cached.contextId);
654
- // Sidecar wiped between runs: the gateway 404s BEFORE the 402 (nothing
655
- // paid). Never fail on a stale manifest — re-bind once and retry.
656
- if (result.response.status === 404) {
657
- const text = await result.response.text();
658
- if (/context_not_found/.test(text)) {
659
- log('bound context is gone on the zoo — re-binding once...');
660
- forgetContext(config.apiBase, cached.hash);
661
- const rebound = await bindCorpus(cached.corpus, { force: true });
662
- result = await send(cached.body, rebound.contextId);
663
- } else {
664
- res.writeHead(404, { 'content-type': 'application/json' });
665
- res.end(text);
666
- return;
667
- }
668
- }
669
- } else {
670
- result = await client.fetch(url, init);
671
- }
672
- const { response, paid, receipt } = result;
673
- if (paid && receipt) {
674
- if (receipt.ok && typeof receipt.billedUsd === 'number') {
675
- sessionSpent += receipt.billedUsd;
676
- // cogs: no per-call field for it, but MARKUP is a known constant
677
- // (3x — confirmed against the gateway's own margin math), and
678
- // billedUsd = cogs * markup on a straight-markup call. Close enough
679
- // on a counterfactual (leCore-discounted) call too since markup is
680
- // still the ceiling those get capped against.
681
- // Prefer the gateway's own cogsUsd. Deriving it as billedUsd/MARKUP
682
- // is only correct on a straight-markup call: under counterfactual
683
- // pricing billedUsd is min(direct×discount, markupUsd), so the
684
- // division understates cost and overstates margin.
685
- sessionCogs += typeof receipt.cogsUsd === 'number'
686
- ? receipt.cogsUsd
687
- : receipt.billedUsd / MARKUP;
688
- // direct = what answering this WITHOUT the zoo would have cost. On an
689
- // attach call that is the whole bound corpus, which is why it can be
690
- // orders of magnitude above what was billed. directUsd is exact and
691
- // always present; savesVsDirect is the same number as a ratio.
692
- sessionDirect += typeof receipt.directUsd === 'number'
693
- ? receipt.directUsd
694
- : typeof receipt.savesVsDirect === 'number'
695
- ? receipt.savesVsDirect * receipt.billedUsd
696
- : receipt.billedUsd;
697
- // The public-URL ceiling meters only public-origin spend — your own
698
- // local calls never eat into it.
699
- if (viaTunnel) tunnelSpent += receipt.billedUsd;
700
- }
701
- const line = receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`;
702
- // Wherever a running total is the thing to watch, it rides the receipt.
703
- if (requireToken) say(`${line} · session $${sessionSpent.toFixed(6)}`);
704
- else if (viaTunnel) say(`${line} · public-url session $${tunnelSpent.toFixed(6)}`);
705
- else say(line);
706
- // ALWAYS-ON SPEND, TUI-SAFE. When a harness owns the terminal (silent),
707
- // the receipt lines go to a file (they corrupt a TUI). But the running
708
- // total should still be visible — so write it to the terminal TITLE via
709
- // an OSC escape, which updates the window/tab title without touching the
710
- // TUI's content. `openzoo ● $0.0042 · 12 calls` in the title bar, live.
711
- if (receipt.ok && typeof receipt.billedUsd === 'number') { paidCalls += 1; }
712
- if (sayFile) {
713
- try { process.stderr.write(`]0;openzoo ● $${sessionSpent.toFixed(4)} · ${paidCalls} call${paidCalls === 1 ? '' : 's'}`); } catch { /* no tty */ }
714
- }
715
- scheduleRefresh(4000); // settlement lands on-chain in a few seconds
716
- }
717
- // Chat completions come back as one JSON object (settle-before-serve).
718
- // Cache it against retries, and if the harness asked to stream, honour
719
- // that contract ourselves. An upstream that someday truly streams (SSE
720
- // content-type) passes straight through the relay below, untouched.
721
- const upCt = response.headers.get('content-type') || '';
722
- if (isChat && response.ok && upCt.includes('application/json')) {
723
- let data = null;
724
- try { data = await response.clone().json(); } catch { /* not JSON after all */ }
725
- // PREPAID CALLS STILL COST MONEY. The block above only meters calls
726
- // where THIS proxy answered a 402 and paid. When prepaid credit covers
727
- // the quote the gateway serves 200 on the FIRST request, so there is
728
- // no 402, no payment and no receipt — and the session read $0.05 / 2
729
- // calls while the credit balance had actually fallen $3.017 -> $1.395
730
- // over a 30-question run. The receipt still rides the response body,
731
- // so meter it from there.
732
- if (!paid && data?.x402 && typeof data.x402.billedUsd === 'number') {
733
- const x = data.x402;
734
- sessionSpent += x.billedUsd;
735
- sessionCogs += typeof x.cogsUsd === 'number' ? x.cogsUsd : x.billedUsd / MARKUP;
736
- sessionDirect += typeof x.directUsd === 'number' ? x.directUsd : x.billedUsd;
737
- paidCalls += 1;
738
- if (viaTunnel) tunnelSpent += x.billedUsd;
739
- say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
740
- }
741
- if (data?.object === 'chat.completion') {
742
- if (rKey) replayPut(rKey, data, response.headers.get('x-payment-response'));
743
- // Anthropic-shaped caller gets an Anthropic-shaped answer, streamed
744
- // or not, so Claude Code and the SDKs parse it natively.
745
- if (anthropicMode) {
746
- const msg = openAIToAnthropic(data, anthropicModel);
747
- if (wantsStream) { writeAnthropicSse(res, msg, response); return; }
748
- const h = { 'content-type': 'application/json' };
749
- const settleHdr = response.headers.get('x-payment-response');
750
- if (settleHdr) h['x-payment-response'] = settleHdr;
751
- res.writeHead(200, h);
752
- res.end(JSON.stringify(msg));
753
- return;
754
- }
755
- if (wantsStream) { serveAsSse(res, data, response); return; }
756
- const h = { 'content-type': 'application/json' };
757
- const settleHdr = response.headers.get('x-payment-response');
758
- if (settleHdr) h['x-payment-response'] = settleHdr;
759
- res.writeHead(200, h);
760
- res.end(JSON.stringify(data));
761
- return;
762
- }
763
- }
764
- await relay(res, response);
765
- } catch (err) {
766
- if (err instanceof QuoteTooHighError) {
767
- log(err.message);
768
- jsonErr(res, 402, err.message, { quote: err.quote });
769
- } else if (err instanceof UnderfundedError) {
770
- log(err.message);
771
- jsonErr(res, 402, err.message);
772
- } else {
773
- // "fetch failed" alone is undiagnosable — undici hides the real
774
- // network error in `cause`. Surface it (and log the stack) or every
775
- // transport hiccup looks identical to a payment bug.
776
- const cause = err.cause?.message || err.cause?.code || err.cause;
777
- const detail = cause ? `${err.message} (${cause})` : err.message;
778
- log(`proxy error: ${detail}`);
779
- if (process.env.OPENZOO_DEBUG) console.error(err.stack);
780
- jsonErr(res, 502, `openzoo proxy error: ${detail}`);
781
- }
782
- }
783
- });
784
-
785
- // BIND HOST. Default 127.0.0.1 — the keyless localhost path must never be
786
- // world-reachable on an ordinary machine. A RunPod box is the exception: its
787
- // HTTP proxy reaches the container over the pod network, so a localhost bind
788
- // shows "Initializing…" forever (MEASURED: podagent's 0.0.0.0 ports went
789
- // Ready, the 127.0.0.1 proxy never did). The box sets OPENZOO_BIND=0.0.0.0
790
- // AND a tunnel token, so the RunPod-fronted port stays gated exactly like the
791
- // public tunnel path.
792
- const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
793
- await new Promise((resolve, reject) => {
794
- server.on('error', reject);
795
- server.listen(config.port, bindHost, resolve);
796
- });
797
-
798
- // AUTO-PREPAY. Paying on-chain per call is where the latency lives: the
799
- // gateway answers its 402 challenge in ~0.12s while a full settled call
800
- // MEASURED 9-37s end to end. Credit is applied automatically server-side
801
- // whenever a balance covers the quote, so buying it once makes every later
802
- // call skip verify+settle entirely.
803
- //
804
- // Runs in the background — never block the listener on a payment — and only
805
- // when this wallet actually has funds, so a fresh/empty wallet is untouched.
806
- // Opt out with OPENZOO_NO_AUTOTOPUP=1; size it with OPENZOO_AUTOTOPUP_USD.
807
- if (!process.env.OPENZOO_NO_AUTOTOPUP) {
808
- // Keep credit topped up, forever, from whatever the wallet holds.
809
- //
810
- // The first version ran ONCE at startup and bought a fixed $5, so funding
811
- // the wallet later did nothing at all — the user sent TOKEN and kept
812
- // paying on-chain per call. This checks on an interval and spends what the
813
- // wallet can actually cover, priced by the gateway's own live quote (so
814
- // TOKEN is valued exactly as it settles).
815
- const FLOOR = Number(process.env.OPENZOO_AUTOTOPUP_FLOOR || 2);
816
- const EVERY = Number(process.env.OPENZOO_AUTOTOPUP_EVERY_MS || 60_000);
817
- let topping = false;
818
- const tick = async () => {
819
- if (topping) return;
820
- topping = true;
821
- try {
822
- const { creditBalance, topUp, affordableUsd } = await import('./info.js');
823
- const have = await creditBalance();
824
- if (have >= FLOOR) return;
825
- const can = await affordableUsd();
826
- if (can < 1) return; // nothing to convert; stay quiet
827
- say(`credit $${have.toFixed(4)} below $${FLOOR} — wallet covers ~$${can.toFixed(2)}, topping up`);
828
- await topUp('all');
829
- } catch (e) {
830
- say(`auto top-up skipped: ${String(e.message || e).slice(0, 120)}`);
831
- } finally {
832
- topping = false;
833
- }
834
- };
835
- tick();
836
- const timer = setInterval(tick, EVERY);
837
- timer.unref?.(); // never hold the process open just for this
838
- }
839
-
840
- if (!silent) {
841
- // VERSION IN THE BANNER, deliberately. `npx openzoo` can serve a STALE
842
- // cached copy — npx reuses a cache entry that matches the bare spec, so a
843
- // user running the newest published version still gets old behaviour and
844
- // no clue why (observed: a missing tunnel and a missing token row, both
845
- // "fixed" releases ago). Printing the version makes that one glance.
846
- const { version } = JSON.parse(
847
- readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
848
- );
849
- console.log(`openzoo v${version} -> ${config.apiBase}`);
850
- console.log(`listening on http://localhost:${config.port}/v1`);
851
- // LAND THEM IN THE APP. `npx openzoo` in a human terminal opens the chat
852
- // GUI — a stranger's first 8 seconds should be a working chat, not a URL
853
- // to notice. Never in CI/agents (no TTY), never twice, opt out with
854
- // OPENZOO_NO_OPEN=1.
855
- if (process.stdout.isTTY && !process.env.OPENZOO_NO_OPEN) {
856
- const opener = process.platform === 'darwin' ? 'open'
857
- : process.platform === 'win32' ? 'start' : 'xdg-open';
858
- import('node:child_process').then(({ exec }) =>
859
- exec(`${opener} http://localhost:${config.port}/`, () => {}));
860
- }
861
- console.log('');
862
- if (client.walletCreated) console.log(`new burner wallet created at ${client.walletPath} (chmod 600)`);
863
- console.log(`wallet (fund me) · solana: ${client.address}`);
864
- if (client.evmAddress) console.log(`wallet (fund me) · evm (base / robinhood): ${client.evmAddress}`);
865
- try {
866
- lastSnap = await snapshotBalances(client);
867
- console.log(`balance: ${balanceLine(lastSnap) || '(no RPC reachable — advisory only)'}`);
868
- if (!lastSnap.some((b) => b.ui > 0)) {
869
- console.log(`fund it: ${fundingLine('the address above')} — a few cents goes a long way.`);
870
- }
871
- } catch { /* RPC hiccup: balance is advisory */ }
872
- // LIVE REFRESH: the startup line goes stale the moment a call settles or
873
- // the user funds mid-session. Poll on an interval (and shortly after each
874
- // paid call), print ONLY on change, and call out arrivals explicitly so
875
- // "did my top-up land?" answers itself in the running log.
876
- refreshBalances = async () => {
877
- try {
878
- const snap = await snapshotBalances(client);
879
- if (!snap.length) return;
880
- const prev = new Map((lastSnap || []).map((b) => [`${b.chain}:${b.symbol}`, b.ui]));
881
- const changed = snap.some((b) => Math.abs((prev.get(`${b.chain}:${b.symbol}`) ?? 0) - b.ui) > 1e-9)
882
- || snap.length !== (lastSnap || []).length;
883
- if (!changed) return;
884
- if (lastSnap) {
885
- for (const b of snap) {
886
- const gain = b.ui - (prev.get(`${b.chain}:${b.symbol}`) ?? 0);
887
- if (gain > 1e-9) console.log(`funding arrived: +${gain.toFixed(6)} ${b.symbol}${b.chain !== 'solana' ? ` (${b.chain})` : ''}`);
888
- }
889
- }
890
- lastSnap = snap;
891
- console.log(`balance: ${balanceLine(snap)}`);
892
- } catch { /* advisory — never noisy on RPC trouble */ }
893
- };
894
- const pollSecs = Number(process.env.OPENZOO_BALANCE_POLL_SECS ?? 45);
895
- if (pollSecs > 0) {
896
- const timer = setInterval(refreshBalances, pollSecs * 1000);
897
- timer.unref?.();
898
- }
899
- // Which rails the zoo will actually settle right now, straight off a live
900
- // 402 — so nobody funds a lane the resource is not currently offering.
901
- try {
902
- const rails = await liveRails();
903
- if (rails) {
904
- console.log(`rails live now: ${rails.live.join(' · ')}`);
905
- // Funding advice is derived from those rails, never hardcoded — the
906
- // zoo can add a chain without this package shipping again.
907
- const hint = railFundingHint(rails.live);
908
- if (hint) console.log(`fund with: ${hint}`);
909
- // The exact contracts those symbols mean — every chain has impersonator
910
- // mints, so a symbol without its CA is an invitation to fund the wrong one.
911
- for (const row of railFundingAddresses(rails.live)) {
912
- row.assets.forEach((a, i) => {
913
- const label = i === 0 ? row.label : '';
914
- console.log(` ${label.padEnd(16)} ${a.symbol.padEnd(11)} ${a.address}${a.note ? ` (${a.note})` : ''}`);
915
- });
916
- }
917
- const unfundable = unfundableRails(rails.live);
918
- if (unfundable.length) {
919
- const fundable = rails.live.filter((r) => RAIL_FUNDING[r]?.assets.length).map((r) => RAIL_FUNDING[r].label);
920
- const instead = fundable.length ? `pay from ${fundable.join(' or ')} instead` : 'no fundable rail is offered right now';
921
- console.log(`note: ${unfundable.join(' / ')} is offered by the zoo but not fundable from a plain balance here — ${instead}.`);
922
- }
923
- if (rails.dark.length) {
924
- const rh = rails.dark.includes('robinhood') ? ' — robinhood also needs OPENZOO_ENABLE_RH=1' : '';
925
- console.log(`rails implemented but not offered by the zoo right now: ${rails.dark.join(' · ')}${rh}`);
926
- }
927
- }
928
- } catch { /* quote probe is advisory */ }
929
- console.log('');
930
- console.log('point any OpenAI-compatible harness at:');
931
- console.log(` base_url = http://localhost:${config.port}/v1`);
932
- console.log(' api_key = sk-openzoo (any value works; the zoo takes payment, not keys)');
933
- }
934
-
935
- // AUTO-TUNNEL: `npx openzoo` must be end-to-end for cloud IDEs too — their
936
- // servers cannot dial localhost, so the default command also publishes a
937
- // quick-tunnel URL. It comes up in the background (never delays localhost),
938
- // failure degrades to local-only, and public traffic is gated above by
939
- // token + its own spend ceiling. OPENZOO_NO_TUNNEL=1 opts out.
940
- if (autoTunnel && process.env.OPENZOO_NO_TUNNEL !== '1') {
941
- (async () => {
942
- try {
943
- const { ensureCloudflared, startCloudflared, mintToken } = await import('./tunnel.js');
944
- const token = mintToken();
945
- const cap = process.env.OPENZOO_TUNNEL_MAX_USD ? Number(process.env.OPENZOO_TUNNEL_MAX_USD) : Infinity;
946
- const bin = await ensureCloudflared((m) => log(m));
947
- const { url, proc } = await startCloudflared(bin, config.port, log);
948
- tunnelGate = { token, sessionMaxUsd: cap, publicUrl: url };
949
- const bye = () => { try { proc.kill('SIGTERM'); } catch { /* already gone */ } };
950
- process.once('SIGINT', () => { bye(); process.exit(0); });
951
- process.once('SIGTERM', () => { bye(); process.exit(0); });
952
- process.once('exit', bye);
953
- log('');
954
- log('cloud IDE / remote harness? use the public URL (they cannot reach localhost):');
955
- log(` base_url = ${url}/v1`);
956
- log(` api_key = ${token}`);
957
- log(` (key REQUIRED on the public URL — it spends this wallet; ${Number.isFinite(cap) ? `capped at $${cap.toFixed(2)}/session` : 'NO session cap — OPENZOO_TUNNEL_MAX_USD adds one'},`);
958
- log(' OPENZOO_NO_TUNNEL=1 for localhost-only)');
959
- } catch (err) {
960
- // Record it: a caller polling for publicUrl (openzoo cursor) would
961
- // otherwise spin the full timeout on a tunnel that already died, with
962
- // silent:true swallowing this very message.
963
- tunnelError = err.message;
964
- log(`public URL unavailable (${err.message}) — localhost still works; OPENZOO_NO_TUNNEL=1 hides this line`);
965
- }
966
- })();
967
- }
968
- // Expose live tunnel details so a caller that starts the proxy in-process
969
- // (openzoo cursor/vscode) can surface the public URL + key instead of the
970
- // user hunting for them. Getters, because the tunnel resolves ASYNC after
971
- // this returns — a snapshot would always be null.
972
- return {
973
- server,
974
- client,
975
- spent: () => sessionSpent,
976
- get publicUrl() { return tunnelGate?.publicUrl ?? null; },
977
- get tunnelToken() { return tunnelGate?.token ?? null; },
978
- get tunnelError() { return tunnelError; },
979
- };
980
- }