openzoo 0.48.22 → 0.48.24
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/info.js +70 -12
- package/lib/launch.js +38 -5
- package/lib/pay.js +60 -0
- 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/lib/wrap.js +18 -4
- 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/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
|
@@ -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/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/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
|