openzoo 0.48.22 → 0.48.23
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 +54 -0
- package/lib/launch.js +38 -5
- package/lib/proxy.js +106 -5
- package/lib/proxy.js.bak +980 -0
- package/lib/responses.js +425 -0
- package/lib/tunnel.js +8 -1
- package/package.json +1 -1
package/bin/openzoo.js
CHANGED
|
@@ -1,4 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// NODE >=18 OR NOTHING — AND HEAL IT IF WE CAN.
|
|
3
|
+
//
|
|
4
|
+
// package.json says engines >=18 but npm only WARNS, so an old node walked
|
|
5
|
+
// straight in and died somewhere useless: there is no global `fetch` before 18,
|
|
6
|
+
// so the very first probe threw, a bare `catch` swallowed it, and the user was
|
|
7
|
+
// left staring at "starting the proxy in the background..." forever with no
|
|
8
|
+
// error to report. Reported from the wild on macOS.
|
|
9
|
+
//
|
|
10
|
+
// Detect-and-exit is not enough when the machine usually HAS a good node sitting
|
|
11
|
+
// in nvm/homebrew and merely isn't using it. Find one and re-exec through it;
|
|
12
|
+
// only give up (with the exact fix) when there is genuinely nothing to run on.
|
|
13
|
+
// NOTE: plain 'fs'/'path' specifiers, not 'node:fs'. The node: prefix only
|
|
14
|
+
// resolves from 14.13.1, and this block's whole job is to run on the old
|
|
15
|
+
// runtime we are rejecting — an import error here would defeat the message.
|
|
16
|
+
import { existsSync, readdirSync } from 'fs';
|
|
17
|
+
import { execFileSync, spawnSync } from 'child_process';
|
|
18
|
+
import { homedir } from 'os';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
|
|
22
|
+
const MIN_NODE = 18;
|
|
23
|
+
if (Number(process.versions.node.split('.')[0]) < MIN_NODE && !process.env.OPENZOO_NODE_REEXEC) {
|
|
24
|
+
const candidates = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'].filter(existsSync);
|
|
25
|
+
// nvm keeps every install under ~/.nvm/versions/node/vNN.../bin/node —
|
|
26
|
+
// newest first, so we land on the best available rather than the oldest.
|
|
27
|
+
try {
|
|
28
|
+
const nvm = join(process.env.NVM_DIR || join(homedir(), '.nvm'), 'versions', 'node');
|
|
29
|
+
for (const v of readdirSync(nvm)
|
|
30
|
+
.filter((d) => /^v\d+/.test(d))
|
|
31
|
+
.sort((a, b) => parseInt(b.slice(1), 10) - parseInt(a.slice(1), 10))) {
|
|
32
|
+
const p = join(nvm, v, 'bin', 'node');
|
|
33
|
+
if (existsSync(p)) candidates.push(p);
|
|
34
|
+
}
|
|
35
|
+
} catch { /* no nvm, fine */ }
|
|
36
|
+
|
|
37
|
+
for (const node of candidates) {
|
|
38
|
+
let v = 0;
|
|
39
|
+
try { v = Number(execFileSync(node, ['-v'], { encoding: 'utf8' }).trim().slice(1).split('.')[0]); } catch { continue; }
|
|
40
|
+
if (v < MIN_NODE) continue;
|
|
41
|
+
console.error(`openzoo: node ${process.versions.node} is too old (need >=${MIN_NODE}) — re-running under ${node} (v${v})`);
|
|
42
|
+
const r = spawnSync(node, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
|
|
43
|
+
stdio: 'inherit',
|
|
44
|
+
env: { ...process.env, OPENZOO_NODE_REEXEC: '1' },
|
|
45
|
+
});
|
|
46
|
+
process.exit(r.status === null ? 1 : r.status);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.error(`openzoo: needs Node >=${MIN_NODE}, you are on ${process.versions.node}.`);
|
|
50
|
+
console.error(' no newer node found on this machine. install one, then re-run:');
|
|
51
|
+
console.error(' brew install node # macOS');
|
|
52
|
+
console.error(' nvm install 20 && nvm use 20 # if you use nvm');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
2
56
|
const cmd = process.argv[2] || 'proxy';
|
|
3
57
|
|
|
4
58
|
const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
|
package/lib/launch.js
CHANGED
|
@@ -49,21 +49,54 @@ function resolveClaudeCli() {
|
|
|
49
49
|
* Both get ANTHROPIC_BASE_URL so inference pays x402.
|
|
50
50
|
*/
|
|
51
51
|
export async function launchClaude(argv) {
|
|
52
|
-
const
|
|
52
|
+
// let, not const: startProxy can heal onto a different port and every URL
|
|
53
|
+
// below must follow the port we actually bound.
|
|
54
|
+
let base = `http://localhost:${config.port}/v1`;
|
|
53
55
|
// AUTO-START THE PROXY. One command should just work — if nothing is listening,
|
|
54
56
|
// boot the proxy in THIS process (it stays alive because claude runs in the
|
|
55
57
|
// foreground below), rather than making the user run `npx openzoo` first.
|
|
56
58
|
let up = false;
|
|
57
59
|
try { up = (await fetch(`${base}/models`, { signal: AbortSignal.timeout(3000) })).ok; } catch { up = false; }
|
|
58
60
|
if (!up) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
|
|
62
|
+
// to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
|
|
63
|
+
// stdio — correct DURING the session, wrong BEFORE it, because it made a
|
|
64
|
+
// slow step and a dead step look identical. Reported from the wild as
|
|
65
|
+
// "always gets stuck at 'starting the proxy in the background...'": there
|
|
66
|
+
// was nothing on screen for up to 46s and then, at worst, one terse line.
|
|
67
|
+
// A ticking elapsed counter is the difference between "hung" and "working".
|
|
68
|
+
process.stderr.write('openzoo: starting the proxy...');
|
|
69
|
+
const t0 = Date.now();
|
|
70
|
+
const tick = setInterval(() => {
|
|
71
|
+
process.stderr.write(`\ropenzoo: starting the proxy... ${((Date.now() - t0) / 1000).toFixed(0)}s`);
|
|
72
|
+
}, 1000);
|
|
73
|
+
tick.unref?.();
|
|
74
|
+
const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
|
|
75
|
+
try {
|
|
76
|
+
const { startProxy } = await import('./proxy.js');
|
|
77
|
+
await startProxy({ silent: true, autoTunnel: true });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// An exception here used to surface as an eternal spinner. Say what broke.
|
|
80
|
+
done(`proxy failed to start: ${err?.message || err}`);
|
|
81
|
+
console.error(' full log: ~/.openzoo/proxy.log');
|
|
82
|
+
console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
// The proxy may have healed onto a different port (8402 busy). config.port
|
|
86
|
+
// is the one it ACTUALLY bound, so re-derive every URL from it — the old
|
|
87
|
+
// code kept polling the port it wished for and timed out on a live proxy.
|
|
88
|
+
base = `http://localhost:${config.port}/v1`;
|
|
62
89
|
for (let i = 0; i < 20 && !up; i++) {
|
|
63
90
|
await new Promise((r) => setTimeout(r, 300));
|
|
64
91
|
try { up = (await fetch(`${base}/models`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* keep waiting */ }
|
|
65
92
|
}
|
|
66
|
-
if (!up) {
|
|
93
|
+
if (!up) {
|
|
94
|
+
done(`proxy did not answer on ${base}`);
|
|
95
|
+
console.error(' full log: ~/.openzoo/proxy.log');
|
|
96
|
+
console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
done(`proxy up on :${config.port} (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
|
|
67
100
|
}
|
|
68
101
|
// TERMINAL (Claude Code CLI) IS THE DEFAULT — it is the guaranteed-x402 path
|
|
69
102
|
// and honours ANTHROPIC_BASE_URL. --desktop explicitly opens the desktop app
|
package/lib/proxy.js
CHANGED
|
@@ -16,6 +16,7 @@ import { forgetContext } from './contexts.js';
|
|
|
16
16
|
import { injectBrief } from './brief.js';
|
|
17
17
|
import { withNamespace } from './namespace.js';
|
|
18
18
|
import { anthropicToOpenAI, openAIToAnthropic, writeAnthropicSse } from './anthropic.js';
|
|
19
|
+
import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
|
|
19
20
|
|
|
20
21
|
const HOP_BY_HOP = new Set([
|
|
21
22
|
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade',
|
|
@@ -41,7 +42,7 @@ const HOP_BY_HOP = new Set([
|
|
|
41
42
|
function normalizePath(url) {
|
|
42
43
|
const [path, query] = (url || '/').split(/(?=\?)/);
|
|
43
44
|
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
|
+
if (!/^\/v1(\/|$)/.test(p) && /^\/(hrr|chat|models|completions|embeddings|usage|responses)/.test(p)) p = `/v1${p}`;
|
|
45
46
|
return p === path ? url : `${p}${query || ''}`;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -547,7 +548,51 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
547
548
|
// answer back on the way out. See lib/anthropic.js.
|
|
548
549
|
let anthropicMode = false;
|
|
549
550
|
let anthropicModel = null;
|
|
551
|
+
let responsesMode = false;
|
|
552
|
+
let responsesModel = null;
|
|
553
|
+
let responsesCustom = null; // names of freeform tools needing custom_tool_call on the way back
|
|
550
554
|
const rawPath = (req.url || '').split('?')[0];
|
|
555
|
+
|
|
556
|
+
// RESPONSES API. Some harnesses speak only this wire format — OpenAI's
|
|
557
|
+
// Codex Security CLI pins `wire_api: "responses"` in its provider table, so
|
|
558
|
+
// a bare 404 here made it fall back to wss://api.openai.com and bypass the
|
|
559
|
+
// proxy entirely while reporting the failure as an auth error. Translate
|
|
560
|
+
// in, translate out; everything between stays on the chat path.
|
|
561
|
+
if (req.method === 'POST' && (rawPath === '/v1/responses' || rawPath === '/responses')) {
|
|
562
|
+
try {
|
|
563
|
+
const inbound = JSON.parse(bodyBuf.toString('utf8'));
|
|
564
|
+
// TEMP CAPTURE: dump the first few Responses requests so the agent's
|
|
565
|
+
// actual wire usage (store / previous_response_id / tool shapes) can be
|
|
566
|
+
// read rather than inferred. Guarded by an env var so it is off unless
|
|
567
|
+
// asked for.
|
|
568
|
+
if (process.env.OZ_CAPTURE_RESPONSES) {
|
|
569
|
+
try {
|
|
570
|
+
const fs = await import('node:fs');
|
|
571
|
+
const dir = process.env.OZ_CAPTURE_RESPONSES;
|
|
572
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
573
|
+
const n = fs.readdirSync(dir).length;
|
|
574
|
+
// Capture the LATER turns too. Turn 1 is already understood; the
|
|
575
|
+
// unknown is what codex sends back after running a tool, so bias
|
|
576
|
+
// the capture toward requests that carry a tool result.
|
|
577
|
+
const hasResult = Array.isArray(inbound.input)
|
|
578
|
+
&& inbound.input.some((i) => i && String(i.type || '').endsWith('_call_output'));
|
|
579
|
+
const tag = hasResult ? 'result' : 'plain';
|
|
580
|
+
if (n < 24) fs.writeFileSync(`${dir}/${tag}-${n}.json`, JSON.stringify(inbound, null, 2));
|
|
581
|
+
} catch { /* capture must never break a paid call */ }
|
|
582
|
+
}
|
|
583
|
+
responsesModel = inbound.model;
|
|
584
|
+
const meta = {};
|
|
585
|
+
bodyBuf = Buffer.from(JSON.stringify(responsesToChat(inbound, meta)));
|
|
586
|
+
responsesCustom = meta.custom;
|
|
587
|
+
responsesMode = true;
|
|
588
|
+
req.url = '/v1/chat/completions';
|
|
589
|
+
url = `${config.apiBase}${req.url}`;
|
|
590
|
+
} catch {
|
|
591
|
+
jsonErr(res, 400, 'invalid responses body');
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
551
596
|
if (req.method === 'POST' && (rawPath === '/v1/messages' || rawPath === '/messages')) {
|
|
552
597
|
try {
|
|
553
598
|
const inbound = JSON.parse(bodyBuf.toString('utf8'));
|
|
@@ -604,6 +649,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
604
649
|
const hit = replayGet(rKey);
|
|
605
650
|
if (hit) {
|
|
606
651
|
log('identical request within 30s — served the cached completion, NOT re-paid');
|
|
652
|
+
// The cache stores the CHAT shape. A Responses caller must get its own
|
|
653
|
+
// wire format back, or the replay path silently answers in a format the
|
|
654
|
+
// client cannot parse — a bug that only appears on the SECOND identical
|
|
655
|
+
// request, which is exactly when nobody is watching.
|
|
656
|
+
if (responsesMode) {
|
|
657
|
+
if (wantsStream) { writeResponsesSse(res, hit.data, responsesModel, null, responsesCustom); return; }
|
|
658
|
+
const rh = { 'content-type': 'application/json' };
|
|
659
|
+
if (hit.settle) rh['x-payment-response'] = hit.settle;
|
|
660
|
+
res.writeHead(200, rh);
|
|
661
|
+
res.end(JSON.stringify(chatToResponses(hit.data, responsesModel, responsesCustom)));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
607
664
|
if (wantsStream) { serveAsSse(res, hit.data, null); return; }
|
|
608
665
|
const h = { 'content-type': 'application/json' };
|
|
609
666
|
if (hit.settle) h['x-payment-response'] = hit.settle;
|
|
@@ -752,6 +809,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
752
809
|
res.end(JSON.stringify(msg));
|
|
753
810
|
return;
|
|
754
811
|
}
|
|
812
|
+
if (responsesMode) {
|
|
813
|
+
// A Responses client that asked to stream is WAITING for
|
|
814
|
+
// `response.completed`; handing it a JSON body closes the socket
|
|
815
|
+
// mid-stream and it reports "stream disconnected before
|
|
816
|
+
// completion". Honour the streaming contract when it asked for it.
|
|
817
|
+
if (wantsStream) { writeResponsesSse(res, data, responsesModel, response, responsesCustom); return; }
|
|
818
|
+
const out = chatToResponses(data, responsesModel, responsesCustom);
|
|
819
|
+
const h = { 'content-type': 'application/json' };
|
|
820
|
+
const settleHdr = response.headers.get('x-payment-response');
|
|
821
|
+
if (settleHdr) h['x-payment-response'] = settleHdr;
|
|
822
|
+
res.writeHead(200, h);
|
|
823
|
+
res.end(JSON.stringify(out));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
755
826
|
if (wantsStream) { serveAsSse(res, data, response); return; }
|
|
756
827
|
const h = { 'content-type': 'application/json' };
|
|
757
828
|
const settleHdr = response.headers.get('x-payment-response');
|
|
@@ -790,10 +861,40 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
790
861
|
// AND a tunnel token, so the RunPod-fronted port stays gated exactly like the
|
|
791
862
|
// public tunnel path.
|
|
792
863
|
const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
864
|
+
// SELF-HEAL A TAKEN PORT. A killed-but-not-reaped run, a second terminal, or
|
|
865
|
+
// anything else already on 8402 made listen() reject and took the whole start
|
|
866
|
+
// down — and because `openzoo claude` starts us with silent:true, the user saw
|
|
867
|
+
// only "starting the proxy in the background..." and no reason. Walk up to the
|
|
868
|
+
// next free port instead of dying; the caller reads config.port back out, so
|
|
869
|
+
// every URL printed afterwards is the one we actually bound.
|
|
870
|
+
//
|
|
871
|
+
// EXCEPTION: if the thing already on the port is a HEALTHY openzoo proxy,
|
|
872
|
+
// reuse it rather than starting a rival that splits spend across two wallets.
|
|
873
|
+
const wanted = config.port;
|
|
874
|
+
for (let attempt = 0; ; attempt++) {
|
|
875
|
+
try {
|
|
876
|
+
await new Promise((resolve, reject) => {
|
|
877
|
+
const onErr = (e) => { server.removeListener('error', onErr); reject(e); };
|
|
878
|
+
server.on('error', onErr);
|
|
879
|
+
server.listen(config.port, bindHost, () => { server.removeListener('error', onErr); resolve(); });
|
|
880
|
+
});
|
|
881
|
+
break;
|
|
882
|
+
} catch (e) {
|
|
883
|
+
if (e?.code !== 'EADDRINUSE' || attempt >= 12) throw e;
|
|
884
|
+
if (attempt === 0) {
|
|
885
|
+
try {
|
|
886
|
+
const probe = await fetch(`http://127.0.0.1:${config.port}/v1/models`, { signal: AbortSignal.timeout(2500) });
|
|
887
|
+
if (probe.ok) {
|
|
888
|
+
say(`openzoo: a healthy proxy is already on :${config.port} — reusing it`);
|
|
889
|
+
return { server: null, client, reused: true, port: config.port, spent: () => 0, publicUrl: null, tunnelToken: null, tunnelError: null };
|
|
890
|
+
}
|
|
891
|
+
} catch { /* not ours, or wedged — take the next port */ }
|
|
892
|
+
}
|
|
893
|
+
config.port += 1;
|
|
894
|
+
say(`openzoo: :${config.port - 1} busy — trying :${config.port}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
|
|
797
898
|
|
|
798
899
|
// AUTO-PREPAY. Paying on-chain per call is where the latency lives: the
|
|
799
900
|
// gateway answers its 402 challenge in ~0.12s while a full settled call
|