openzoo 0.50.92 → 0.50.94
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/claude-zoo.js +3 -28
- package/bin/openzoo.js +26 -2
- package/lib/aoe.js +2 -8
- package/lib/cursorbackend.js +10 -15
- package/lib/launch.js +24 -18
- package/lib/models.js +30 -0
- package/lib/proxy.js +86 -125
- package/lib/setup.js +13 -12
- package/lib/stripeOnramp.js +0 -50
- package/lib/websearch.js +31 -0
- package/package.json +1 -1
package/bin/claude-zoo.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* the local openzoo proxy on :8402 (x402 per-call payment, no Anthropic
|
|
7
7
|
* subscription, no login):
|
|
8
8
|
*
|
|
9
|
-
* - starts the proxy if it is not already
|
|
9
|
+
* - starts the proxy if it is not already listening
|
|
10
10
|
* - applies claudeZooEnv(): ANTHROPIC_BASE_URL=localhost:PORT/v1,
|
|
11
11
|
* AUTH_TOKEN=sk-openzoo, ANTHROPIC_API_KEY deleted, compaction disabled
|
|
12
12
|
* (the proxy binds the prefix), 1M-token ceiling restored
|
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
* `claude-code-cli` (same directory, symlinked to occ).
|
|
17
17
|
*/
|
|
18
18
|
import { spawn } from 'node:child_process';
|
|
19
|
-
import { existsSync, readdirSync as fsReaddir
|
|
20
|
-
import { execSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, readdirSync as fsReaddir } from 'node:fs';
|
|
21
20
|
import { homedir, platform } from 'node:os';
|
|
22
21
|
import { dirname, join, sep } from 'node:path';
|
|
23
22
|
import { fileURLToPath } from 'node:url';
|
|
@@ -51,34 +50,10 @@ if (!occ) {
|
|
|
51
50
|
const PROXY_PORT = Number(process.env.OPENZOO_PROXY_PORT || 8402);
|
|
52
51
|
const PROXY_URL = `http://localhost:${PROXY_PORT}/v1`;
|
|
53
52
|
|
|
54
|
-
function mineVersion() {
|
|
55
|
-
try {
|
|
56
|
-
return JSON.parse(readFileSync(join(shimRoot, 'package.json'), 'utf8')).version;
|
|
57
|
-
} catch { return ''; }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function stealPort(port) {
|
|
61
|
-
const n = Number(port);
|
|
62
|
-
for (const cmd of [
|
|
63
|
-
`lsof -t -iTCP:${n} -sTCP:LISTEN | xargs kill -9`,
|
|
64
|
-
`fuser -k ${n}/tcp`,
|
|
65
|
-
]) {
|
|
66
|
-
try { execSync(cmd, { stdio: 'ignore', timeout: 2000, shell: true }); } catch { /* missing */ }
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
53
|
async function proxyUp() {
|
|
71
54
|
try {
|
|
72
55
|
const r = await fetch(`http://localhost:${PROXY_PORT}/v1/info`, { signal: AbortSignal.timeout(1500) });
|
|
73
|
-
|
|
74
|
-
const j = await r.json().catch(() => ({}));
|
|
75
|
-
const mine = mineVersion();
|
|
76
|
-
if (mine && String(j.version || '') !== mine) {
|
|
77
|
-
console.error(`claude: stale proxy v${j.version || '?'} on :${PROXY_PORT} — stealing for v${mine}`);
|
|
78
|
-
stealPort(PROXY_PORT);
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
return true;
|
|
56
|
+
return r.ok;
|
|
82
57
|
} catch { return false; }
|
|
83
58
|
}
|
|
84
59
|
|
package/bin/openzoo.js
CHANGED
|
@@ -330,7 +330,7 @@ async function main() {
|
|
|
330
330
|
}
|
|
331
331
|
case 'ask': {
|
|
332
332
|
const question = process.argv[3];
|
|
333
|
-
if (!question) throw new Error('usage: openzoo ask "<question>" [--context <id>] [--model <id>] [--system <text>]');
|
|
333
|
+
if (!question) throw new Error('usage: openzoo ask "<question>" [--context <id>] [--model <id>] [--system <text>] [--web [--web-results N]]');
|
|
334
334
|
const ci = process.argv.indexOf('--context');
|
|
335
335
|
const mi = process.argv.indexOf('--model');
|
|
336
336
|
// A BARE QUESTION IS A DIFFERENT PRODUCT FROM A BRIEFED ONE.
|
|
@@ -344,7 +344,20 @@ async function main() {
|
|
|
344
344
|
// DHH, Hyprland and theming. Same gateway, same product, one had context.
|
|
345
345
|
// A caller that knows where it is running can now say so.
|
|
346
346
|
const si = process.argv.indexOf('--system');
|
|
347
|
-
|
|
347
|
+
let system = si !== -1 ? process.argv[si + 1] : '';
|
|
348
|
+
// --web: a keyless DuckDuckGo search, top results injected into THIS
|
|
349
|
+
// call's system prompt. The x402 rail strips OpenRouter's `plugins`
|
|
350
|
+
// field, so search-then-inject has to happen here, on the caller's
|
|
351
|
+
// side. EGRESS: the question text goes to duckduckgo.com. Also on with
|
|
352
|
+
// OPENZOO_ASK_WEB=1; --web-results N caps the count (default 5).
|
|
353
|
+
const wantWeb = process.argv.includes('--web') || process.env.OPENZOO_ASK_WEB === '1';
|
|
354
|
+
if (wantWeb) {
|
|
355
|
+
const wi = process.argv.indexOf('--web-results');
|
|
356
|
+
const n = wi !== -1 ? Number(process.argv[wi + 1]) || 5 : 5;
|
|
357
|
+
const { webSearch, formatWebResults } = await import('../lib/websearch.js');
|
|
358
|
+
const hits = await webSearch(question, n).catch((e) => { console.error(`web search failed: ${e.message}`); return []; });
|
|
359
|
+
if (hits.length) system = (system ? system + '\n\n' : '') + formatWebResults(question, hits);
|
|
360
|
+
}
|
|
348
361
|
const { PayClient } = await import('../lib/pay.js');
|
|
349
362
|
const { config } = await import('../lib/config.js');
|
|
350
363
|
const client = new PayClient();
|
|
@@ -389,6 +402,17 @@ async function main() {
|
|
|
389
402
|
case '-h':
|
|
390
403
|
console.log(HELP);
|
|
391
404
|
break;
|
|
405
|
+
case 'version':
|
|
406
|
+
case '--version':
|
|
407
|
+
case '-v':
|
|
408
|
+
case '-V': {
|
|
409
|
+
// Every installer, mise shim and shell script that probes a CLI asks
|
|
410
|
+
// this first; answering "unknown command" to it failed real installs.
|
|
411
|
+
const { readFileSync } = await import('fs');
|
|
412
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
413
|
+
console.log(pkg.version);
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
392
416
|
default:
|
|
393
417
|
console.error(`unknown command: ${cmd}\n`);
|
|
394
418
|
console.log(HELP);
|
package/lib/aoe.js
CHANGED
|
@@ -287,15 +287,9 @@ export async function setupAoe(argv = []) {
|
|
|
287
287
|
|
|
288
288
|
const base = `http://localhost:${config.port}/v1`;
|
|
289
289
|
if (!flags.has('--no-proxy')) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
console.error(`openzoo: proxy v${packageVersion()} already on ${base}`);
|
|
290
|
+
if (await proxyUp(base)) {
|
|
291
|
+
console.error(`openzoo: proxy already up on ${base}`);
|
|
293
292
|
} else {
|
|
294
|
-
if (await proxyUp(base)) {
|
|
295
|
-
console.error(`openzoo: stale proxy on ${base} — stealing :${config.port}`);
|
|
296
|
-
killListen(config.port);
|
|
297
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
298
|
-
}
|
|
299
293
|
const { pid, logPath } = startDetachedProxy({ tunnel: flags.has('--tunnel') });
|
|
300
294
|
let up = false;
|
|
301
295
|
for (let i = 0; i < 40 && !up; i++) {
|
package/lib/cursorbackend.js
CHANGED
|
@@ -3075,22 +3075,17 @@ async function zooComplete(prompt, log, agentId, parsed = {}, opts = {}) {
|
|
|
3075
3075
|
if (r.status === 402) {
|
|
3076
3076
|
const raw = zooTextFromMessage(data?.choices?.[0]?.message, data)
|
|
3077
3077
|
|| data?.error?.message
|
|
3078
|
-
|| 'openzoo
|
|
3079
|
-
data.error = data.error || {};
|
|
3080
|
-
data.error.message = raw;
|
|
3078
|
+
|| 'openzoo wallet underfunded.';
|
|
3081
3079
|
try {
|
|
3082
|
-
const { withOnrampLink
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
usd,
|
|
3092
|
-
});
|
|
3093
|
-
}
|
|
3080
|
+
const { withOnrampLink } = await import('./stripeOnramp.js');
|
|
3081
|
+
const { loadOrCreateWallet } = await import('./wallet.js');
|
|
3082
|
+
const w = loadOrCreateWallet();
|
|
3083
|
+
const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
|
|
3084
|
+
data.error = data.error || {};
|
|
3085
|
+
data.error.message = await withOnrampLink(raw, {
|
|
3086
|
+
solana: w.keypair.publicKey.toBase58(),
|
|
3087
|
+
usd,
|
|
3088
|
+
});
|
|
3094
3089
|
} catch { /* keep proxy copy */ }
|
|
3095
3090
|
}
|
|
3096
3091
|
return { r, data };
|
package/lib/launch.js
CHANGED
|
@@ -190,21 +190,18 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
|
|
|
190
190
|
* DEFAULT is the desktop app; `--terminal` (or `-t`) runs the Claude Code CLI.
|
|
191
191
|
* Both get ANTHROPIC_BASE_URL so inference pays x402.
|
|
192
192
|
*/
|
|
193
|
-
|
|
194
|
-
|
|
195
193
|
export async function launchClaude(argv) {
|
|
196
|
-
//
|
|
194
|
+
// let, not const: startProxy can heal onto a different port and every URL
|
|
195
|
+
// below must follow the port we actually bound.
|
|
197
196
|
let base = `http://localhost:${config.port}/v1`;
|
|
198
|
-
// AUTO-START THE PROXY. One command should just work — if nothing is listening
|
|
199
|
-
//
|
|
197
|
+
// AUTO-START THE PROXY. One command should just work — if nothing is listening,
|
|
198
|
+
// boot the proxy in THIS process (it stays alive because claude runs in the
|
|
199
|
+
// foreground below), rather than making the user run `npx openzoo` first.
|
|
200
200
|
let up = false;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
} else {
|
|
206
|
-
process.stderr.write(`openzoo: claiming :${config.port} for v${mine}\n`);
|
|
207
|
-
}
|
|
201
|
+
// /info, not /models — see the poll below. "Is a proxy already listening" must
|
|
202
|
+
// not be answered by an endpoint that needs the gateway, or a user whose
|
|
203
|
+
// upstream is flaky gets told to start a proxy that is already running.
|
|
204
|
+
try { up = (await fetch(`${base}/info`, { signal: AbortSignal.timeout(3000) })).ok; } catch { up = false; }
|
|
208
205
|
if (!up) {
|
|
209
206
|
// NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
|
|
210
207
|
// to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
|
|
@@ -221,6 +218,7 @@ export async function launchClaude(argv) {
|
|
|
221
218
|
tick.unref?.();
|
|
222
219
|
const done = (msg) => { clearInterval(tick); process.stderr.write(`\r\x1b[2Kopenzoo: ${msg}\n`); };
|
|
223
220
|
try {
|
|
221
|
+
const { startProxy } = await import('./proxy.js');
|
|
224
222
|
await startProxy({ silent: true, autoTunnel: true });
|
|
225
223
|
} catch (err) {
|
|
226
224
|
// An exception here used to surface as an eternal spinner. Say what broke.
|
|
@@ -229,6 +227,9 @@ export async function launchClaude(argv) {
|
|
|
229
227
|
console.error(' try: OPENZOO_NO_TUNNEL=1 npx openzoo claude (skips the cloudflared download)');
|
|
230
228
|
process.exit(1);
|
|
231
229
|
}
|
|
230
|
+
// The proxy may have healed onto a different port (8402 busy). config.port
|
|
231
|
+
// is the one it ACTUALLY bound, so re-derive every URL from it — the old
|
|
232
|
+
// code kept polling the port it wished for and timed out on a live proxy.
|
|
232
233
|
base = `http://localhost:${config.port}/v1`;
|
|
233
234
|
// PROBE /v1/info, NOT /v1/models. `models` is PROXIED UPSTREAM, so on a
|
|
234
235
|
// network with a bad path to the gateway the local proxy is listening and
|
|
@@ -579,12 +580,17 @@ export async function launchClaude(argv) {
|
|
|
579
580
|
}
|
|
580
581
|
|
|
581
582
|
export async function launchHarness(cmd, args) {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
583
|
+
const base = `http://localhost:${config.port}/v1`;
|
|
584
|
+
// Fail early with a clear message rather than letting the harness spew
|
|
585
|
+
// connection errors — the #1 support question would otherwise be "why won't
|
|
586
|
+
// claude connect" when the answer is "the proxy isn't up".
|
|
587
|
+
try {
|
|
588
|
+
const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(4000) });
|
|
589
|
+
if (!r.ok) throw new Error(String(r.status));
|
|
590
|
+
} catch {
|
|
591
|
+
console.error(`openzoo: no proxy reachable at ${base}`);
|
|
592
|
+
console.error('start it first in another terminal: npx openzoo');
|
|
593
|
+
process.exit(1);
|
|
588
594
|
}
|
|
589
595
|
|
|
590
596
|
const env = claudeZooEnv(process.env, { base });
|
package/lib/models.js
CHANGED
|
@@ -168,9 +168,39 @@ const tokensOf = (id) => id.toLowerCase().split(/[^a-z0-9.]+/).filter((t) => t &
|
|
|
168
168
|
* the id is already servable (no rewrite), otherwise the closest zoo id.
|
|
169
169
|
* OPENZOO_DEFAULT_MODEL is an explicit user override, not a fallback tier.
|
|
170
170
|
*/
|
|
171
|
+
/**
|
|
172
|
+
* UNOPENROUTER. OpenRouter is not an upstream any more (gateway, 2026-09-02):
|
|
173
|
+
* every completion is bought from an x402 door, and doors publish BARE ids
|
|
174
|
+
* (`grok-4.3`, `claude-sonnet-5`, `gemini-2.5-flash`). A harness that still
|
|
175
|
+
* sends the OpenRouter spelling (`x-ai/grok-4.3`, `anthropic/claude-sonnet-5`)
|
|
176
|
+
* is rewritten to the bare id whenever the live catalog serves that bare id.
|
|
177
|
+
*
|
|
178
|
+
* OPENZOO_UNOPENROUTER=1 strip the vendor prefix ALWAYS, catalog or not
|
|
179
|
+
* OPENZOO_UNOPENROUTER=0 never strip
|
|
180
|
+
* unset strip when the bare id is in the catalog
|
|
181
|
+
*
|
|
182
|
+
* Router aliases and `openzoo-` twins are never touched: their slash is not a
|
|
183
|
+
* vendor.
|
|
184
|
+
*/
|
|
185
|
+
export function unopenrouter(requested, ids) {
|
|
186
|
+
const mode = process.env.OPENZOO_UNOPENROUTER;
|
|
187
|
+
if (mode === '0') return null;
|
|
188
|
+
const s = String(requested || '');
|
|
189
|
+
if (!s || isAutoModel(s) || /^openzoo[-/]/i.test(s)) return null;
|
|
190
|
+
const slash = s.indexOf('/');
|
|
191
|
+
if (slash <= 0) return null;
|
|
192
|
+
const bare = s.slice(slash + 1);
|
|
193
|
+
if (!bare || bare.includes('/')) return null;
|
|
194
|
+
if (mode === '1') return bare;
|
|
195
|
+
return Array.isArray(ids) && ids.includes(bare) ? bare : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
171
198
|
export function resolveModel(requested, ids) {
|
|
172
199
|
// Virtual router id — never family-match, never steal via OPENZOO_DEFAULT_MODEL.
|
|
173
200
|
if (isAutoModel(requested)) return null;
|
|
201
|
+
// Vendor-prefixed OpenRouter spelling → the bare id the doors serve.
|
|
202
|
+
const bareVendor = unopenrouter(requested, ids);
|
|
203
|
+
if (bareVendor) return bareVendor;
|
|
174
204
|
// Bare Anthropic / Claude Code ids are never live on Fly/OpenRouter
|
|
175
205
|
// (`claude-opus-5` → 500 unknown model). Rewrite even on a catalog miss
|
|
176
206
|
// or if a gateway row lists the bare name — the request must not leave
|
package/lib/proxy.js
CHANGED
|
@@ -9,11 +9,11 @@ import {
|
|
|
9
9
|
} from './config.js';
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
11
|
import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
|
|
12
|
-
import { withOnrampLink
|
|
12
|
+
import { withOnrampLink } from './stripeOnramp.js';
|
|
13
13
|
import { tokenBalance } from './x402.js';
|
|
14
14
|
import { evmTokenBalance } from './evm.js';
|
|
15
15
|
import { autoContext } from './autobind.js';
|
|
16
|
-
import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows } from './models.js';
|
|
16
|
+
import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows, unopenrouter } from './models.js';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Quoteable catalog ids, cached 5 minutes, for the fuzzy /v1/models/<id> probe.
|
|
@@ -38,89 +38,19 @@ import { receiptUsedCogs, receiptDirectUsd, pairActualBilled } from './racesettl
|
|
|
38
38
|
import { fetchHeaders } from './fetch.js';
|
|
39
39
|
import { attachX402Proof } from './spendProof.js';
|
|
40
40
|
|
|
41
|
-
/** Kill whatever is LISTEN on this port except this process.
|
|
42
|
-
* lsof first (macOS), then fuser, then /proc (Omarchy/Arch with neither).
|
|
43
|
-
* Every openzoo subcommand that binds :8402 goes through this. Hopping to
|
|
44
|
-
* 8403 was the second-burner bug. */
|
|
41
|
+
/** Kill whatever is LISTEN on this port except this process. */
|
|
45
42
|
export function killListen(port, run = execSync) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
const tryRun = (cmd, opts = {}) => {
|
|
55
|
-
try {
|
|
56
|
-
return run(cmd, { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], ...opts });
|
|
57
|
-
} catch { return ''; }
|
|
58
|
-
};
|
|
59
|
-
addFrom(tryRun(`lsof -nP -iTCP:${n} -sTCP:LISTEN -t`));
|
|
60
|
-
addFrom(tryRun(`fuser -n tcp ${n}`));
|
|
61
|
-
if (process.platform === 'linux' && seen.size === 0) {
|
|
62
|
-
addFrom(tryRun(`python3 - ${n}`, {
|
|
63
|
-
timeout: 2500,
|
|
64
|
-
input: `import os, glob, sys
|
|
65
|
-
port=int(sys.argv[1]); hx=f'{port:04X}'
|
|
66
|
-
inodes=set(); pids=set()
|
|
67
|
-
for path in ('/proc/net/tcp','/proc/net/tcp6'):
|
|
68
|
-
try:
|
|
69
|
-
for line in open(path):
|
|
70
|
-
p=line.split()
|
|
71
|
-
if len(p)<10: continue
|
|
72
|
-
if p[1].split(':')[-1].upper()==hx: inodes.add(p[9])
|
|
73
|
-
except FileNotFoundError:
|
|
74
|
-
pass
|
|
75
|
-
for fd in glob.glob('/proc/[0-9]*/fd/[0-9]*'):
|
|
76
|
-
try: t=os.readlink(fd)
|
|
77
|
-
except OSError: continue
|
|
78
|
-
if any(ino and ino!='0' and ino in t for ino in inodes):
|
|
79
|
-
try: pids.add(int(fd.split('/')[2]))
|
|
80
|
-
except (OSError, ValueError): pass
|
|
81
|
-
print('\\n'.join(str(p) for p in pids if p != os.getpid()))
|
|
82
|
-
`,
|
|
83
|
-
}));
|
|
84
|
-
}
|
|
85
|
-
for (const pid of seen) {
|
|
86
|
-
try { run(`kill ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
|
|
87
|
-
try { run(`kill -9 ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
|
|
88
|
-
}
|
|
89
|
-
return [...seen];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** This package.json version. Same one /v1/info and /v1/session publish. */
|
|
93
|
-
export function packageVersion() {
|
|
94
|
-
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** True when the listener on this port is THIS openzoo version or newer.
|
|
98
|
-
* Missing / unparseable version is stale (0.49.x answered with no version).
|
|
99
|
-
* Older than us is stale. Newer we leave alone so we never downgrade. */
|
|
100
|
-
export async function oursOn(port = config.port) {
|
|
101
|
-
const mine = packageVersion();
|
|
102
|
-
const parse = (v) => {
|
|
103
|
-
const m = String(v || '').trim().match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
104
|
-
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
105
|
-
};
|
|
106
|
-
const attachable = (theirs) => {
|
|
107
|
-
const a = parse(theirs), b = parse(mine);
|
|
108
|
-
if (!a || !b) return false;
|
|
109
|
-
for (let i = 0; i < 3; i++) {
|
|
110
|
-
if (a[i] > b[i]) return true;
|
|
111
|
-
if (a[i] < b[i]) return false;
|
|
43
|
+
try {
|
|
44
|
+
const pids = run(`lsof -nP -iTCP:${Number(port)} -sTCP:LISTEN -t`, {
|
|
45
|
+
encoding: 'utf8', timeout: 2000,
|
|
46
|
+
}).trim().split('\n').map(Number).filter((n) => Number.isInteger(n) && n > 0 && n !== process.pid);
|
|
47
|
+
for (const pid of pids) {
|
|
48
|
+
try { run(`kill ${pid}`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
|
|
112
49
|
}
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
const r = await fetch(`http://127.0.0.1:${Number(port)}${path}`, { signal: AbortSignal.timeout(1500) });
|
|
118
|
-
if (!r.ok) continue;
|
|
119
|
-
const j = await r.json().catch(() => ({}));
|
|
120
|
-
if (attachable(j.version)) return true;
|
|
121
|
-
} catch { /* try next */ }
|
|
50
|
+
return pids;
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
122
53
|
}
|
|
123
|
-
return false;
|
|
124
54
|
}
|
|
125
55
|
|
|
126
56
|
// THE SHIM IS A FACILITATOR, NOT A MIDDLEBOX.
|
|
@@ -582,13 +512,8 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
582
512
|
? `${tunnelGate.publicUrl}/v1`
|
|
583
513
|
: `http://localhost:${config.port}/v1`;
|
|
584
514
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
585
|
-
const { version: ozVersion } = JSON.parse(
|
|
586
|
-
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
587
|
-
);
|
|
588
515
|
res.end(JSON.stringify({
|
|
589
516
|
youAreTalkingTo: 'openzoo proxy',
|
|
590
|
-
version: ozVersion,
|
|
591
|
-
solana: client.address,
|
|
592
517
|
yourEndpoint: self,
|
|
593
518
|
reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
|
|
594
519
|
publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
|
|
@@ -693,10 +618,43 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
693
618
|
const isPaidPost = req.method === 'POST'
|
|
694
619
|
&& /\/(chat\/completions|completions|messages|responses)$/.test(rawPath);
|
|
695
620
|
let wantsStream = false;
|
|
621
|
+
// RECEIPTS ARE OPT-OUT. `disableStats: true` in the body (or the
|
|
622
|
+
// x-openzoo-disable-stats header) means this caller does not want the x402
|
|
623
|
+
// block, so we neither attach our settle proof nor pass the gateway's
|
|
624
|
+
// receipt through. The field is deliberately FORWARDED, not stripped: the
|
|
625
|
+
// gateway reads it too and drops its own half. Absent/false keeps today's
|
|
626
|
+
// behaviour, because our own spend line reads that block.
|
|
627
|
+
let statsOff = false;
|
|
628
|
+
if (isPaidPost) {
|
|
629
|
+
try {
|
|
630
|
+
const b = JSON.parse(bodyBuf.toString('utf8'));
|
|
631
|
+
const h = req.headers['x-openzoo-disable-stats'];
|
|
632
|
+
const truthy = (v) => v === true || v === 'true' || v === '1' || v === 1;
|
|
633
|
+
statsOff = truthy(b?.disableStats) || truthy(Array.isArray(h) ? h[0] : h);
|
|
634
|
+
} catch { /* not JSON */ }
|
|
635
|
+
}
|
|
696
636
|
if (isPaidPost) {
|
|
697
637
|
servedRequests += 1;
|
|
698
638
|
say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
|
|
699
639
|
try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
|
|
640
|
+
// UNOPENROUTER THE MODEL ID before anything downstream sees the body —
|
|
641
|
+
// the replay key, the outage gate, the wire. A vendor-prefixed
|
|
642
|
+
// OpenRouter spelling becomes the bare id the doors serve when the
|
|
643
|
+
// catalog lists it (OPENZOO_UNOPENROUTER=1 forces it, =0 disables).
|
|
644
|
+
// This is the one place the body is rewritten; see models.js.
|
|
645
|
+
try {
|
|
646
|
+
const parsed = JSON.parse(bodyBuf.toString('utf8'));
|
|
647
|
+
if (parsed && typeof parsed.model === 'string') {
|
|
648
|
+
let ids = [];
|
|
649
|
+
try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* catalog unreachable: only the forced mode rewrites */ }
|
|
650
|
+
const bare = unopenrouter(parsed.model, ids);
|
|
651
|
+
if (bare) {
|
|
652
|
+
say(` model ${parsed.model} -> ${bare} (bare id: doors, not OpenRouter)`);
|
|
653
|
+
parsed.model = bare;
|
|
654
|
+
bodyBuf = Buffer.from(JSON.stringify(parsed));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
} catch { /* not JSON */ }
|
|
700
658
|
}
|
|
701
659
|
|
|
702
660
|
// Retry of a body we answered seconds ago? Serve the cached completion —
|
|
@@ -867,7 +825,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
867
825
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
868
826
|
}
|
|
869
827
|
if (data?.object === 'chat.completion') {
|
|
870
|
-
if (
|
|
828
|
+
if (statsOff) {
|
|
829
|
+
// The gateway already dropped its half; drop ours, and any block an
|
|
830
|
+
// older gateway in front of us still attached.
|
|
831
|
+
delete data.x402;
|
|
832
|
+
} else if (paid && receipt) {
|
|
871
833
|
attachX402Proof(data, {
|
|
872
834
|
tx: receipt.tx,
|
|
873
835
|
memo: receipt.memo || accept?.extra?.memo,
|
|
@@ -904,13 +866,14 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
904
866
|
rememberSpend();
|
|
905
867
|
say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
|
|
906
868
|
};
|
|
907
|
-
// A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote
|
|
908
|
-
//
|
|
909
|
-
// fail on-chain
|
|
910
|
-
// retry with a fresh 402. Relaying that
|
|
911
|
-
//
|
|
912
|
-
//
|
|
869
|
+
// A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote —
|
|
870
|
+
// the client-side balance check is advisory, so a wallet that looks
|
|
871
|
+
// fundable can still fail on-chain, and the gateway answers the paid
|
|
872
|
+
// retry with a fresh 402. Relaying that raw meant Claude Code printed a
|
|
873
|
+
// half-kilobyte accepts[] blob at the user instead of the one thing they
|
|
874
|
+
// need: the price, what the wallet holds, and where to send funds.
|
|
913
875
|
if (response.status === 402) {
|
|
876
|
+
let quoted = '';
|
|
914
877
|
let usd;
|
|
915
878
|
let q402 = null;
|
|
916
879
|
try { q402 = await response.clone().json(); } catch { q402 = null; }
|
|
@@ -927,21 +890,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
927
890
|
return;
|
|
928
891
|
}
|
|
929
892
|
try {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
}
|
|
943
|
-
log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
|
|
944
|
-
jsonErr(res, paid ? copy.status : 402, msg);
|
|
893
|
+
const q = q402;
|
|
894
|
+
usd = Number(q?.accepts?.[0]?.extra?.billedUsd);
|
|
895
|
+
if (Number.isFinite(usd)) quoted = ` This call needs ≈$${usd.toFixed(4)}.`;
|
|
896
|
+
} catch { /* body was not the quote after all */ }
|
|
897
|
+
const msg = await withOnrampLink(
|
|
898
|
+
`openzoo wallet underfunded — payment did not settle.${quoted} `
|
|
899
|
+
+ `Fund it and retry: send USDC (or TOKEN/LEOS for half price) to ${client.address} on Solana, `
|
|
900
|
+
+ `or USDC to ${client.evmAddress} on Base. Check with: openzoo balance`,
|
|
901
|
+
{ solana: client.address, usd },
|
|
902
|
+
);
|
|
903
|
+
log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : 'onramp: no fund blurb');
|
|
904
|
+
jsonErr(res, 402, msg);
|
|
945
905
|
return;
|
|
946
906
|
}
|
|
947
907
|
await relay(res, response, meterStreamed);
|
|
@@ -974,18 +934,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
974
934
|
// OPENZOO_BIND=0.0.0.0 AND a tunnel token, so that port stays gated exactly
|
|
975
935
|
// like the public tunnel path.
|
|
976
936
|
const bindHost = process.env.OPENZOO_BIND || '127.0.0.1';
|
|
977
|
-
// SELF-HEAL
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
|
|
982
|
-
{
|
|
983
|
-
const pids = killListen(config.port);
|
|
984
|
-
if (pids.length) {
|
|
985
|
-
say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
|
|
986
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
987
|
-
}
|
|
988
|
-
}
|
|
937
|
+
// SELF-HEAL A TAKEN PORT. Walk up to the next free port instead of dying;
|
|
938
|
+
// the caller reads config.port back out, so every URL printed afterwards is
|
|
939
|
+
// the one we actually bound. A healthy proxy already on the port is KILLED
|
|
940
|
+
// — reusing it left a stale PayClient serving $0 after a TOKEN top-up.
|
|
941
|
+
const wanted = config.port;
|
|
989
942
|
for (let attempt = 0; ; attempt++) {
|
|
990
943
|
try {
|
|
991
944
|
await new Promise((resolve, reject) => {
|
|
@@ -995,12 +948,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
|
|
|
995
948
|
});
|
|
996
949
|
break;
|
|
997
950
|
} catch (e) {
|
|
998
|
-
if (e?.code !== 'EADDRINUSE' || attempt >=
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
951
|
+
if (e?.code !== 'EADDRINUSE' || attempt >= 12) throw e;
|
|
952
|
+
if (attempt === 0) {
|
|
953
|
+
const pids = killListen(config.port);
|
|
954
|
+
if (pids.length) {
|
|
955
|
+
say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
|
|
956
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
config.port += 1;
|
|
961
|
+
say(`openzoo: :${config.port - 1} busy — trying :${config.port}`);
|
|
1002
962
|
}
|
|
1003
963
|
}
|
|
964
|
+
if (config.port !== wanted) say(`openzoo: listening on :${config.port} (:${wanted} was busy)`);
|
|
1004
965
|
|
|
1005
966
|
// AUTO-PREPAY. Paying on-chain per call is where the latency lives: credit
|
|
1006
967
|
// is applied automatically server-side whenever a balance covers the quote,
|
package/lib/setup.js
CHANGED
|
@@ -214,7 +214,7 @@ async function printStartupDiagnostic(base, which) {
|
|
|
214
214
|
|
|
215
215
|
console.log('openzoo diagnostic');
|
|
216
216
|
console.log(` version : ${version} node ${process.version} ${process.platform}/${process.arch}`);
|
|
217
|
-
console.log(` port ${config.port} : ${portBusy ? '
|
|
217
|
+
console.log(` port ${config.port} : ${portBusy ? 'ALREADY SERVING (an older proxy may still be running — kill it if this build is newer)' : 'free'}`);
|
|
218
218
|
console.log(` editor : ${picked ? `${picked.which} @ ${picked.cmd}` : 'NONE FOUND'}`);
|
|
219
219
|
console.log(` running : ${picked ? (q(() => editorRunning(picked.which), false) ? 'yes — will be quit so settings stick' : 'no') : '-'}`);
|
|
220
220
|
console.log(` backend : ${hosts}`);
|
|
@@ -245,17 +245,9 @@ export async function setupEditor(which, target) {
|
|
|
245
245
|
// Declared out here: the tunnel-rebind hook is registered further down, well
|
|
246
246
|
// outside the block that starts the proxy.
|
|
247
247
|
let started = null;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
try {
|
|
252
|
-
const info = await (await fetch(`${base}/info`)).json();
|
|
253
|
-
publicUrl = (info?.publicTunnel || '').replace(/\/v1$/, '') || null;
|
|
254
|
-
tunnelKey = info?.tunnelToken ?? tunnelKey;
|
|
255
|
-
if (publicUrl) console.log(`tunnel: ${publicUrl}/v1 (from the running proxy)`);
|
|
256
|
-
} catch { /* no /info */ }
|
|
257
|
-
} else {
|
|
258
|
-
console.log(`${(await proxyUp(base)) ? 'stale proxy — stealing' : 'starting proxy on'} ${base} (+ public tunnel)...`);
|
|
248
|
+
if (!(await proxyUp(base))) {
|
|
249
|
+
console.log(`starting proxy on ${base} (+ public tunnel)...`);
|
|
250
|
+
const { startProxy } = await import('./proxy.js');
|
|
259
251
|
started = await startProxy({ silent: true, autoTunnel: true });
|
|
260
252
|
publicUrl = started?.publicUrl ?? null;
|
|
261
253
|
tunnelKey = started?.tunnelToken ?? null;
|
|
@@ -306,6 +298,15 @@ export async function setupEditor(which, target) {
|
|
|
306
298
|
console.log(' most common cause here: no working IPv6 route (we already force');
|
|
307
299
|
console.log(' --edge-ip-version 4). check: npx openzoo tunnel for the raw log.');
|
|
308
300
|
}
|
|
301
|
+
} else {
|
|
302
|
+
console.log(`proxy already running on ${base}`);
|
|
303
|
+
// A proxy someone else started owns the tunnel; ask it for the public URL.
|
|
304
|
+
try {
|
|
305
|
+
const info = await (await fetch(`${base}/info`)).json();
|
|
306
|
+
publicUrl = (info?.publicTunnel || '').replace(/\/v1$/, '') || null;
|
|
307
|
+
tunnelKey = info?.tunnelToken ?? tunnelKey;
|
|
308
|
+
if (publicUrl) console.log(`tunnel: ${publicUrl}/v1 (from the running proxy)`);
|
|
309
|
+
} catch { /* no /info — fall through to the localhost warning below */ }
|
|
309
310
|
}
|
|
310
311
|
// What the EDITOR is configured with. Localhost only as a last resort, and
|
|
311
312
|
// said out loud, because it will fail with the private-networks error.
|
package/lib/stripeOnramp.js
CHANGED
|
@@ -103,58 +103,8 @@ export function whopFundBlurb(solana) {
|
|
|
103
103
|
].join('\n');
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
107
|
-
* Genuine empty-wallet / fund-me copy. A post-pay settle failure
|
|
108
|
-
* ("payment did not settle" with a gateway reason and no underfunded
|
|
109
|
-
* wording) is NOT this — those wallets are often funded; the 402 is
|
|
110
|
-
* the facilitator or upstream.
|
|
111
|
-
*/
|
|
112
|
-
export function isFundInstruction(text, extra = {}) {
|
|
113
|
-
const code = extra.code ?? extra.advice?.code;
|
|
114
|
-
if (String(code || '') === 'insufficient_funds') return true;
|
|
115
|
-
const s = String(text || '');
|
|
116
|
-
if (!s) return false;
|
|
117
|
-
if (/\b(?:wallet underfunded|empty wallet|wallet is empty|needs more than the wallet holds|insufficient[_\s]funds)\b/i.test(s)) return true;
|
|
118
|
-
if (/\bunderfunded\b/i.test(s)) return true;
|
|
119
|
-
if (/\bsend (?:usdc|a few cents)\b/i.test(s)) return true;
|
|
120
|
-
if (/\bno offered payment row is affordable/i.test(s)) return true;
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function gatewayReason(q402) {
|
|
125
|
-
if (!q402 || typeof q402 !== 'object') return '';
|
|
126
|
-
const err = q402.error;
|
|
127
|
-
const advice = q402.advice;
|
|
128
|
-
if (typeof err?.message === 'string' && err.message.trim()) return err.message.trim();
|
|
129
|
-
if (typeof err === 'string' && err.trim()) return err.trim();
|
|
130
|
-
if (typeof advice?.message === 'string' && advice.message.trim()) return advice.message.trim();
|
|
131
|
-
if (typeof advice === 'string' && advice.trim()) return advice.trim();
|
|
132
|
-
if (advice && typeof advice === 'object') {
|
|
133
|
-
const bits = [advice.code, advice.reason, advice.detail].filter((x) => typeof x === 'string' && x.trim());
|
|
134
|
-
if (bits.length) return bits.join(': ');
|
|
135
|
-
}
|
|
136
|
-
return '';
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Copy for a 402 AFTER PayClient already signed and retried (paid:true).
|
|
141
|
-
* Never "wallet underfunded" — that string is reserved for preflight
|
|
142
|
-
* empty-wallet errors. Prefix stays greppable as "payment did not settle".
|
|
143
|
-
*/
|
|
144
|
-
export function settleFailCopy(q402) {
|
|
145
|
-
const reason = gatewayReason(q402);
|
|
146
|
-
const code = q402?.advice?.code || q402?.error?.code || '';
|
|
147
|
-
const fund = isFundInstruction(reason, { code, advice: q402?.advice });
|
|
148
|
-
const message = reason
|
|
149
|
-
? `openzoo payment did not settle: ${reason}`
|
|
150
|
-
: 'openzoo payment did not settle';
|
|
151
|
-
const upstreamish = /upstream|facilitator|internal(?: server)? error|settle(?:ment)? (?:failed|error)/i.test(reason) && !fund;
|
|
152
|
-
return { message, status: upstreamish ? 502 : 402, fund, reason, code: String(code || '') };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
106
|
export async function withOnrampLink(text, dest) {
|
|
156
107
|
const body = String(text || '').trim();
|
|
157
|
-
if (!isFundInstruction(body, dest)) return body;
|
|
158
108
|
const blurb = whopFundBlurb(dest?.solana);
|
|
159
109
|
if (!blurb) return body;
|
|
160
110
|
if (/ties to your account/i.test(body) && body.includes(String(dest.solana))) return body;
|
package/lib/websearch.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Keyless web search for `openzoo ask --web`: DuckDuckGo's HTML endpoint,
|
|
2
|
+
// scraped for title / url / snippet. No API key, no account, one GET. The
|
|
3
|
+
// only thing that leaves is the question text, to duckduckgo.com.
|
|
4
|
+
const strip = (s) => String(s || '')
|
|
5
|
+
.replace(/<[^>]+>/g, '')
|
|
6
|
+
.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>')
|
|
7
|
+
.replace(/\s+/g, ' ').trim();
|
|
8
|
+
|
|
9
|
+
export async function webSearch(query, max = 5) {
|
|
10
|
+
const res = await fetch('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query), {
|
|
11
|
+
headers: { 'user-agent': 'Mozilla/5.0 openzoo-ask/1.0' },
|
|
12
|
+
signal: AbortSignal.timeout(12_000),
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) throw new Error(`duckduckgo HTTP ${res.status}`);
|
|
15
|
+
const html = await res.text();
|
|
16
|
+
const out = [];
|
|
17
|
+
const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
|
18
|
+
let m;
|
|
19
|
+
while ((m = re.exec(html)) && out.length < Math.max(1, Math.min(10, max))) {
|
|
20
|
+
let url = m[1];
|
|
21
|
+
const redirected = url.match(/uddg=([^&]+)/);
|
|
22
|
+
if (redirected) url = decodeURIComponent(redirected[1]);
|
|
23
|
+
out.push({ title: strip(m[2]), url, snippet: strip(m[3]).slice(0, 400) });
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatWebResults(query, hits) {
|
|
29
|
+
const lines = hits.map((h, i) => `${i + 1}. ${h.title} — ${h.url}\n ${h.snippet}`);
|
|
30
|
+
return `Web search results for "${query}" (DuckDuckGo, fetched just now; cite the url when you rely on one):\n${lines.join('\n')}`;
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.94",
|
|
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",
|