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.
- package/lib/cursorbackend.js +13 -10
- package/lib/launch.js +52 -2
- package/lib/proxy.js +28 -39
- package/lib/stripeOnramp.js +50 -0
- package/package.json +2 -2
package/lib/cursorbackend.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
852
|
-
//
|
|
853
|
-
// retry with a fresh 402. Relaying that
|
|
854
|
-
//
|
|
855
|
-
//
|
|
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
|
-
|
|
875
|
-
usd =
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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);
|
package/lib/stripeOnramp.js
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "Local x402-paying proxy + MCP server for openzoo.fun
|
|
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": {
|