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 +29 -0
- package/lib/info.js +70 -12
- package/lib/launch.js +20 -1
- package/lib/pay.js +60 -0
- package/lib/wrap.js +18 -4
- package/package.json +1 -1
- package/lib/proxy.js.bak +0 -980
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
|
-
|
|
38
|
-
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
console.log(` ${a.symbol.padEnd(11)}: ${ui}${
|
|
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)}
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
}
|