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 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 THIS version on :8402 (steals stale)
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, readFileSync } from 'node:fs';
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
- if (!r.ok) return false;
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
- const system = si !== -1 ? process.argv[si + 1] : '';
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
- const { oursOn, packageVersion, killListen } = await import('./proxy.js');
291
- if (await oursOn(config.port)) {
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++) {
@@ -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 payment required (HTTP 402).';
3079
- data.error = data.error || {};
3080
- data.error.message = raw;
3078
+ || 'openzoo wallet underfunded.';
3081
3079
  try {
3082
- const { withOnrampLink, isFundInstruction } = await import('./stripeOnramp.js');
3083
- // Settle/upstream 402s keep the real message. Whop copy-paste only
3084
- // when this is a genuine empty-wallet / fund-me instruction.
3085
- if (isFundInstruction(raw)) {
3086
- const { loadOrCreateWallet } = await import('./wallet.js');
3087
- const w = loadOrCreateWallet();
3088
- const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
3089
- data.error.message = await withOnrampLink(raw, {
3090
- solana: w.keypair.publicKey.toBase58(),
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
- // NEVER hop. :8402 is the product. startProxy steals a stale listener.
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
- // (or a leftover npx cache is), boot THIS version on :8402 in this process.
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
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
202
- const mine = packageVersion();
203
- if (await oursOn(config.port)) {
204
- up = true;
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
- let base = `http://localhost:${config.port}/v1`;
583
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
584
- if (!(await oursOn(config.port))) {
585
- process.stderr.write(`openzoo: claiming :${config.port} for v${packageVersion()}\n`);
586
- await startProxy({ silent: true, autoTunnel: true });
587
- base = `http://localhost:${config.port}/v1`;
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, settleFailCopy, isFundInstruction } from './stripeOnramp.js';
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
- const n = Number(port);
47
- const seen = new Set();
48
- const addFrom = (out) => {
49
- for (const tok of String(out || '').split(/[^\d]+/)) {
50
- const pid = Number(tok);
51
- if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) seen.add(pid);
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 true;
114
- };
115
- for (const path of ['/v1/info', '/v1/session']) {
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 (paid && receipt) {
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
- // The client-side balance check is advisory, so a funded wallet can still
909
- // fail on-chain / at the facilitator, and the gateway answers the paid
910
- // retry with a fresh 402. Relaying that as "wallet underfunded" + Whop
911
- // copy-paste blamed burners that already paid. Surface the gateway's
912
- // real reason; only prepend fund-me copy on genuine insufficient_funds.
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
- usd = Number(q402?.accepts?.[0]?.extra?.billedUsd);
931
- if (!Number.isFinite(usd)) usd = undefined;
932
- } catch { usd = undefined; }
933
- const copy = settleFailCopy(q402);
934
- // paid:true never "wallet underfunded", never Whop unless the
935
- // gateway itself named insufficient_funds. UnderfundedError (empty
936
- // wallet preflight) is handled in the catch below.
937
- let msg = copy.message;
938
- const wantOnramp = copy.code === 'insufficient_funds'
939
- || (!paid && isFundInstruction(copy.reason, copy));
940
- if (wantOnramp) {
941
- msg = await withOnrampLink(msg, { solana: client.address, usd, code: copy.code });
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 :8402. Kill whoever is on THIS port, then bind it. Never hop to
978
- // 8403 that was the second-burner. Every subcommand that calls startProxy
979
- // (openzoo, claude, bot, web, cursor, aoe, grok, tunnel) goes through here.
980
- // Reusing a leftover listener left a stale PayClient serving $0 after a
981
- // TOKEN top-up, and an old npx cache answering /v1 with no version.
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 >= 8) throw e;
999
- const pids = killListen(config.port);
1000
- if (pids.length) say(`openzoo: killed proxy on :${config.port} (pids ${pids.join(',')})`);
1001
- await new Promise((r) => setTimeout(r, 300));
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 ? 'occupied (steal unless this exact version)' : 'free'}`);
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
- const { startProxy, oursOn, packageVersion } = await import('./proxy.js');
249
- if (await oursOn(config.port)) {
250
- console.log(`proxy v${packageVersion()} already on ${base} keeping it`);
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.
@@ -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;
@@ -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(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#x27;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/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.92",
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",