openzoo 0.50.89 → 0.50.91

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.
@@ -3075,17 +3075,20 @@ 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 wallet underfunded.';
3078
+ || 'openzoo payment required (HTTP 402).';
3079
+ data.error = data.error || {};
3080
+ data.error.message = raw;
3079
3081
  try {
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
- });
3082
+ const { withOnrampLink, isFundInstruction } = await import('./stripeOnramp.js');
3083
+ if (isFundInstruction(raw)) {
3084
+ const { loadOrCreateWallet } = await import('./wallet.js');
3085
+ const w = loadOrCreateWallet();
3086
+ const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
3087
+ data.error.message = await withOnrampLink(raw, {
3088
+ solana: w.keypair.publicKey.toBase58(),
3089
+ usd,
3090
+ });
3091
+ }
3089
3092
  } catch { /* keep proxy copy */ }
3090
3093
  }
3091
3094
  return { r, data };
package/lib/launch.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * harness with the two env vars set. The proxy must already be running
10
10
  * (`npx openzoo` in another terminal); we check first and say so if not.
11
11
  */
12
- import { spawn, spawnSync } from 'node:child_process';
12
+ import { spawn, spawnSync, execSync } from 'node:child_process';
13
13
  import fs from 'node:fs';
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
@@ -190,6 +190,41 @@ 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
+ /** Omarchy often has no lsof. Kill whatever is bound to the proxy port. */
195
+ function killPort(port) {
196
+ const n = Number(port);
197
+ for (const cmd of [
198
+ `lsof -t -iTCP:${n} -sTCP:LISTEN | xargs kill -9`,
199
+ `fuser -k ${n}/tcp`,
200
+ ]) {
201
+ try { execSync(cmd, { stdio: 'ignore', timeout: 2000, shell: true }); } catch { /* tool missing */ }
202
+ }
203
+ // /proc fallback — no lsof/fuser required (Omarchy).
204
+ try {
205
+ execSync(`python3 - ${n}`, {
206
+ stdio: 'ignore', timeout: 2500, input: `import os, glob, sys
207
+ port=int(sys.argv[1]); hx=f'{port:04X}'
208
+ inodes=set()
209
+ for path in ('/proc/net/tcp','/proc/net/tcp6'):
210
+ try:
211
+ for line in open(path):
212
+ p=line.split()
213
+ if len(p)<10: continue
214
+ if p[1].split(':')[-1].upper()==hx: inodes.add(p[9])
215
+ except FileNotFoundError:
216
+ pass
217
+ for fd in glob.glob('/proc/[0-9]*/fd/[0-9]*'):
218
+ try: t=os.readlink(fd)
219
+ except OSError: continue
220
+ if any(ino and ino!='0' and ino in t for ino in inodes):
221
+ try: os.kill(int(fd.split('/')[2]), 9)
222
+ except OSError: pass
223
+ `,
224
+ });
225
+ } catch { /* no python or not linux */ }
226
+ }
227
+
193
228
  export async function launchClaude(argv) {
194
229
  // let, not const: startProxy can heal onto a different port and every URL
195
230
  // below must follow the port we actually bound.
@@ -201,7 +236,22 @@ export async function launchClaude(argv) {
201
236
  // /info, not /models — see the poll below. "Is a proxy already listening" must
202
237
  // not be answered by an endpoint that needs the gateway, or a user whose
203
238
  // 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; }
239
+ const mine = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
240
+ try {
241
+ const r = await fetch(`${base}/info`, { signal: AbortSignal.timeout(3000) });
242
+ if (r.ok) {
243
+ const info = await r.json().catch(() => ({}));
244
+ const theirs = String(info.version || '');
245
+ if (!theirs || theirs !== mine) {
246
+ process.stderr.write(`openzoo: replacing stale proxy ${theirs ? 'v'+theirs : '(no version)'} with v${mine} on :${config.port}\n`);
247
+ killPort(config.port);
248
+ await new Promise((ok) => setTimeout(ok, 300));
249
+ up = false;
250
+ } else {
251
+ up = true;
252
+ }
253
+ }
254
+ } catch { up = false; }
205
255
  if (!up) {
206
256
  // NEVER GO SILENT DURING STARTUP. silent:true routes the proxy's own lines
207
257
  // to ~/.openzoo/proxy.log so payment receipts cannot corrupt Claude Code's
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 } from './stripeOnramp.js';
12
+ import { withOnrampLink, settleFailCopy, isFundInstruction } 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, unopenrouter } from './models.js';
16
+ import { modelsListForRequest, isHarnessAliasId, resolveModel, quoteableRows } from './models.js';
17
17
 
18
18
  /**
19
19
  * Quoteable catalog ids, cached 5 minutes, for the fuzzy /v1/models/<id> probe.
@@ -512,8 +512,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
512
512
  ? `${tunnelGate.publicUrl}/v1`
513
513
  : `http://localhost:${config.port}/v1`;
514
514
  res.writeHead(200, { 'content-type': 'application/json' });
515
+ const { version: ozVersion } = JSON.parse(
516
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
517
+ );
515
518
  res.end(JSON.stringify({
516
519
  youAreTalkingTo: 'openzoo proxy',
520
+ version: ozVersion,
521
+ solana: client.address,
517
522
  yourEndpoint: self,
518
523
  reachedVia: viaTunnel ? 'public tunnel' : 'localhost',
519
524
  publicTunnel: tunnelGate?.publicUrl ? `${tunnelGate.publicUrl}/v1` : null,
@@ -622,24 +627,6 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
622
627
  servedRequests += 1;
623
628
  say(`\n<- request #${servedRequests} from ${(req.headers['user-agent'] || 'unknown').slice(0, 40)}`);
624
629
  try { wantsStream = JSON.parse(bodyBuf.toString('utf8'))?.stream === true; } catch { /* not JSON */ }
