openzoo 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/bin/openzoo.js +7 -1
- package/lib/proxy.js +29 -3
- package/lib/tunnel.js +156 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,39 @@ npx openzoo contexts --forget all # drop everything
|
|
|
126
126
|
|
|
127
127
|
Opt out with `OPENZOO_NO_CONTEXT_CACHE=1` (always ship the full body); tune the threshold with `OPENZOO_CONTEXT_MIN_CHARS` (default 16384 chars).
|
|
128
128
|
|
|
129
|
+
## Using openzoo from a cloud IDE (Cursor, Windsurf, hosted agents)
|
|
130
|
+
|
|
131
|
+
Some IDEs run their model calls **from their own servers**, not your machine. Point one of those at `http://localhost:8402` and you get, verbatim:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
Provider returned error: Access to private networks is forbidden
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
That is reachability, not payment — their cloud cannot dial your laptop. Give it a public URL instead:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npx openzoo tunnel
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
It installs `cloudflared` itself (one-time, cached in `~/.openzoo/bin`; a Cloudflare account is not needed), opens a quick tunnel to your local proxy, and prints:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
base_url = https://<random>.trycloudflare.com/v1
|
|
147
|
+
api_key = oz_<random> # in tunnel mode the key is REAL auth
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Tunnel mode is the one mode where the api key matters.** A public URL in front of a wallet is a public URL in front of your money, so:
|
|
151
|
+
|
|
152
|
+
- every request without `Authorization: Bearer <that key>` is refused **401** before anything is forwarded, quoted or paid;
|
|
153
|
+
- the per-call cap (`OPENZOO_MAX_USD_PER_CALL`, default $0.50) still applies;
|
|
154
|
+
- a session ceiling stops all spending at `OPENZOO_TUNNEL_MAX_USD` (default **$1.00**);
|
|
155
|
+
- every served request prints its receipt and the running session total;
|
|
156
|
+
- the URL dies when you Ctrl-C, and the session's total spend is printed on exit.
|
|
157
|
+
|
|
158
|
+
Pin the key with `OPENZOO_TUNNEL_TOKEN` if your IDE stores it. Keys never leave your machine either way — the tunnel forwards to the same local proxy, which signs with the same local wallet.
|
|
159
|
+
|
|
160
|
+
**MCP clients don't need any of this.** If your tool speaks MCP, use the hosted server at `https://mcp.openzoo.fun/mcp` — cloud-reachable, no local process, mint a wallet with `zoo_wallet`.
|
|
161
|
+
|
|
129
162
|
## The wallet model
|
|
130
163
|
|
|
131
164
|
- **Burner, local, yours.** A keypair in `~/.openzoo/wallet.json`, created on first run, chmod 600. Keys never leave your machine — the zoo only ever sees signed transfers.
|
package/bin/openzoo.js
CHANGED
|
@@ -6,6 +6,8 @@ const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
|
|
|
6
6
|
usage:
|
|
7
7
|
npx openzoo start the proxy on http://localhost:8402/v1
|
|
8
8
|
npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_models, zoo_wallet)
|
|
9
|
+
npx openzoo tunnel public HTTPS url for cloud IDEs that cannot reach localhost
|
|
10
|
+
(installs cloudflared itself; mints a required api key)
|
|
9
11
|
npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
|
|
10
12
|
(run it twice — the second run reuses the bound corpus and is near-free)
|
|
11
13
|
npx openzoo contexts list corpora bound to the zoo (never re-uploaded)
|
|
@@ -24,7 +26,8 @@ env:
|
|
|
24
26
|
OPENZOO_MAX_USD_PER_CALL (0.5) OPENZOO_DEMO_MAX_USD (0.01)
|
|
25
27
|
OPENZOO_CONTEXT_MIN_CHARS (16384 — bodies bigger than this bind once + reuse)
|
|
26
28
|
OPENZOO_NO_CONTEXT_CACHE (0 — set 1 to always ship the full body)
|
|
27
|
-
OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)
|
|
29
|
+
OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)
|
|
30
|
+
OPENZOO_TUNNEL_MAX_USD (1.00 — tunnel session ceiling) OPENZOO_TUNNEL_TOKEN (pin the api key)`;
|
|
28
31
|
|
|
29
32
|
async function main() {
|
|
30
33
|
switch (cmd) {
|
|
@@ -35,6 +38,9 @@ async function main() {
|
|
|
35
38
|
case 'mcp':
|
|
36
39
|
await (await import('../lib/mcp.js')).startMcp();
|
|
37
40
|
break;
|
|
41
|
+
case 'tunnel':
|
|
42
|
+
await (await import('../lib/tunnel.js')).runTunnel();
|
|
43
|
+
break;
|
|
38
44
|
case 'demo':
|
|
39
45
|
await (await import('../lib/demo.js')).runDemo();
|
|
40
46
|
break;
|
package/lib/proxy.js
CHANGED
|
@@ -104,12 +104,32 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* `requireToken` / `sessionMaxUsd` are TUNNEL MODE (see lib/tunnel.js): once the
|
|
109
|
+
* proxy is reachable from the internet, the api key stops being decorative and
|
|
110
|
+
* becomes the only thing between a stranger and your wallet. Both are off by
|
|
111
|
+
* default, so localhost behaviour is unchanged.
|
|
112
|
+
*/
|
|
113
|
+
export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null } = {}) {
|
|
108
114
|
const client = new PayClient();
|
|
109
115
|
const log = silent ? () => {} : (...a) => console.log(...a);
|
|
116
|
+
let sessionSpent = 0;
|
|
110
117
|
|
|
111
118
|
const server = http.createServer(async (req, res) => {
|
|
112
119
|
const url = `${config.apiBase}${req.url}`;
|
|
120
|
+
// Auth first: refuse before reading a body, forwarding, quoting or paying.
|
|
121
|
+
if (requireToken) {
|
|
122
|
+
const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
|
123
|
+
if (got !== requireToken) {
|
|
124
|
+
log(`tunnel: 401 ${req.method} ${req.url} from ${req.socket.remoteAddress}`);
|
|
125
|
+
jsonErr(res, 401, 'unauthorized: this openzoo tunnel requires the api key printed at startup');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (sessionMaxUsd != null && sessionSpent >= sessionMaxUsd) {
|
|
129
|
+
jsonErr(res, 402, `openzoo tunnel session cap reached ($${sessionMaxUsd}) — restart the tunnel or raise OPENZOO_TUNNEL_MAX_USD`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
113
133
|
let bodyBuf;
|
|
114
134
|
try {
|
|
115
135
|
bodyBuf = await readBody(req);
|
|
@@ -154,7 +174,13 @@ export async function startProxy({ silent = false } = {}) {
|
|
|
154
174
|
result = await client.fetch(url, init);
|
|
155
175
|
}
|
|
156
176
|
const { response, paid, receipt } = result;
|
|
157
|
-
if (paid && receipt)
|
|
177
|
+
if (paid && receipt) {
|
|
178
|
+
if (receipt.ok && typeof receipt.billedUsd === 'number') sessionSpent += receipt.billedUsd;
|
|
179
|
+
const line = receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`;
|
|
180
|
+
// In tunnel mode the running total is the thing you actually want to
|
|
181
|
+
// watch, so it rides on every receipt.
|
|
182
|
+
log(requireToken ? `${line} · session $${sessionSpent.toFixed(6)}` : line);
|
|
183
|
+
}
|
|
158
184
|
await relay(res, response);
|
|
159
185
|
} catch (err) {
|
|
160
186
|
if (err instanceof QuoteTooHighError) {
|
|
@@ -218,5 +244,5 @@ export async function startProxy({ silent = false } = {}) {
|
|
|
218
244
|
console.log(` base_url = http://localhost:${config.port}/v1`);
|
|
219
245
|
console.log(' api_key = sk-openzoo (any value works; the zoo takes payment, not keys)');
|
|
220
246
|
}
|
|
221
|
-
return { server, client };
|
|
247
|
+
return { server, client, spent: () => sessionSpent };
|
|
222
248
|
}
|
package/lib/tunnel.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx openzoo tunnel` — make the local paying proxy reachable from a CLOUD-run
|
|
3
|
+
* harness.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS: IDEs that execute model calls from their own servers cannot
|
|
6
|
+
* dial your laptop. Pointing one at http://localhost:8402 returns, verbatim:
|
|
7
|
+
* "Provider returned error: Access to private networks is forbidden"
|
|
8
|
+
* The payment path is fine — the harness just can't reach it. A tunnel gives
|
|
9
|
+
* that harness a public HTTPS URL while your keys stay on this machine.
|
|
10
|
+
*
|
|
11
|
+
* SELF-PROVISIONING: we do not ask you to install anything. If `cloudflared`
|
|
12
|
+
* isn't on PATH we fetch the single static binary for this platform into
|
|
13
|
+
* ~/.openzoo/bin and cache it. Quick tunnels need no Cloudflare account.
|
|
14
|
+
*
|
|
15
|
+
* SECURITY IS THE DESIGN. A public URL in front of a wallet is a public URL in
|
|
16
|
+
* front of your money, so tunnel mode is the ONE mode where the api key is real:
|
|
17
|
+
* - a random bearer token is minted per session and REQUIRED (401 otherwise),
|
|
18
|
+
* checked before anything is forwarded, quoted, or paid;
|
|
19
|
+
* - the per-call cap (OPENZOO_MAX_USD_PER_CALL) still applies, and a session
|
|
20
|
+
* ceiling (OPENZOO_TUNNEL_MAX_USD, default $1) stops paying when reached;
|
|
21
|
+
* - every tunnel-served request is logged with its running spend.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { createWriteStream } from 'node:fs';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import crypto from 'node:crypto';
|
|
29
|
+
import { pipeline } from 'node:stream/promises';
|
|
30
|
+
import { Readable } from 'node:stream';
|
|
31
|
+
|
|
32
|
+
const BIN_DIR = path.join(os.homedir(), '.openzoo', 'bin');
|
|
33
|
+
|
|
34
|
+
/** cloudflared publishes one static binary per platform; pick ours. */
|
|
35
|
+
function cloudflaredAsset() {
|
|
36
|
+
const p = process.platform;
|
|
37
|
+
const a = process.arch;
|
|
38
|
+
if (p === 'darwin') return { name: 'cloudflared-darwin-amd64.tgz', tgz: true };
|
|
39
|
+
if (p === 'linux') {
|
|
40
|
+
const arch = a === 'arm64' ? 'arm64' : a === 'arm' ? 'arm' : 'amd64';
|
|
41
|
+
return { name: `cloudflared-linux-${arch}`, tgz: false };
|
|
42
|
+
}
|
|
43
|
+
if (p === 'win32') return { name: 'cloudflared-windows-amd64.exe', tgz: false };
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function onPath(bin) {
|
|
48
|
+
const dirs = (process.env.PATH || '').split(path.delimiter);
|
|
49
|
+
for (const d of dirs) {
|
|
50
|
+
const f = path.join(d, bin);
|
|
51
|
+
try { fs.accessSync(f, fs.constants.X_OK); return f; } catch { /* keep looking */ }
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return a usable cloudflared path, downloading it if needed. Order: PATH, our
|
|
58
|
+
* cache, then the official GitHub release. macOS ships a .tgz; the others are
|
|
59
|
+
* bare binaries.
|
|
60
|
+
*/
|
|
61
|
+
export async function ensureCloudflared(log = console.log) {
|
|
62
|
+
const found = onPath(process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
63
|
+
if (found) return found;
|
|
64
|
+
|
|
65
|
+
const cached = path.join(BIN_DIR, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
66
|
+
try { fs.accessSync(cached, fs.constants.X_OK); return cached; } catch { /* fetch it */ }
|
|
67
|
+
|
|
68
|
+
const asset = cloudflaredAsset();
|
|
69
|
+
if (!asset) throw new Error(`no cloudflared build for ${process.platform}/${process.arch} — install it manually and re-run`);
|
|
70
|
+
|
|
71
|
+
fs.mkdirSync(BIN_DIR, { recursive: true, mode: 0o700 });
|
|
72
|
+
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.name}`;
|
|
73
|
+
log(`fetching cloudflared for ${process.platform}/${process.arch} (one-time, ~35MB)...`);
|
|
74
|
+
const r = await fetch(url, { redirect: 'follow' });
|
|
75
|
+
if (!r.ok) throw new Error(`cloudflared download failed: HTTP ${r.status}`);
|
|
76
|
+
|
|
77
|
+
if (asset.tgz) {
|
|
78
|
+
const tgz = path.join(BIN_DIR, 'cloudflared.tgz');
|
|
79
|
+
await pipeline(Readable.fromWeb(r.body), createWriteStream(tgz));
|
|
80
|
+
await new Promise((resolve, reject) => {
|
|
81
|
+
const t = spawn('tar', ['xzf', tgz, '-C', BIN_DIR], { stdio: 'ignore' });
|
|
82
|
+
t.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(`tar exited ${c}`))));
|
|
83
|
+
t.on('error', reject);
|
|
84
|
+
});
|
|
85
|
+
fs.rmSync(tgz, { force: true });
|
|
86
|
+
} else {
|
|
87
|
+
await pipeline(Readable.fromWeb(r.body), createWriteStream(cached));
|
|
88
|
+
}
|
|
89
|
+
fs.chmodSync(cached, 0o755);
|
|
90
|
+
log(`cloudflared cached at ${cached}`);
|
|
91
|
+
return cached;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Start a quick tunnel to localhost:<port>; resolve with its public URL. */
|
|
95
|
+
export function startCloudflared(bin, port, log) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], {
|
|
98
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
|
+
});
|
|
100
|
+
let settled = false;
|
|
101
|
+
const scan = (buf) => {
|
|
102
|
+
const m = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i.exec(buf.toString());
|
|
103
|
+
if (m && !settled) { settled = true; resolve({ url: m[0], proc }); }
|
|
104
|
+
};
|
|
105
|
+
proc.stdout.on('data', scan);
|
|
106
|
+
proc.stderr.on('data', scan); // cloudflared prints the URL on stderr
|
|
107
|
+
proc.on('error', reject);
|
|
108
|
+
proc.on('exit', (c) => { if (!settled) reject(new Error(`cloudflared exited ${c} before printing a URL`)); });
|
|
109
|
+
setTimeout(() => { if (!settled) reject(new Error('cloudflared did not produce a URL in 60s')); }, 60000);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function mintToken() {
|
|
114
|
+
return process.env.OPENZOO_TUNNEL_TOKEN || `oz_${crypto.randomBytes(24).toString('base64url')}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function runTunnel() {
|
|
118
|
+
const { startProxy } = await import('./proxy.js');
|
|
119
|
+
const { config } = await import('./config.js');
|
|
120
|
+
|
|
121
|
+
const token = mintToken();
|
|
122
|
+
const sessionCap = Number(process.env.OPENZOO_TUNNEL_MAX_USD || 1);
|
|
123
|
+
|
|
124
|
+
// The proxy enforces the gate itself: nothing is forwarded, quoted or paid
|
|
125
|
+
// without the bearer token, and the session ceiling stops spend cold.
|
|
126
|
+
const { spent } = await startProxy({
|
|
127
|
+
silent: true,
|
|
128
|
+
requireToken: token,
|
|
129
|
+
sessionMaxUsd: sessionCap,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const bin = await ensureCloudflared();
|
|
133
|
+
const { url, proc } = await startCloudflared(bin, config.port, console.log);
|
|
134
|
+
|
|
135
|
+
console.log('');
|
|
136
|
+
console.log(' ┌─────────────────────────────────────────────────────────────');
|
|
137
|
+
console.log(' │ THIS URL SPENDS REAL MONEY FROM YOUR WALLET.');
|
|
138
|
+
console.log(' │ The token below is the only thing protecting it.');
|
|
139
|
+
console.log(` │ Session ceiling: $${sessionCap.toFixed(2)} — then it stops paying.`);
|
|
140
|
+
console.log(' │ Ctrl-C when you are done; the URL dies with this process.');
|
|
141
|
+
console.log(' └─────────────────────────────────────────────────────────────');
|
|
142
|
+
console.log('');
|
|
143
|
+
console.log('point your cloud IDE at:');
|
|
144
|
+
console.log(` base_url = ${url}/v1`);
|
|
145
|
+
console.log(` api_key = ${token}`);
|
|
146
|
+
console.log('');
|
|
147
|
+
|
|
148
|
+
const shutdown = () => {
|
|
149
|
+
try { proc.kill('SIGTERM'); } catch { /* already gone */ }
|
|
150
|
+
const total = typeof spent === 'function' ? spent() : 0;
|
|
151
|
+
console.log(`\ntunnel closed — session spend $${total.toFixed(6)}`);
|
|
152
|
+
process.exit(0);
|
|
153
|
+
};
|
|
154
|
+
process.on('SIGINT', shutdown);
|
|
155
|
+
process.on('SIGTERM', shutdown);
|
|
156
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|