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 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 base = `http://localhost:${config.port}/v1`;
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
- console.error('openzoo: starting the proxy in the background...');
60
- const { startProxy } = await import('./proxy.js');
61
- await startProxy({ silent: true, autoTunnel: true });
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) { console.error(`openzoo: proxy did not come up on ${base}`); process.exit(1); }
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
- await new Promise((resolve, reject) => {
794
- server.on('error', reject);
795
- server.listen(config.port, bindHost, resolve);
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