625
- // UNOPENROUTER THE MODEL ID before anything downstream sees the body —
626
- // the replay key, the outage gate, the wire. A vendor-prefixed
627
- // OpenRouter spelling becomes the bare id the doors serve when the
628
- // catalog lists it (OPENZOO_UNOPENROUTER=1 forces it, =0 disables).
629
- // This is the one place the body is rewritten; see models.js.
630
- try {
631
- const parsed = JSON.parse(bodyBuf.toString('utf8'));
632
- if (parsed && typeof parsed.model === 'string') {
633
- let ids = [];
634
- try { ids = await catalogIdsCached(`${config.apiBase}/v1/models`, upstreamHeaders(req)); } catch { /* catalog unreachable: only the forced mode rewrites */ }
635
- const bare = unopenrouter(parsed.model, ids);
636
- if (bare) {
637
- say(` model ${parsed.model} -> ${bare} (bare id: doors, not OpenRouter)`);
638
- parsed.model = bare;
639
- bodyBuf = Buffer.from(JSON.stringify(parsed));
640
- }
641
- }
642
- } catch { /* not JSON */ }
643
630
  }
644
631
 
645
632
  // Retry of a body we answered seconds ago? Serve the cached completion —
@@ -847,14 +834,13 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
847
834
  rememberSpend();
848
835
  say(`credit -> $${x.billedUsd.toFixed(6)} · session $${sessionSpent.toFixed(6)}`);
849
836
  };
850
- // A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote
851
- // the client-side balance check is advisory, so a wallet that looks
852
- // fundable can still fail on-chain, and the gateway answers the paid
853
- // retry with a fresh 402. Relaying that raw meant Claude Code printed a
854
- // half-kilobyte accepts[] blob at the user instead of the one thing they
855
- // need: the price, what the wallet holds, and where to send funds.
837
+ // A 402 AFTER we attempted payment is a SETTLEMENT failure, not a quote.
838
+ // The client-side balance check is advisory, so a funded wallet can still
839
+ // fail on-chain / at the facilitator, and the gateway answers the paid
840
+ // retry with a fresh 402. Relaying that as "wallet underfunded" + Whop
841
+ // copy-paste blamed burners that already paid. Surface the gateway's
842
+ // real reason; only prepend fund-me copy on genuine insufficient_funds.
856
843
  if (response.status === 402) {
857
- let quoted = '';
858
844
  let usd;
859
845
  let q402 = null;
860
846
  try { q402 = await response.clone().json(); } catch { q402 = null; }
@@ -871,18 +857,21 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
871
857
  return;
872
858
  }
873
859
  try {
874
- const q = q402;
875
- usd = Number(q?.accepts?.[0]?.extra?.billedUsd);
876
- if (Number.isFinite(usd)) quoted = ` This call needs ≈$${usd.toFixed(4)}.`;
877
- } catch { /* body was not the quote after all */ }
878
- const msg = await withOnrampLink(
879
- `openzoo wallet underfunded payment did not settle.${quoted} `
880
- + `Fund it and retry: send USDC (or TOKEN/LEOS for half price) to ${client.address} on Solana, `
881
- + `or USDC to ${client.evmAddress} on Base. Check with: openzoo balance`,
882
- { solana: client.address, usd },
883
- );
884
- log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : 'onramp: no fund blurb');
885
- jsonErr(res, 402, msg);
860
+ usd = Number(q402?.accepts?.[0]?.extra?.billedUsd);
861
+ if (!Number.isFinite(usd)) usd = undefined;
862
+ } catch { usd = undefined; }
863
+ const copy = settleFailCopy(q402);
864
+ // paid:true never "wallet underfunded", never Whop unless the
865
+ // gateway itself named insufficient_funds. UnderfundedError (empty
866
+ // wallet preflight) is handled in the catch below.
867
+ let msg = copy.message;
868
+ const wantOnramp = copy.code === 'insufficient_funds'
869
+ || (!paid && isFundInstruction(copy.reason, copy));
870
+ if (wantOnramp) {
871
+ msg = await withOnrampLink(msg, { solana: client.address, usd, code: copy.code });
872
+ }
873
+ log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : `402: ${msg.slice(0, 140)}`);
874
+ jsonErr(res, paid ? copy.status : 402, msg);
886
875
  return;
887
876
  }
888
877
  await relay(res, response, meterStreamed);
@@ -103,8 +103,58 @@ 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
+
106
155
  export async function withOnrampLink(text, dest) {
107
156
  const body = String(text || '').trim();
157
+ if (!isFundInstruction(body, dest)) return body;
108
158
  const blurb = whopFundBlurb(dest?.solana);
109
159
  if (!blurb) return body;
110
160
  if (/ties to your account/i.test(body) && body.includes(String(dest.solana))) return body;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.89",
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.",
3
+ "version": "0.50.91",
4
+ "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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",
7
7
  "bin": {